08 / Components

Built against the contract

Every specimen below is a real control — tab into it, press it, hover it, resize the page. Each carries the anatomy attributes rather than class names, and every value comes from the token contract.

Button — intent × emphasis

Two axes and one geometry. Four intents say what an action means, three emphases say how loudly — twelve combinations from one set of measurements, each with optional leading and trailing icon slots. Each intent declares a single colour — the same value the contract uses for status text, since one colour serves as both text and fill — and hover, press and the soft tints are derived from it with color-mix. Four colour values in the whole component rather than twenty-four, plus --se-text-on-intent for the label, which a skin with a pale fill flips to dark.

Intent × emphasis

accent The one action a view exists for. Never twice on a screen.
positive Confirms, approves, publishes. Not a synonym for accent.
negative Destroys something the reader cannot get back.
neutral Everything else. Most buttons in the product are these.

Two independent axes. Intent says what the action means; emphasis says how loudly to say it. All three carry a 1px border — solid draws it in its own fill colour, transparent draws it invisible — so the three measure identically and swapping emphasis never shifts a toolbar by a pixel. Icons are Phosphor bold, a step up from the regular set, because at 1.15em a regular stroke reads lighter than the medium-weight label beside it.

Icons — leading, trailing, both, alone

A leading icon names the thing being acted on; a trailing icon names where you are going next, which is why arrows and carets only ever trail. Icon-only buttons drop to square padding, carry an aria-label, and are marked data-icon-only — the one case where the touch floor has to apply on both axes, since a label normally carries the width and an icon cannot.

Sizes and states

Three things step together: the height token, the inline padding and the type step — 32px at --se-text-xs, 40px at --se-text-sm, 48px at --se-text-md. Height comes from --se-control-h rather than accumulating out of padding, which is what keeps it identical in every state. The radius stays em so it stays proportional, and the icon holds at --se-icon-sm so glyph sizes stay on the published ladder. Disabled collapses all twelve combinations to one appearance, because a disabled button that still looks negative or accent invites the click it will not accept.

<button data-se="button" data-intent="accent" data-emphasis="solid" data-size="md">
  <svg data-part="icon" aria-hidden="true"></svg>   <!-- leading -->
  <span data-part="label">Publish portfolio</span>
  <svg data-part="icon" aria-hidden="true"></svg>   <!-- trailing -->
</button>

/* geometry — one set for all twelve combinations */
[data-se="button"] {
  font-family: var(--se-font-sans);
  font-weight: var(--se-weight-medium);
  line-height: var(--se-leading-ui);
  font-size: calc(var(--se-text-sm) * var(--se-font-scale));
  padding-block: 0;
  padding-inline: calc(var(--se-space-4) * var(--se-density));
  gap: calc(0.5em * var(--se-density));
  border-style: solid;
  border-radius: 0.45em;
  block-size: var(--se-control-h);   /* never min- : no state may change it */
}
[data-se="button"][data-size="sm"] { block-size: var(--se-control-h-sm); padding-inline: var(--se-space-3); font-size: var(--se-text-xs); }
[data-se="button"][data-size="lg"] { block-size: var(--se-control-h-lg); padding-inline: var(--se-space-5); font-size: var(--se-text-md); }
[data-se="button"] [data-part="icon"] { inline-size: var(--se-icon-sm); block-size: var(--se-icon-sm); flex: none; }

/* emphasis owns the border, intent owns one colour, states derive */
[data-se="button"][data-emphasis="solid"]   { border-width: var(--se-border-width); }
[data-se="button"][data-emphasis="outline"] { border-width: var(--se-border-width); background: transparent; }
[data-se="button"][data-emphasis="transparent"]    { border-width: var(--se-border-width); border-color: transparent; background: transparent; }

[data-se="button"][data-intent="positive"] { --se-intent: var(--se-positive); }

