Default
Overview
The Button component lives in @flatpay-dk/ui and mirrors the canonical Figma matrix. Six variants (primary, secondary, tertiary, transparent, success, danger), three sizes (sm, md, lg), four icon configurations (none, leading, trailing, icon-only), and six visible states. State styling is handled natively via CSS pseudo-classes — no JavaScript hover state, no animation orchestration.
Reach for primary sparingly
Most actions in dense product UI should be secondary or tertiary. Primary is the single “most likely next thing” — a screen with three primaries has none.
Variants
Six variants, each tied to a specific role in the action hierarchy. Color earns its place by communicating the role — Flatpay has no brand hue, so primary is charcoal, not blurple.
Primary
variant="primary"The single most likely next action on a screen. Reach for it once per view.
Secondary
variant="secondary"The everyday button. Most product UI should use secondary; primary is reserved.
Tertiary
variant="tertiary"Bordered ghost. Use in toolbars, alongside a primary, or when you need quiet affordance.
Transparent
variant="transparent"Borderless ghost — the quietest affordance. Reach for it on table-row inline actions, dialog skip controls, and anywhere a button must not occupy visual weight at rest.
Success
variant="success"Affirmative confirmation — completed payouts, demo-ready confirmations. Mint, not loud green.
Danger
variant="danger"Destructive actions. Pair with a confirmation step; never the only path to recovery.
Variants × states
The full matrix: every variant rendered at every state. Held still here for review — interactive states (hover, active, focus) come for free on the real component.
6 variants × 6 states. Hover any cell on a real button to feel the actual transition; the matrix below shows each state held still for review.
primarysecondarytertiarytransparentsuccessdangerSizes
Three sizes. md is the default and covers most product UI. sm is for dense rows; lg is for marketing surfaces and login.
Small
size="sm"32 px · dense rows, toolbar strips, table cells.
Medium
size="md"40 px · the default for product UI.
Large
size="lg"48 px · marketing surfaces, login, empty states.
Icons
Four icon configurations. Icons sit at 16 px and inherit the button's text color via currentColor. Use leadingIcon / trailingIcon props rather than interleaving icons with text in children — the props guarantee correct gap and alignment.
None
Label-only — the default.
Leading
Icon before label. Best for additive verbs (create, add, open).
Trailing
Icon after label. Best for navigation and continuation.
Only
Icon-only — always pair with aria-label.
Loading
When an action is in flight, loading={true} swaps the label for a spinner, disables the button, and sets aria-busy. The button keeps its width so the surrounding layout doesn't reflow mid-click.
Width is preserved
The button keeps its dimensions when loading flips on. The label is replaced by a spinner; nothing reflows.
Inherits text color
The spinner uses currentColor, so it adopts the variant's foreground tone — black on secondary, deep green on success.
Anatomy
Five named parts; the icon slots are optional. The container carries every variant, size, state, and focus-ring decision — never style the inner span directly.
Container
Fixed height, 4 px radius, variant-driven background and border. Owns the focus ring.
Leading icon (optional)
16 px glyph in the
leadingIconslot. Inherits color from the label.Label
Inter Tight, semibold (600), sentence case. The size token sets font size and line-height.
Trailing icon (optional)
16 px glyph in the
trailingIconslot. Use for navigation cues (→) and continuation.Focus ring
2 px
#9EB7FFring with a 2 px offset. Visible only on:focus-visible— never on mouse click.
Behavior
- Hover and active are color shifts only. No scale transform, no shadow, no glow. The state difference is read by the eye via tone, not motion.
- Transition is 150 ms ease-out. Fast enough that hover feels instant; slow enough that you notice the press. No bounce, no elastic.
- Focus ring is keyboard-only.
:focus-visibleshows the ring; mouse clicks do not. Don't override. - Loading disables the button. The button still receives focus (so screen readers announce
aria-busy) but ignores clicks. Don't fire the action twice. - Width is reserved on loading. The label is replaced by a spinner of equal weight; the page never reflows.
Accessibility
- Always include accessible text. For icon-only buttons, set
aria-label. For label-with-icon buttons, the icon should bearia-hidden(the prop slots handle this automatically). - Keyboard: activates on Enter and Space — native button behavior. Don't hijack with custom key handlers.
- Disabled vs. unavailable: use the native
disabledattribute to remove from the tab order; usearia-disabled="true"if the button must remain discoverable (e.g., to surface a tooltip explaining why). - Contrast: every variant ships with WCAG AA contrast at the default state. Hover and active deepen the background; the foreground stays constant.
- Destructive actions (the danger variant) should always announce the consequence — body copy nearby, a confirmation dialog, or an undo affordance. The variant alone is not consent.
Code
Import from @flatpay-dk/ui. Every standard button HTML attribute (onClick, type, aria-*,formAction) passes through.
tsx
import { Button } from "@flatpay-dk/ui";
// Default — primary, md
<Button onClick={handleRun}>Run prototype</Button>
// Variant + size
<Button variant="secondary" size="sm">Open repo</Button>
// Leading icon — pair with the slot prop, not inline
<Button leadingIcon={<PlusIcon />}>Create prototype</Button>
// Trailing icon
<Button trailingIcon={<ArrowRightIcon />}>Continue</Button>
// Icon-only — always pair with aria-label
<Button iconOnly aria-label="Close">
<CloseIcon />
</Button>
// Loading state — keeps width, disables clicks
<Button loading={isPending}>Submit</Button>
// Destructive action — pair with a confirm step
<Button variant="danger" onClick={archive}>Archive prototype</Button>Best practices
The button is the most-used component in the product. Small habits compound across screens.
Do
One primary button per screen — the single most likely next action.
Don't
Don't pair two primaries. Two equal weights cancel each other; the user reads neither.
Do
Sentence case labels — 'Run prototype', not 'Run Prototype'.
Don't
Don't title case. It feels Bootstrap, not Flatpay.
Do
Pair danger with a confirmation step — never the only path to recovery.
Don't
Don't fire destruction on click. Append … and confirm in the next step.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "primary" | "secondary" | "tertiary" | "transparent" | "success" | "danger" | "primary" | Visual treatment. See Variants for guidance on which to pick. |
| size | "sm" | "md" | "lg" | "md" | Height + horizontal padding preset. sm=32px, md=40px, lg=48px. |
| iconOnly | boolean | false | Strips horizontal padding and forces a square aspect ratio. Always pair with aria-label. |
| leadingIcon | ReactNode | — | Optional 16 px icon rendered before the label. Inherits text color via currentColor. |
| trailingIcon | ReactNode | — | Optional 16 px icon rendered after the label. Use for continuation cues. |
| loading | boolean | false | Replaces the label with a spinner, disables clicks, sets aria-busy. Width is preserved. |
| disabled | boolean | false | Native disabled attribute. Removes from tab order. Always pair with an explanation in nearby text or a tooltip. |
| type | "button" | "submit" | "reset" | "button" | Native button type. In forms, set type="submit" explicitly — the default does not submit. |
| ...rest | ButtonHTMLAttributes<HTMLButtonElement> | — | All standard button HTML attributes (onClick, aria-label, formAction, etc.) pass through. |