Button

One box, four ranks of emphasis. It owns the fill, the states and the focus ring, so an icon inside it never carries a second box of its own.

import { Button } from "@dede-style/ui/button", then <Button>Save</Button>. The words are the label and the accessible name at once. For a button with no words, see IconButton — same code underneath, one extra rule.

Button renders one <button> and whatever you put inside it. There is no background, border-colour or radius prop — variant is the only thing that paints. An escape hatch gets used once and then everywhere, and a system in which every page can invent its own button is not a system.

It has no state and no hooks of its own, so a page that renders a button with no handler ships no JavaScript for it.

Variants

Three ranks of emphasis, and one that is not a rank at all.

The names are ranks, not looks. primary / secondary / tertiary say how much emphasis the action claims. fill / outline / text would say what it renders as, which welds the drawing into the API — restyle the secondary button and its name becomes a lie. It is the same rule that gives this system --color-brand rather than --teal.

secondary is the default because a page has one primary action and several secondaries. A default that claimed to be the action would be wrong more often than right.

destructive sits on a different axis on purpose: it names a purpose, because “is this dangerous” is not the same question as “how loud is this”. It is the only variant with a colour, in a system that is otherwise monochrome — which is the point. Colour here is the message, not decoration.

A light-grey fill was considered for secondary and rejected on a fact about this system rather than a preference: surface-sunken already means set into the page — it is inline code’s tint, the nav hover and the control tracks — so a grey-filled button would read as a dent rather than a control sitting on the surface. An outline has no such collision.

Size

Four steps of the same element ramp Icon reads, and the same ramp form inputs will take.

Numeric, not t-shirt sizes. The steps are --size-element-*, so the number is the height in quarters of a pixel-grid unit — 8 is 32px. Names would collide across components: md is about 20px on an icon and about 40 on a control, and one word cannot be both in one codebase.

6 is for icon buttons, not for labelled ones. 24px is exactly the minimum target size a pointer is entitled to, so it passes with no headroom for a border or a miscalculated padding — and it is too short to hold a form input. The real control set is 32 / 40 / 48, and those are the three that will carry over to inputs unchanged.

The button sizes the icon. You never pass a size through — the box lands at roughly 60% of the button height, which is 12 / 16 / 20 / 24px across the four steps. It reads smaller than it sounds because size sets the box and the glyph fills about twenty of its twenty-four units; that padding is the live area, and it is what makes a wide glyph and a tall one read as the same size.

Shape

The one visual dial that is a prop, and the line that makes it one.

Background, border colour and radius are CSS everywhere else in this component, so why is shape a prop? Because a named set is still a system. A pill and a rounded rectangle are both legitimate house styles, chosen once and reused — where radius="7px" is a page inventing its own button. Three options is a decision; any length is an escape hatch.

With an icon

The icon takes a side, and that side takes less padding.

A glyph is sparse where a letter is dense. A plus is a thin cross with four empty quadrants inside its own box; an A fills its em with solid ink. Same footprint, very different amount of visible white — so the eye adds the glyph’s internal emptiness to the button’s padding and reads that side as airier, while the label’s first letter sits hard against its own. The icon side therefore takes one step less.

It is not an ink-alignment rule, and the difference matters. Justified that way — “land the glyph’s ink where text ink starts” — it fails on measurement: the glyph’s own air is 1.3 to 3.2px, not the step it would need. Making the padding symmetric on the strength of that then looked left-heavy, because ink position was never what the eye was reading. One step is a median approximation, since glyph fill runs from 48% to 94% of the box — but zero is an approximation too, and a worse one.

The gap is always smaller than the padding. An icon and its label are one unit and should read as one, so the space between them is always less than the space around them. They were briefly equal at the smaller steps, which left the glyph floating midway between the edge and its own words.

The icon is hidden from screen readers. The words beside it already say what the control does, so announcing both would read “delete delete”. Nothing has to be passed for this — an icon inside a Button is decorative by construction.

Icons on both sides are deliberately unsupported. Two glyphs bracketing a label is a layout, and a layout belongs to the primitives rather than to a prop on a control.

Accessibility

Three of the four states are behaviour, so most of what matters here cannot be shown in a still frame.

The words inside the button are its accessible name. There is no label prop and that is deliberate: a second source for one string is a second thing to keep in step, and the two drift the first time somebody edits only what they can see. A button with no words is a different component — IconButton, where the name is required because nothing else can supply it.

Focus takes the text colour. The system is monochrome and has no accent to spend, so the ring reuses --color-text-primary at two pixels with one pixel of offset — clear of a filled variant’s own edge, so it reads on all four variants without a per-variant value. Tab to any button on this page to see it.

Disabled removes the hover and the press, not just the click. A control that still lifts under the cursor is offering something it will not honour. All four variants drop to the same opacity rather than each getting a muted colour of its own, which keeps “unavailable” reading as one thing.

Motion is decoration here. The hover colour and the focus ring carry every state on their own, so the scale transform is switched off entirely under prefers-reduced-motion and nothing is lost but the movement.