Components / forms
Button
max() for the touch floor was rejected. It flattened the size scale — sm and md became the same height on a phone, which removed the reason sm exists.
Summary
The system's only action element.
| APG pattern | Button |
| Built on | native elements |
| Status | stable |
| Since | 0.1.0 |
Two independent axes: intent says what the action means, emphasis says how loudly to say it. Twelve combinations from four intents and three emphases — accent + solid is the one action a view exists for and appears once per screen; negative is for actions that destroy something unrecoverable, not a louder accent.
<Button intent="accent" emphasis="solid" leadingIcon={<Icon path={ICONS.uploadSimple} />}>
Publish portfolio
</Button>
<Button intent="negative" emphasis="outline" iconOnly={<Icon path={ICONS.trash} />} label="Delete project" />
Anatomy
A native <button> carrying data-se="button". It owns its contents: a label, and up to one icon either side. A table cannot go inside a button, which is what makes it a thing rather than a frame.
| Part | Element | Required |
|---|---|---|
label | <span> | yes |
All three emphases draw the same border width, so swapping one for another never reflows a row.
Properties
| Property | Values | Default | What it does |
|---|---|---|---|
intent | accent · positive · negative · neutral | neutral | What the action means. |
emphasis | solid · outline · transparent | solid | How loudly. All three share one geometry, so they are interchangeable in place. |
size | sm · md · lg | md | The control height. sm for a dense row, lg for a single call to action on a page that has one. |
leadingIcon | React.ReactNode | none | An <Icon> shown before the label — names the thing being acted on. |
trailingIcon | React.ReactNode | none | An <Icon> shown after the label — reserved for direction (arrows, carets). |
iconOnly | React.ReactNode | none | Renders a square icon-only button. Requires label for its accessible name. |
label | string | none | Visible text, or the accessible name when iconOnly is set. |
disabled | boolean | none | From the platform. A disabled button is skipped by the keyboard and announced as unavailable — if the reader needs to know why, that belongs in text beside it, not in a tooltip they cannot reach. |
States
| State | Expressed as | |
|---|---|---|
| Pressed | :active | |
| Disabled | :disabled | |
| Hover | :hover |
Rest is the absence of the others and is never written. Disabled comes from the element, not an attribute the caller invents.
Disabled collapses all twelve combinations to one appearance. A disabled button that still looks negative, or still looks accent, invites the click it will not accept.
Behavior
It submits, or it calls back. Nothing else: a button that navigates is a Link, and the difference is whether the address changes.
type="button" is written explicitly, because a button inside a form defaults to submitting it and that default has surprised everyone at least once.
Accessibility
Role and name. The native element carries role="button" and its text is its name. An iconOnly button has no text, so label becomes its aria-label — a button whose name is an icon has no name at all.
Keyboard. Enter and Space activate, from the platform. Nothing here re-implements them.
Focus. The ring is drawn by the contract rather than the browser, because Radix ships no focus styling and the ring has to look the same whichever binding is underneath.
Target size. The visual height is --se-control-h; the touch floor is applied only under (pointer: coarse), so a compact desktop row is not inflated to satisfy a rule about thumbs.
An icon-only button is the one case where the floor applies on both axes. A label normally carries the width and an icon cannot, so the square has to be grown deliberately.
Contrast. --se-text-on-intent is one token, so every solid fill uses the same label color. A skin cannot have a pale accent and dark status fills at once. That limit is recorded in docs/audit/DEFERRED.md.
Keyboard
| Key | Result |
|---|---|
Enter | Activates |
Space | Activates |
Roles, states and properties
| Attribute | Set by |
|---|---|
aria-label | set at render |
Focus
A normal tab stop. Focus stays on it after activation, unless what it opened moves focus — a Dialog does, and returns it here on close.
Screen reader
Not recorded as verified. Stage 4 of the readiness ladder — driving each component with VoiceOver and NVDA — is unstarted across the package, and no component should claim otherwise until docs/journal/ records the session that did it.
Contrast and target size
npm run check:contrast audits every color pair this component uses across all four skins, light and dark, and fails the build on a shortfall. Two waivers are on the record in docs/audit/DEFERRED.md.
It takes the touch floor under (pointer: coarse) — the visual size is unchanged on a desktop pointer.
Tested
test/apg/batch-a.test.jsxtest/apg/partials-sweep.test.jsxtest/browser/button-group.spec.mjstest/browser/icon-box.spec.mjstest/browser/upstream-natives.spec.mjs
Known gaps
- 1 upstream ref — a toggle button. We ship no pressed state — a capability gap, not a defect.
- 1 upstream ref — IDL reflection of a div[role="button"]; ours is a real button
Appearance
| Token | Used for |
|---|---|
--se-accent | intent |
--se-bg | background, border-color |
--se-border | border-color |
--se-border-width | border-width |
--se-control-h | block-size, inline-size |
--se-control-h-lg | block-size, inline-size |
--se-control-h-sm | block-size, inline-size |
--se-duration-instant | transition |
--se-ease-standard | transition |
--se-mix-down | background, border-color |
--se-mix-hover | background, border-color |
--se-mix-line | border-color |
--se-mix-press | background |
--se-mix-shade | background, border-color |
--se-mix-soft | background |
--se-negative | intent |
--se-positive | intent |
--se-space-3 | padding-inline |
--se-space-4 | padding-inline |
--se-space-5 | padding-inline |
--se-surface-sunk | background |
--se-target-min | block-size, inline-size, min-block-size |
--se-text | intent |
--se-text-disabled | color |
--se-text-md | font-size |
--se-text-on-intent | color |
--se-text-xs | font-size |
--se-_intent is the component's own property, not a contract token: each intent assigns it once and every emphasis reads it, so a fifth intent would be one line.
Four color values, not twenty-four. Each intent declares one color — the same value the contract uses for status text, since one color serves as both text and fill — and hover, press and the soft tints are all derived from it with color-mix. Twelve combinations come out of four declarations plus --se-text-on-intent for the label, which a skin with a pale fill flips to dark.
The type comes from a role. The button carries data-se-text="ui", so its resting treatment is the role's and not a second copy of it — check:type fails a component that writes a role's declarations out again. Only the two size variants override it, because a size is the one thing a role cannot know.
Three things step together across the sizes: the height token, the inline padding and the type step. Each height is a multiple of --se-text-md — 2×, 2.5× and 3×, which is 32, 40 and 48px at the base scale — so the box tracks the type root, and the type dial with it, instead of holding a fixed rem while the text moves. Height comes from --se-control-h rather than accumulating out of padding, which is what keeps it identical in every state. The radius stays in em so it stays proportional. Button declares no icon size: the glyph takes its box from the shared [data-part="icon"] rule.
Motion
Background and border color cross --se-duration-instant on hover and press. Nothing moves — no lift, no scale. A button that grows under the pointer moves the thing beside it, and a row of them ripples.
Press is a color change rather than a transform for the same reason.
Content
A label names the action it performs. Publish, Delete, Add section. Never OK, never Submit.
The verb carries through the flow. Publish produces Published, and confirms with Publish portfolio.
Sentence case. No terminal punctuation. Two words is usually enough and four is usually too many — if the label needs a clause, the thing it does needs a dialog.
Trailing icons are for direction only, arrows and carets. A trailing icon that decorates competes with the label for the reader's eye at the exact moment they are deciding.
Usage
Use a Button when something happens. Use a Link when the address changes. That is the whole test, and it is a governance question rather than a styling one: a link styled as a button still opens in a new tab on a middle click, and a button styled as a link still does not.
One accent + solid per view. Two primary actions is a view that has not decided what it is for.
negative is for the unrecoverable. Deleting a draft that goes to a trash is not negative; deleting a published portfolio is. Reaching for it as a louder accent spends the one signal that has to still work later.
When not to use it. A set of joined actions is a ButtonGroup. A set that shares one tab stop is a Toolbar. A single-select choice that looks like buttons is a RadioGroup — that is a segmented control, and it is an input rather than an action.
Notes
variant was rejected and the reasoning is in CONVENTIONS.md: one prop cannot answer both what an action means and how loudly to say it.
The label icon slot was rejected. A leading icon on every button left an indent that made unlabelled rows look broken.
The docs page claimed an icon size this component does not set. It said the icon holds at the small icon token — true once, and false since Button stopped restating the box and one shared rule took it over. The claim was carried here during the migration and the Appearance gate rejected it, which is what that section is for: it lists what the CSS consumes and nothing else, whatever a sentence beside it argues.
max() for the touch floor was rejected. It flattened the size scale — sm and md became the same height on a phone, which removed the reason sm exists.