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
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); }
}
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
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
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
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); }
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
Radio
Switch
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.
<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);
}
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
<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; }
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.
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.
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.