The primary action control: a hug-width pill assembled from one customButton base — a label column between two swappable side slots. Four sizes, six styles.
Overview
Everything ships from one base — customButton, with all four slots configurable. The four named sizes are its locked configurations, largest first; the base itself closes the row, with both side slots on and a second text row. All shown in the Primary style.
Styles and States
Main-CTA (Tsar button)
The Main-CTA button has primary style only.
L / M / S
The three hug-width sizes carry all six styles. The Style axis sets the fill and the label colour; the size sets the height and the text style, and the two are independent.
States
The State axis across the same six styles, shown at M. Disabled and Skeleton collapse every style onto one appearance — that is the contract, not a rendering shortcut — while the Loader in Loading picks up each style's label colour.
Primary · Standard
-
1
Fill var(--background-brand) — the same on every size.
-
2
Label and icons var(--text-and-icon-always-dark) — one token for both, which is why the pointer lands twice.
-
3
Main CTA Title Heading 2. No subtitle row.
-
4
L Title Heading 4, subtitle Compact Body.
-
5
M Title Main Body, subtitle Compact Body.
-
6
S Title Main Body, subtitle Caption.
Secondary · Standard
-
1
Fill var(--surface-on-white)
-
2
Label and icons var(--text-and-icon-primary)
-
3
Loader var(--text-and-icon-primary)
AlwaysLight · Standard
-
1
Fill var(--background-always-light)
-
2
Label and icons var(--text-and-icon-always-dark)
-
3
Loader var(--text-and-icon-always-dark)
Ghost · Standard
-
1
Fill None — transparent. Ghost is the one style with no surface of its own.
-
2
Label and icons var(--text-and-icon-primary)
-
3
Loader var(--text-and-icon-primary)
Inverse · Standard
-
1
Fill var(--background-inverse-primary)
-
2
Label and icons var(--text-and-icon-inverse-primary)
-
3
Loader var(--text-and-icon-inverse-primary)
Error · Standard
-
1
Fill var(--surface-on-white)
-
2
Label and icons var(--text-and-icon-error)
-
3
Loader var(--text-and-icon-error)
customButton · Standard
-
1
Fill var(--background-brand) — a default, not a lock: any colour token in the DS can replace it.
-
2
Label and icons var(--text-and-icon-always-dark) — a default, not a lock: any colour token in the DS can replace it.
-
3
Loader var(--text-and-icon-always-dark) — a default, not a lock: any colour token in the DS can replace it.
Common · Disabled
-
1
Fill var(--surface-on-white)
-
2
Label and icons var(--text-and-icon-disabled)
-
3
Loader var(--text-and-icon-disabled)
Common · Skeleton
-
1
Fill var(--skeleton-on-white)
-
2
Wave var(--skeleton-wave)
Anatomy
Button is built entirely out of slots, named with the DS-wide StartSlot / StartText / EndSlot semantics. The specimen is the base, customButton, with all four of them filled.
-
1
StartSlot .button__start-slot — swap slot, default IconContainer s24. Off by default on the presets.
-
2
StartText .button__start-text — the row column, s2 between rows.
-
3
Title row .button__title-row — required. A swap slot on the base, plain text on the presets.
-
4
Subtitle row .button__subtitle-row — optional. On by default on the base, behind Show Subtitle on the presets.
-
5
EndSlot .button__end-slot — mirrors StartSlot. Main CTA has neither.
Layout
customButton at its maximum slot configuration, redlined. The three slots are framed; the label rows are drawn again below on their own, since the gap between them is two pixels and nothing inside a full button would show it.
customButton
-
General var(--sp-s16) — padding on both sides. Default — changeable on a design request.var(--sp-s12) — gap between StartSlot / StartText / EndSlot. Default — changeable on a design request.var(--sp-s20) — roundness, on every size. Default — changeable on a design request.var(--sp-s56) — height, fixed rather than driven by the content. Default — changeable on a design request.Hug / fill — the whole button. Hug by default; an instance may fill its container, and Main CTA always does.Centre / centre — the content inside the button, on both axes.
-
1
StartSlot / EndSlot Hug — the slot takes the size of whatever is nested in it.var(--sp-s24) — the IconContainer that sits there by default. A swap slot: any DS element fits. Default — changeable on a design request.
-
2
TextContainer var(--sp-s2) — gap between the 1st line (Title) and the 2nd (Subtitle). Default — changeable on a design request.Hug / fill. Hug by default.One line each, truncating to an ellipsis. Title and Subtitle truncate independently.Texts by default. Both rows are swap slots — anything from the DS can go in either.Title is permanent; Subtitle is optional and can be hidden.
Main CTA
-
General Locked, all of it. A preset exists so that nothing is changed — not the padding, not the height, not the roundness, not the text style. A shape these numbers do not cover is built from customButton.var(--sp-s64) — height, the tallest size in the system.var(--sp-s16) — padding, the same on both sides. One number, since nothing stands beside the label.var(--sp-s20) — roundness, the same as every other size. At 64 it reads as a rounded rectangle rather than a pill.Fill — by default; changeable to hug. The only size that fills out of the box, which is what makes it the screen-level action pinned at the bottom of a flow. An instance may hug its label instead.No gap — there is no StartSlot and no EndSlot to separate the label from.Centre / centre — the label inside the button, on both axes.
-
1
TextContainer Heading 2 — the one configuration that reaches for PP Agrandir. Scaling is cap130, not full.One row. Main CTA has no subtitle — the second row is absent, not hidden.One line, truncating to an ellipsis.Text, locked. A preset row is plain text; swapping a component in is the base's affordance, not this one's.
L
-
General Locked, all of it. Height, padding, gap, roundness and both text styles are fixed by the preset. A shape they do not cover is built from customButton.var(--sp-s56) — height, the same as the base.var(--sp-s12) — padding on the root. What a side ends up at is 16 or 20 — see 3.var(--sp-s4) — gap between StartSlot / StartText / EndSlot. A third of the base's s12: the preset carries one row by default, so the parts sit closer.var(--sp-s20) — roundness, the same on every size.Hug — by default; changeable to fill.Centre / centre — the content inside the button, on both axes.
-
1
StartSlot / EndSlot Off by default — both. The reverse of the base, where both are on.var(--sp-s24) — the IconContainer inside. Hug — the slot takes its size.var(--sp-s4) — outer padding, toward the button edge.
-
2
TextContainer var(--sp-s8) — horizontal padding. Absent on the base; it is what makes a label side wider than a slot side.var(--sp-s2) — gap between Title and Subtitle.Heading 4 and Compact Body. Title is permanent; Subtitle sits behind Show SubTitle, off by default.One line each, truncating to an ellipsis.Text, locked. Swapping a component into a row is the base's affordance.
-
3
Padding by what the side ends in var(--sp-s16) — a side that ends in a slot. s12 on the root plus s4 on the slot. An icon carries its own air inside the 24 container.var(--sp-s20) — a side that ends in a label. s12 on the root plus s8 on StartText. Text runs to its own edge, so it needs more; without this the button reads as pulled toward the icon.There is no second padding token. The difference is where the inner padding sits, which is why the base — with no inner padding — stays at 16 on both sides.
M
-
General Locked, all of it. Height, padding, gap, roundness and both text styles are fixed by the preset.var(--sp-s48) — height. The only value that separates M from L. Vertical padding is 0: the height is fixed and the content centres inside it.var(--sp-s12) — padding on the root, var(--sp-s16) to a slot and var(--sp-s20) to a label. Composed the same way at every preset size — s4 on the slot, s8 on StartText. Drawn once under L.var(--sp-s4) — gap between StartSlot / StartText / EndSlot.var(--sp-s20) — roundness, the same on every size.Hug — by default; changeable to fill.Centre / centre — the content inside the button, on both axes.
-
1
StartSlot / EndSlot Off by default — both.var(--sp-s24) — the IconContainer inside. Hug — the slot takes its size.var(--sp-s4) — outer padding, toward the button edge.The icon does not step down with the button: 24 at L, M and S alike.
-
2
TextContainer Main Body and Compact Body. The title steps down from L's Heading 4 — same family, no heading weight at 48.var(--sp-s8) — horizontal padding.var(--sp-s2) — gap between Title and Subtitle.Subtitle behind Show SubTitle, off by default. Both rows fit: 20 + 2 + 16 against a 48 height.One line each, truncating to an ellipsis. Text, locked.
S
-
General Locked, all of it. Height, padding, gap, roundness and both text styles are fixed by the preset.var(--sp-s40) — height, the smallest in the system. Vertical padding is 0. At 40 the s20 radius makes a true pill, where the same token reads as a rounded rectangle at 64.var(--sp-s12) — padding on the root, var(--sp-s16) to a slot and var(--sp-s20) to a label. Composed the same way at every preset size. Drawn once under L.var(--sp-s4) — gap between StartSlot / StartText / EndSlot.var(--sp-s20) — roundness, the same on every size.Hug — by default; changeable to fill.Centre / centre — the content inside the button, on both axes.
-
1
StartSlot / EndSlot Off by default — both.var(--sp-s24) — the IconContainer inside, the same as at L and M. It leaves 8 above and below at a 40 height, which is what sets the floor on this size.var(--sp-s4) — outer padding, toward the button edge.
-
2
TextContainer Main Body and Caption.var(--sp-s8) — horizontal padding.var(--sp-s2) — gap between Title and Subtitle.Subtitle behind Show SubTitle, off by default. Two rows come to 20 + 2 + 16 against a 40 height.One line each, truncating to an ellipsis. Text, locked.
Animation
Press
-
pushButton The one pattern where scale and fill move together. Named in motion-rules.md §6.1 with Button as its consumer.Press is the whole of the pressed state. There is no pressed fill token and no Pressed member on the State axis — the scale is what a person sees.
-
1
Press var(--component-push-button-press) — 200ms on standard-ease-in-out.transform 100% → 95% and background-color, on the same curve and the same duration.
-
2
Release var(--component-push-button-release) — the same 200ms back.transform 95% → 100%, fill restored. Symmetric by design: a slower release would read as lag.
Usage
Button commits a person to an action — submitting, confirming, accepting, continuing. The Style axis carries the weight of that action rather than its wording, so what a person reads first is decided by which style you reach for, not by how the label is phrased.
-
1
One style per job Primary for the one action a screen is built around. Secondary or Ghost for an alternative beside it. Error for a destructive choice — a tinted label on a neutral fill, so it stays legible next to a Primary without competing with it. Main CTA for the single screen-level action pinned to the bottom of a flow.
-
2
One Primary per view Two Primary buttons compete for the same attention and the screen stops having an answer. The second action reads more clearly as Secondary or Ghost — the label does not need to change.
-
3
Nothing under Main CTA It is the bottom of a screen, not the top of a stack. A second button beneath makes two screen-level commitments out of one, and whatever ends up pinned lowest stops being the Main CTA. A skip or an alternative belongs above it, in the flow, or nowhere.
Accessibility
The three specimens are real <button> elements, which is the DS markup — a native button carries the role, the focus behaviour and both activation keys on its own. Tab into the canvas to see the focus ring. TalkBack and VoiceOver agree on the Standard announcement — the label, then Button — and part company on every edge state: Disabled reads as 'Disabled' on TalkBack, which also drops the hint since the control is no longer actionable, against 'Dimmed' on VoiceOver, which keeps it in the rotor so the state stays discoverable. The per-platform label, trait and hint tables live in specs/components/button/button-a11y.md.
Screen reader and keyboard
-
1
One stop per button The root is the focusable element, and the only one. No slot and no row takes focus. Whatever a slot holds is merged into the button's name rather than becoming a second stop.aria-hidden="true" on a decorative icon. Beside a label that already names the action, exposing it would repeat the label. A slot swapped to a component carrying information the label omits is exposed instead, and its text joins the name in reading order.
-
Keyboard Enter and Space both activate. Which is where Button differs from Checkbox, where Space toggles and Enter does nothing.:focus-visible — var(--sp-s2) in var(--text-and-icon-primary), radius var(--sp-s20) to follow the pill.Ghost has no fill, so the ring is the only shape on the surface. Worth checking on both Light and Dark.
-
3
Loading aria-busy="true" on the root, and the name held in aria-label. The visible label leaves the tree with the spinner, so without it the button announces as an unnamed busy control.Focus stays where it is. Moving it when a request starts strands the person who pressed the button, since that button is the anchor for whatever the response reports.Repeated activation fires nothing. A second press while busy does not queue a second action.
-
4
Disabled and Skeleton aria-disabled="true" keeps the button in the tab order. The hard disabled attribute also works and drops it out — the choice is whether the action should stay findable while unavailable.Skeleton is not exposed at all. A placeholder is removed from the tree, so nobody is offered a control that does not exist yet.