Button
One part on a native <button>. There is no behavior to add — the platform
already handles activation, form submission and disabled — so what zero adds is the
anatomy: a stable selector carrying data-color / data-size / data-variant, which is
where a design system puts its fill styles.
Import
import { Button } from '@sigx/zero/button';
Button is a compound with a single member, Button.Root. It is also re-exported from the
@sigx/zero root, together with buttonAnatomy.
Usage
import { Button } from '@sigx/zero/button';
<Button.Root color="primary" variant="outline" size="lg" onClick={save}>
Save
</Button.Root>
type defaults to button, not the platform's submit — a button inside a form does not
post it unless you say type="submit".
As a link
A link that looks like a button is asChild over an <a>. It stays a real link — middle-click,
"copy link" and the link role all work — and wears the button's anatomy and recipe:
<Button.Root asChild variant="outline">
{(p) => <a href="/docs" {...p}>Docs</a>}
</Button.Root>
With asChild the default slot receives the part's attribute bag; spread it so the anatomy
lands on your element. A native <button disabled> is inert by itself; an asChild element
is not, so a disabled asChild button gets aria-disabled="true" and its click is
suppressed by the component.
A design system's recipes live in @layer zero.recipes, and any unlayered rule beats any
layered one whatever its specificity — so an app stylesheet with a plain a { color: … }
repaints every link button. That is the layering promise (app CSS
always wins) working as designed. Hand the colour back with one unlayered rule in the app:
a[data-scope="button"][data-part="root"] { color: revert-layer; text-decoration: revert-layer; }
revert-layer rolls the property back to the layered cascade, the button recipe included.
Every shipped button recipe sets text-decoration: none, so the underline reverts to none
rather than to the browser's link default.
The loading button
loading is Button's one state — work in flight:
<Button.Root loading={saving()} onClick={save}>Save</Button.Root>
It renders data-state="loading", aria-busy="true" and aria-disabled="true", and blocks
activation: a click neither calls onClick nor submits the form, and there is no press
feedback. It does not set the native disabled, which would drop focus from the button
the user just pressed. The label stays — it is what the reader is waiting on — and a
spinner part, an empty aria-hidden span, renders before it for the design system to draw.
Style it by name (parts.spinner, states.loading) rather than through a skin's
pseudo-element. An asChild element gets the state and the ARIA but no spinner, since its
children are the caller's. Announce a long operation with your own live region when a label
change alone won't be heard.
Attributes and forms
Button.Root forwards aria-*, your own data-*, id, title and role to the element
(or the asChild bag) — see attribute pass-through.
An icon-only button is named the ordinary way:
<Button.Root aria-label="Close" data-testid="close" onClick={close}>×</Button.Root>
The native name, value and form land on the built-in <button>, so a submitter posts
its value and a button outside a form's subtree can submit it by id. An asChild element
carries its own.
Anatomy
| Part | Element | States | Flags | Notes |
|---|---|---|---|---|
root | button | loading (absent at rest) | disabled, focus-visible, pressed, press-animating | Carries the variant axes. aria-busy + aria-disabled while loading. asChild. |
spinner | span | — | — | aria-hidden. Rendered before the label while loading, on the built-in <button> only. Inside root. |
A button's one machine state is loading; it has nothing to be open or checked about, and a
button with a persistent pressed mode is a Toggle. :active
remains available to recipes; data-pressed / data-press-animating and the --press-x /
--press-y / --press-r custom properties are the runtime
press feedback on top of it — pointer-anchored,
keyboard-parity, and able to outlive release so a one-shot ripple always plays out.
Props
Button.Root
| Prop | Type | Default | Description |
|---|---|---|---|
type | 'button' | 'submit' | 'reset' | 'button' | The native button type. |
disabled | boolean | false | Inert; renders data-disabled (and aria-disabled under asChild). |
loading | boolean | false | Work in flight: data-state="loading", aria-busy, aria-disabled, activation blocked, focus kept; renders the spinner part. |
name / value / form | string | — | The native button's form attributes, on the built-in <button>. |
color / size / variant / axes / mods | design-system vocabulary | — | The variant axes, rendered as data-* on root. |
asChild | boolean | false | Render through the default slot, which receives the part bag. |
class | string | — | Extra classes. |
aria-* / data-* / id / title / role | HTML attributes | — | Forwarded to the element; a contract-owned data-* throws. |
onClick | (e: MouseEvent) => void | — | Click handler; not called while disabled or loading. |
onKeydown / onFocus / onBlur | handlers | — | Compose with the component's own focus and press tracking. |
Handlers are declared props rather than forwarded rest props — sigx passes no rest props, so
onClick has to be part of the type to reach the element.
In the shipped design systems
Button is the worked example of the two-axis composition: a design system routes color
through a component token pair (--btn-accent and its content colour) that the variant
rules read, so the two axes compose instead of multiplying into a rule per combination.
color | size | variant | mods | |
|---|---|---|---|---|
@sigx/zero-basic | the eight recommended roles | xs–xl | solid (default) · outline · soft · ghost | — |
@sigx/zero-daisyui | the eight recommended roles | xs–xl | solid (default) · outline · soft · ghost · dash · link | wide · block · square · circle · active |
@sigx/zero-daisyui also ships the daisy-native surface:
import { Button } from '@sigx/zero-daisyui/components' gives a single-import
<Button wide loading variant="dash" color="primary"> with the modifiers as flat booleans
(loading there is zero's own prop, not a modifier, and the recipe draws the spinner
part) — see Vendor-named component APIs.
