/// Inputs
Button
The system action control — mono uppercase label, sharp corners, and a primary fill that inverts on hover.
import { Button } from "@protocore/pds";Basics
A Button triggers an action in place. It renders a native <button type="button"> — attach your own onClick, or set type="submit" inside a form.
Variants
Four visual weights. primary inverts to an outline on hover; secondary and ghost are progressively quieter; danger is a destructive outline that fills on hover. Use exactly one primary per view.
Sizes
Control heights track the shared scale: sm 32 · md 36 · lg 40 · xl 48. full stretches the button to fill its container for form and mobile CTAs.
With icons
startIcon and endIcon take any node — size lucide glyphs to 16px so they align with the mono label. Icons are decorative (aria-hidden); the label carries the meaning.
Loading and disabled
loading swaps the leading icon for a spinner, sets aria-busy, and disables the control while a background action runs. A plain disabled button is removed from the tab order.
As a link
Set asChild to render your own element (via Radix Slot) with the button's styling — use it to make a real <a> when the control navigates. Icon slots are not injected in this mode.
Composed: a confirmation flow
Destructive actions earn a second step. Here a danger button reveals an inline Cancel / Confirm pair (a ButtonGroup), and the confirm button shows its loading state while the work runs.
When to use
Reach for Button when activating it *does* something in place — submit a form, deploy a service, open a dialog, run a job.
- Navigating to another route or URL? Use Link (even when it looks like a button) so it renders a real
<a>with correct semantics, or passasChildand wrap an anchor. - A compact, icon-only action in a toolbar? Use IconButton, which requires an
aria-label. - Holding a *selected* state that filters or toggles a view? Use Chip — it is a pressed toggle, not a one-shot action.
Do & don't
Do
- Lead each view with a single primary Button and make everything else secondary or ghost.
- Reserve variant="danger" for destructive actions, and pair it with a confirmation step.
- Write labels as short verb phrases — "Deploy service", "Export ledger".
- Use asChild to render a real <a> whenever the control navigates.
Don't
- Don't stack multiple primary buttons competing for the same attention.
- Don't use a Button for pure navigation — an anchor styled as a button breaks middle-click and right-click.
- Don't disable the submit button as your only validation feedback; say what's missing.
- Don't pad labels with prose — one or two words, no trailing punctuation.
Accessibility
| Keys | Action |
|---|---|
| Tab / Shift+Tab | Move focus onto / off the button. |
| Space or Enter | Activate the button. |
- Renders a native `<button type="button">`; set `type="submit"` when it submits a form.
- `loading` sets `aria-busy` and disables the control; the spinner and icon slots are `aria-hidden`.
- Disabled buttons leave the tab order — don't rely on disabling alone to explain why an action is unavailable.
- Icon-only actions must carry a visible label or an `aria-label`; use IconButton for that case.
Mobile (React Native)
Preview. @protocore/pds-mobile ships the React Native sibling of Button. It mirrors the web API where React Native allows; the package is a preview with no device-level QA yet, so pin it and expect small changes.
Import it from the mobile package (not @protocore/pds), inside a <PdsProvider> — there is no stylesheet, so style (a ViewStyle) replaces className and every value comes from the theme:
import { Button } from "@protocore/pds-mobile";Parity with web. It renders a Pressable, not an HTML <button>, and has no asChild — React Native has no polymorphic Slot.
- There is no
:hoveron touch:primaryinverts (anddangerfills) on press viaPressable'spressedstate, not hover. - Use
onPressinstead ofonClick; for navigation, wrap your router's pressable or pass anonPresshandler. - a11y is built in:
accessibilityRole="button"plusaccessibilityState{ disabled, busy }(set byloading).
<Button variant="primary" size="md" onPress={() => deploy()}>
Deploy service
</Button>Button props
| Prop | Type | Default | Description |
|---|---|---|---|
asChild | boolean | false | Render as the single child element (Radix `Slot`) instead of a `<button>` — e.g. wrap a link. Icon slots are not injected in this mode. |
className | string | — | |
endIcon | ReactNode | — | Trailing icon slot (any `ReactNode`). Ignored when `asChild`. |
full | boolean | false | Stretch to fill the width of the container (block button). |
loading | boolean | false | Show an inline spinner, set `aria-busy`, and disable the control while a background action runs. |
size | enum | md | Control height + horizontal padding: sm 32 · md 36 · lg 40 · xl 48. |
startIcon | ReactNode | — | Leading icon slot (any `ReactNode`). Suppressed while `loading` and ignored when `asChild`. |
style | CSSProperties | — | |
variant | enum | primary | Visual weight. `primary` inverts on hover; `secondary`/`ghost` are quieter; `danger` is a destructive outline that fills on hover. |