Base components
Button
A native button in five sizes and five hierarchies that eases its colors on hover, gives a small press, and opens a spinner slot while it works.
Live preview
Loading preview
A row of the five hierarchies at size md with a leading icon, a size row from xs to xl, and an icon-only button. A Save button turns on loading for 1.6 seconds when clicked. Replay runs that loading cycle on the Save button.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add buttonUsage
The demo above, as code.
demo.tsx
'use client';
import * as React from 'react';
import { Button } from './button';
const LOADING_MS = 1600;
function PlusIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.67"
strokeLinecap="round"
>
<path d="M10 4.167v11.666M4.167 10h11.666" />
</svg>
);
}
function ArrowRightIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.67"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4.167 10h11.666M10 4.167 15.833 10 10 15.833" />
</svg>
);
}
function SaveIcon() {
return (
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.67"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5.833 2.5v3.333h6.667M14.167 17.5v-6.667H5.833V17.5M15.833 17.5H4.167A1.667 1.667 0 0 1 2.5 15.833V4.167A1.667 1.667 0 0 1 4.167 2.5h9.166L17.5 6.667v9.166a1.667 1.667 0 0 1-1.667 1.667Z" />
</svg>
);
}
const hierarchies = [
['primary', 'Primary'],
['secondary', 'Secondary'],
['tertiary', 'Tertiary'],
['link-color', 'Link color'],
['link-gray', 'Link gray'],
] as const;
const destructiveHierarchies = [
['primary', 'Delete'],
['secondary', 'Delete'],
['tertiary', 'Delete'],
['link-color', 'Delete'],
] as const;
const sizes = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
export default function ButtonDemo({ replay = 0 }: { replay?: number }) {
const [saving, setSaving] = React.useState(false);
const timer = React.useRef<number | undefined>(undefined);
const save = React.useCallback(() => {
window.clearTimeout(timer.current);
setSaving(true);
timer.current = window.setTimeout(() => setSaving(false), LOADING_MS);
}, []);
React.useEffect(() => {
if (replay > 0) save();
}, [replay, save]);
React.useEffect(() => () => window.clearTimeout(timer.current), []);
return (
<div className="flex flex-col items-center gap-8">
<div className="flex flex-wrap items-center justify-center gap-3">
{hierarchies.map(([hierarchy, label]) => (
<Button key={hierarchy} hierarchy={hierarchy} iconLeading={<PlusIcon />}>
{label}
</Button>
))}
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
{destructiveHierarchies.map(([hierarchy, label]) => (
<Button key={hierarchy} hierarchy={hierarchy} destructive>
{label}
</Button>
))}
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
{sizes.map((size) => (
<Button key={size} size={size} hierarchy="secondary" iconTrailing={<ArrowRightIcon />}>
Size {size}
</Button>
))}
</div>
<div className="flex flex-wrap items-center justify-center gap-3">
<Button data-testid="save-button" loading={saving} iconLeading={<SaveIcon />} onClick={save}>
{saving ? 'Saving' : 'Save'}
</Button>
<Button hierarchy="secondary" disabled>
Disabled
</Button>
<Button hierarchy="secondary" iconOnly aria-label="Add item">
<PlusIcon />
</Button>
</div>
</div>
);
}
API
Parts: root (button), icon slot (leading icon or spinner), label, trailing icon.
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Height and padding: 32, 36, 40, 44 or 48px. Link hierarchies keep the text height. |
| hierarchy | "primary" | "secondary" | "tertiary" | "link-color" | "link-gray" | "primary" | Visual weight. Use one primary button per area. |
| destructive | boolean | false | Uses the error palette and the error focus ring, for actions that delete data or cannot be undone. Works with primary, secondary, tertiary and link-color (the Figma Link); link-gray renders as link-color. |
| iconOnly | boolean | false | Makes a square button that shows only the icon passed as the label. Needs an aria-label. Not for link hierarchies. |
| loading | boolean | false | Shows the spinner in the icon slot, sets aria-busy and ignores clicks. The button keeps focus. |
| disabled | boolean | false | Turns the button off. Uses the native disabled attribute unless focusableWhenDisabled is set. |
| focusableWhenDisabled | boolean | false | Keeps a disabled button in the tab order with aria-disabled, so a tooltip can still explain why it is off. |
| type | "button" | "submit" | "reset" | "button" | Native button type. |
| Event | Description |
|---|---|
| click | Fires on activation. Not fired while loading or disabled. |
| Slot | Description |
|---|---|
| default | Label text, or the icon when iconOnly is set. |
| iconLeading | Icon before the label, 20px. The spinner takes its place while loading. In React this is the iconLeading prop. |
| iconTrailing | Icon after the label, 20px. In React this is the iconTrailing prop. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| BT1 | hover | root | background-color, color, border-color | Default colors → Hover colors (for example bg-brand-solid to bg-brand-solid_hover) | duration-sm (200)ease-out | Swaps at once |
| BT2 | hover (link hierarchies) | label | text-decoration-color | transparent → fg-brand-secondary_alt or fg-quaternary | duration-sm (200)ease-out | Swaps at once |
| BT3 | press (pointer down or Space held) | root | scale | 1 → scale-press | duration-sm (200)ease-out | Stays still |
| BT4 | release | root | scale | scale-press → 1 | duration-sm (200)ease-out | Stays still |
| BT5 | keyboard focus | root | box-shadow | shadow-xs-skeuomorphic → focus ring (2px bg-primary gap, 4px focus-ring) | duration-2xs (120)ease-out | Shows at once |
| BT6 | loading -> true (no leading icon) | icon slot | width, margin-inline-end | 0, 0 → 20px, the size gap | duration-xl (380)ease-out | Resizes at once |
| BT7 | loading -> true | leading icon -> spinner | opacity, filter blur | 1, blur 0 → 0, blur-swap (the spinner does the reverse) | duration-md (260)ease-out | Swaps at once |
| BT8 | loading -> true | leading icon -> spinner | scale | 1 → scale-swap (the spinner does the reverse) | duration-xl (380)ease-out | Swaps at once |
| BT9 | while loading | spinner arc | rotate | 0deg → 360deg, repeats | duration-6xl (900)ease-linear | No loop |
| BT10 | loading -> false | icon slot and icons | reverse of BT6 to BT8 | spinner shown → leading icon or closed slot | duration-xl (380)ease-out | Swaps at once |
| BT11 | disabled -> true | root | opacity | 1 → 0.5 | duration-sm (200)ease-out | Swaps at once |
Accessibility
- Native button element with type button by default, so Enter and Space work without extra code.
- While loading: aria-busy is true, clicks are ignored, and the button stays focusable so focus is not lost.
- disabled uses the native attribute and leaves the tab order; focusableWhenDisabled uses aria-disabled instead and blocks clicks in code.
- Icon-only buttons need an aria-label; icons and the spinner are aria-hidden.
- Visible focus ring on keyboard focus only (focus-visible), built from the focus-ring token.
- Link hierarchies at xs to md are 20px tall; a transparent hit area grows the target to 24px (WCAG 2.5.8).
- Reduced motion: colors and icons swap at once, the press does not scale, and the spinner stops turning.