[data-se="button"][data-emphasis="solid"] {
  background: var(--se-intent);
  border-color: var(--se-intent);
  color: var(--se-text-on-intent);
}
[data-se="button"][data-emphasis="solid"]:hover:not(:disabled) {
  background: color-mix(in oklch, var(--se-intent) var(--se-mix-hover), #262934);
}
[data-se="button"][data-emphasis="outline"]:hover:not(:disabled) {
  background: color-mix(in oklch, var(--se-intent) var(--se-mix-soft), var(--se-bg));
}

/* three attributes deep, or the intent rules outrank it */
[data-se="button"][data-emphasis][data-intent]:disabled {
  background: var(--se-surface-sunk);
  border-color: var(--se-border);
  color: var(--se-text-disabled);
  cursor: not-allowed;
}
@media (pointer: coarse) {
  [data-se="button"] { block-size: var(--se-target-min); }
  /* a label carries its own width; an icon cannot */
  [data-se="button"][data-icon-only] { inline-size: var(--se-target-min); }
}

Height is a token, not an outcome

Every single-line control sets block-size from --se-control-h rather than accumulating a height out of line-height plus padding plus border. That is what makes the height identical in every state: a border growing from 1px to 2px is absorbed inward by border-box, an outline never participates in layout, and padding-inline only moves the text. Buttons and fields read the same token, so they sit level in a row, and one override under (pointer: coarse) lifts all of them to 44px.

Base colour and state colour must share a tier

An inline style outranks any stylesheet rule without !important, so a variant colour set inline makes every :hover, :active and :disabled rule unreachable. Geometry stays inline so specimens paint immediately; colour lives in the stylesheet with the states that override it.

The touch floor is a media query, not a max()

Wrapping every size in max(2.75rem, …) makes small, medium and large the same height on a desktop — destroying the size scale to solve a problem that only exists for fingers. The floor belongs under (pointer: coarse), where it is true.

There is no primary, secondary or tertiary

Those three names conflate two independent questions — primary means both loudest and accent-coloured, which leaves no way to describe a loud destructive action without inventing something like secondary danger. Emphasis answers how loudly, intent answers what it means, and the familiar tiers fall out of the pair:

primary → accent + solid

secondary → neutral + outline

tertiary → neutral + transparent

Splitting them is what makes negative + solid available as the loudest destructive action, and accent + transparent available as a quiet action that still reads as the main intent. If a team adds a data-variant="primary" alongside these, the hierarchy has two sources and they will disagree within a release.

Emphasis carries the border, not the fill

All three emphases draw the same 1px border — outline and solid in colour, transparent invisible. That is what makes them interchangeable in place: a toolbar does not reflow when a button changes emphasis. It is also the one border weight the contract already names, so the button adds nothing of its own.

An icon-only button still needs a name

The icon carries aria-hidden and the button carries an aria-label. That is the anatomy’s responsibility — a skin has no way to add an accessible name, so it cannot be left to one.

Input — the label is part of the component

A field is label, control, and one message slot that is either a description or an error, never both. Bundling them is deliberate: a control whose label is somebody else’s problem is a control that ships without one.

Field states

Point a CNAME at studioedit.app
Domains cannot contain spaces
Verified 4 minutes ago

No icon in the label and none in front of the text — a label icon repeats what the label already says, and the reserved empty slot it needed left an indent that read as a mistake on the fields without one. What stays is the message row, which keeps its height when empty. That alone is what aligns a row: all four controls share a baseline and all four fields end at the same depth.

The trailing slot inside the control

Clears the filter

The one icon position that earns its place. It reports state the label cannot — valid, invalid, locked — or offers an action on the value itself: clear, reveal, copy. The control gains 2.4em of end padding so text never runs under it, and the icon is pointer-events: none unless it is genuinely a button.

Select and textarea — where the height token stops applying

Unlisted pages stay out of search results

Select takes the same 40px --se-control-h as the inputs, so a form of mixed controls sits on one rhythm. It also drops the native arrow with appearance: none and draws a caret in the adornment slot, because the platform arrow cannot be recoloured or tokenised.

Textarea is the documented exemption. It is multi-line, so a fixed block-size would clip it — it takes padding-block from the space scale and grows with its content instead. It is also the only control on --se-leading-body rather than --se-leading-ui, because it holds prose rather than a single value. Both facts are exceptions worth stating: a rule that never names where it stops is a rule people apply somewhere it breaks.

Every state, side by side

Rest

Border --se-border-strong. Placeholder --se-text-placeholder.

Hover

Border darkens to --se-border-hover. Wrapped in (hover: hover).

Focus

2px --se-accent ring at 2px offset, from :focus-visible.

Filled

No separate style — a value is not a state.

Invalid

data-invalid on the field. Border and adornment --se-negative.

Valid

Confirmed, not merely non-empty. Adornment --se-positive.

Read-only

Sunk fill, real border, text at full contrast. Still focusable.

Disabled

Sunk fill, --se-text-disabled, not focusable, not-allowed.

Hover and focus are drawn here with a data-force attribute so both are visible at once — every other cell is the genuine state, and you can tab through them to see focus behave for real. Read-only and disabled look alike deliberately but differ where it matters: read-only keeps full text contrast and stays in the tab order, because its value is information the reader may want to copy.

<div data-se="field" data-invalid>
  <label data-part="label" for="domain">Custom domain</label>
  <input data-part="control" id="domain"
         aria-invalid="true" aria-describedby="domain-msg">
  <span data-part="error" id="domain-msg"></span>
</div>

[data-se="field"] [data-part="control"] {
  border: var(--se-border-width) solid #D6D1C9;
  border-radius: 0.45em;
  padding-block: 0;
  padding-inline: var(--se-space-3);
  block-size: var(--se-control-h);
}
[data-se="field"] [data-part="control"]::placeholder { color: var(--se-text-placeholder); }
[data-se="field"][data-invalid] [data-part="control"] { border-color: var(--se-negative); }
[data-se="field"][data-disabled] [data-part="label"]  { color: var(--se-text-disabled); }

Error is an attribute on the field, not a colour on the border

data-invalid on the wrapper lets a skin restyle label, control and message together. A skin that could only recolour the border would have no way to make an error legible in a high-contrast theme.

Never colour alone

The red border is reinforced by a message in words, because roughly one man in twelve cannot separate it from the neutral state. The same rule the status tones follow.

Reserve the message row, not the label

The message row keeps a 1.4em minimum height whether or not it holds text, which is what makes a row of fields align: controls share one baseline and every field ends at the same depth. Reserving a slot in the label was tried and removed — it indented the labels that had no icon, which read as a mistake rather than as alignment.

Placeholder is not a label

It vanishes the moment someone types, so it can only hold an example. Every field above has a real label, and the placeholder shows a sample value.

Checkbox, radio, switch — a 20px box with a 44px target

Three controls, one anatomy: a native input with appearance stripped, plus a label that is part of the target rather than a caption beside it. These are the components where the box is far smaller than the area a finger needs, so the row carries the height and the box only carries the mark.

Checkbox

Required before publishing

Radio

Switch

The label is the target, not a caption

Each row is a <label>, so the whole line is clickable and the row carries min-block-size — 1.75rem on a fine pointer, 2.75rem under (pointer: coarse). The box stays 1.25rem at every density, because a checkbox that grows on touch stops matching the type beside it. This is the one place the target and the visible control are deliberately different sizes.

Native inputs, appearance stripped

appearance: none removes the platform control but keeps :checked, :indeterminate, :disabled and the whole keyboard contract — space to toggle, arrows to move within a radio group. The mark is a background-image built from the Phosphor bold check and minus, so it inherits nothing and needs no extra element inside a void tag.

Checkbox squares, radio rounds, switch slides

Shape is the only thing separating them, and it has to be unambiguous: --se-radius-sm for a checkbox, --se-radius-pill for a radio. A switch is a checkbox that reports a setting rather than a selection — it applies immediately, so it never appears in a form that has a Save button.

Popover, menu, tooltip — one layer, three contracts

All three sit on --se-shadow-floating and all three dismiss on click outside — that much is shared. What separates them is what they are allowed to contain, and where the focus indicator has to be drawn.

Open them — click outside or press Escape to dismiss

Share this portfolio

Anyone with the link can view it.

A DNS record that points your domain at Studio Edit.
<div data-se="menu">
  <button aria-haspopup="menu" aria-expanded="true">Project</button>
  <div data-part="floater" data-state="open" role="menu">
    <button data-part="item" role="menuitem" data-highlighted>Copy link</button>
    <div  data-part="separator"></div>
    <button data-part="item" data-intent="negative">Delete project</button>
  </div>
</div>

[data-part="floater"] {
  box-shadow: var(--se-shadow-floating);
  border: var(--se-border-width) solid var(--se-border);
  border-radius: var(--se-radius-lg);
  background: var(--se-surface);
}
[data-part="floater"][data-state="closed"] { display: none; }

/* DOM focus stays on the menu; the ring is drawn on the highlighted item */
[data-se="menu"] [data-part="item"][data-highlighted] {
  background: var(--se-accent-soft);
  color: var(--se-accent);
}

The ring goes on the highlighted item, not the focused element

In a menu, DOM focus stays on the container while the arrow keys move a highlight. The browser can only ring the element that actually has focus, so it has nothing to draw — which is exactly why the focus indicator is the anatomy's job and not the browser's. Highlight is a data attribute, so a skin restyles it and cannot remove it.

A menu item has no hover state

The pointer moves the highlight instead. A menu has exactly one current item, so a hover tint alongside the keyboard highlight would show two — and if the mouse rests on one item while the arrow keys have moved to another, the two indicators disagree about what Enter will do. Hovering sets aria-activedescendant just as ArrowDown does, which is what the WAI-ARIA menu pattern asks for. Buttons and fields do have hover states, because they have no notion of a current item to conflict with.

A tooltip may never hold anything interactive

It appears on hover and on focus, and it has no focus of its own — so a link or a button inside one is unreachable by keyboard and invisible to touch. If the content needs a click, it is a popover. This is the distinction that decides which of the three you are building.

Floating, not overlay

All three take --se-shadow-floating and dismiss on click outside, because they are transient rather than modal. They never trap focus and never dim the page — that is the overlay level, and it belongs to Dialog. Height tells the reader how to get rid of something, so the two levels must not borrow each other's shadow.

Entry is animated, exit is not

The floater fades and rises 0.25rem over --se-duration-fast, which tells the reader where it came from. Dismissal is immediate: someone who has decided to close a menu is not interested in watching it leave. The reduced-motion reset collapses the entry to nothing without affecting either behaviour.

Dialog and sheet — the level that takes over

The overlay level dims the page, traps focus and dismisses on Escape. That combination is the definition — anything that does not need all three belongs on the floating level instead. A sheet is the same component anchored to an edge, for content that is long rather than urgent.

Open one — Escape or the close button dismisses, Tab stays inside

Delete Northlight?

The case study and its 12 images are removed permanently. Anyone holding the link will get a 404.

Publish settings

<dialog data-se="dialog" aria-labelledby="t" aria-describedby="d">
  <div data-part="header">
    <p data-part="title" id="t"></p>
    <p data-part="description" id="d"></p>
    <button data-part="close" aria-label="Close"></button>
  </div>
  <div data-part="body"></div>
  <div data-part="footer"></div>
</dialog>

[data-se="dialog"] {
  box-shadow: var(--se-shadow-overlay);
  border-radius: var(--se-radius-lg);
  max-inline-size: min(92vi, 30rem);
}
[data-se="dialog"]::backdrop { background: color-mix(in oklch, var(--se-text) 32%, transparent); }

/* entry announces where it came from; exit is immediate */
[data-se="dialog"][open] { animation: se-dialog var(--se-duration-medium) var(--se-ease-decelerate) both; }

Native dialog, not a div

showModal() gives the focus trap, Escape, the inert background and ::backdrop for free, and returns focus to the trigger on close. Every one of those is a thing hand-rolled overlays get wrong. Radix Dialog is still the production answer once you need animated exit or nested overlays — native dialog cannot animate its own closing — but the anatomy is the same either way, which is the point of specifying parts rather than implementations.

A destructive dialog names what is lost

The title asks a question, the description says what disappears and what breaks, and the confirming button repeats the verb — Delete permanently, never OK. Where the action is irreversible the dialog also asks the reader to type the name, which is the only affordance that reliably stops a reflex click.

A sheet is a dialog with a different anchor

Same parts, same dismissal, same overlay shadow — it only changes which edge it is attached to and which corners stay square. Use it when the content is long or the reader needs to keep the page in view; use a dialog when the decision must be made before anything else happens.

The backdrop is mixed, not a new colour

32% of --se-text over transparent, so a skin changing its text colour gets a backdrop in the same temperature without defining one. It dims rather than blurs: a blurred backdrop costs a repaint on every scroll behind it, and on the cheapest device your readers own that is the frame budget for the animation you actually want.

Table and list — where compact density earns its place

A table exists to let one row be compared with another, so everything that helps a column scan matters more here than anywhere else: one height per row, numbers right-aligned on tabular figures, and a header that stays put. A list is the same content when there is nothing to compare.

5 projects · 1 selected

Select Kind Status Updated
Northlight Case study Published 2,481 12 Mar
Résumé 2024 Document Published 974 4 Mar
Motion reel Video Unsaved 2 Mar
Ceramics Gallery Draft 28 Feb
Old portfolio Case study Failed 11 Feb

The same rows as a list

  • NorthlightPublished
  • maya.studioDomain verified
  • Motion reelUnsaved

Numbers are right-aligned on tabular figures

A column of numbers is only comparable if the digits line up, which needs both — right alignment so the units column is shared, and font-variant-numeric: tabular-nums so every digit takes the same width. Proportional figures make 1,111 narrower than 8,888 and the column stops reading as a magnitude. Text columns stay start-aligned; only quantities move.

This is what the density dial is for

Toggle Compact above: rows drop from --se-control-h to --se-control-h-sm and the type does not change. Every other component looks much the same at either density, but a table is where the reader's actual goal is more rows on screen — which is why density moves space and never type.

Sort is a button in the header, not a clickable cell

Only sortable columns get a button, so the pointer only changes where something will happen, and aria-sort tells a screen reader the current direction rather than leaving it to an arrow glyph. The caret appears on the sorted column and rotates rather than swapping for a second icon — one element, two states.

Selection is on the row, hover is not

aria-selected carries the state and paints --se-accent-soft; hover paints --se-surface-sunk and is suppressed on a selected row, so the two never stack into a third colour nobody chose. The header stays visible while the body scrolls, because a column heading you have scrolled past is a column you can no longer read.

Builder surface — composed, not invented

The product's main screen, and it adds nothing to the system: sections panel, canvas, inspector, built from the buttons, fields, choice controls and rows above, on the Sidebar primitive. If a screen this central needs something new, the something belongs in the system first — that is the test the whole document has been building toward.

Sections
Maya OkonjoHeading
Northlight Case study · 12 images
Selected
image placeholder — 16 / 9
Inspector

Nothing here is new

Section rows reuse the menu item's geometry, the inspector is fields and choice controls at --se-control-h-sm, the canvas blocks are the list row with a border, and the shell is the Sidebar primitive with a second panel. The only value this screen introduces is the 32rem threshold at which the inspector leaves.

Selection is one thing, in two places

aria-current marks the selected section in the panel and the selected block on the canvas, and both paint --se-accent-soft — so the reader can see what the inspector is editing without reading a word. Two selection colours, or a selected row that looked different from a selected block, would make the link something you have to learn.

The container decides, not the viewport

The shell is a query container, so the inspector leaves at 46rem of its own width rather than the window's. That is what lets the same builder sit in a full page, a preview pane and a docs example without three sets of rules — and it is the case Responsive rule 04 was written for, since collapsing three regions to one is a change of shape rather than a reflow.

Drag is the one place springs are allowed

Reordering a section is the exception the Motion section carves out: the reader's own hand sets the velocity, so physics reads better than a curve, and a dragged row takes --se-shadow-dragging to say it is attached to the cursor rather than to the page.

Where this leaves off

Every component the adoption sequence named is now built, in the order it recommended — button and fields first, the high-contrast skin second rather than last, then the floating layer, the overlay level, data display, and finally the builder surface composed from all of them.

What the specimens here are not: a package. They demonstrate the anatomy and prove the token contract holds, but they run on native elements and a stylesheet in one document. The production build wraps Radix for the overlay and floating layers, ships the contract as CSS custom properties on a real root, and installs Phosphor as an app dependency rather than inlining seven glyphs.

The next honest step is not another component. It is taking one real Studio Edit screen, building it only from what is specified here, and recording every time you reach for something that does not exist — because that list, not this page, is the backlog.