Button
StableThe lit-metal button in five finishes and five sizes, with icons, a loading state and shortcut hints.
Every button is a small piece of metal: a graded face, a 1px silver rim, brushed grain and a soft shadow underneath. Primary is graphite in light mode and silver in dark mode; secondary is pearl. Use one primary button per view for the main action.
Finishes
import { Button } from '@rchursin/product/ui'
/** * Five finishes. Primary is graphite in light mode and silver in dark mode. Inverse is always the * opposite of the page, so it goes on an inverted panel. */export default function ButtonVariants() { return ( <div className="flex flex-wrap items-center justify-center gap-3"> <Button>Save changes</Button> <Button variant="secondary">Preview</Button> <Button variant="outline">Export</Button> <Button variant="ghost">Cancel</Button> <div className="flex items-center gap-3 rounded-xl bg-inverse py-2 pr-2 pl-3.5 text-[12px] text-on-inverse/70"> Inverted panel <Button variant="inverse">Publish</Button> </div> </div> )}Usage
import { Button } from '@rchursin/product/ui'<Button icon="plus" onClick={createTask}>New task</Button>Examples
Sizes
xs 24 · sm 28 · md 32 (default) · lg 36 · xl 40 pixels tall. Every size clears the 24 pixel minimum target.
import { Button } from '@rchursin/product/ui'
/** xs 24 · sm 28 · md 32 (default) · lg 36 · xl 40. Every size clears the 24px target minimum. */export default function ButtonSizes() { return ( <div className="flex flex-wrap items-center justify-center gap-3"> <Button size="xs">Extra small</Button> <Button size="sm">Small</Button> <Button size="md">Medium</Button> <Button size="lg">Large</Button> <Button size="xl">Extra large</Button> </div> )}With icons
An icon before the label says what kind of action it is; an icon after it says where it goes next.
import { Button } from '@rchursin/product/ui'
/** An icon before the label says what kind of action it is; one after it says where it goes. */export default function ButtonIcons() { return ( <div className="flex flex-wrap items-center justify-center gap-3"> <Button icon="plus">New task</Button> <Button variant="secondary" icon="download"> Download </Button> <Button variant="outline" iconRight="arrow-right"> Continue </Button> </div> )}Loading
Press it: the spinner replaces the icon and the button stays focusable, so keyboard focus is never lost.
import { useState } from 'react'import { Button } from '@rchursin/product/ui'
/** * While loading, the button keeps its width and stays focusable (aria-busy, not disabled), * so keyboard focus is never lost mid-action. */export default function ButtonLoading() { const [saving, setSaving] = useState(false)
function save() { setSaving(true) window.setTimeout(() => setSaving(false), 1600) }
return ( <Button loading={saving} onClick={save}> {saving ? 'Saving…' : 'Save'} </Button> )}Shortcut hint
Shows the keys that do the same thing. “mod” is ⌘ on Apple keyboards and Ctrl elsewhere.
import { Button } from '@rchursin/product/ui'
/** A shortcut hint inside the button. ⌘ on Apple keyboards, Ctrl elsewhere. */export default function ButtonShortcut() { return ( <div className="flex flex-wrap items-center justify-center gap-3"> <Button shortcut="mod+enter">Send</Button> <Button variant="secondary" shortcut="c"> Create </Button> </div> )}API
Button props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | 'primary' | 'secondary' | 'ghost' | 'outline' | 'inverse' | 'primary' | The finish. Inverse is for dark panels on a light page (and the other way round). |
size | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | Height 24, 28, 32, 36 or 40 pixels. |
icon | IconName | – | An icon from the Library’s set, shown before the label. |
iconRight | IconName | – | An icon after the label, for actions that lead somewhere. |
loading | boolean | false | Shows a spinner, sets aria-busy and ignores clicks, but keeps the button focusable (not disabled). |
shortcut | string | – | A key hint drawn inside the button, e.g. "mod+enter". Display only: bind the keys with useHotkeys. |
type | 'button' | 'submit' | 'reset' | 'button' | Defaults to "button" so a button inside a form never submits by accident. |
Accessibility
Keyboard
| Key | What it does |
|---|---|
| EnterSpace | Presses the button. |
| Tab | Moves focus to the next control. Focus shows as a 2px ring that follows the theme. |
- It is a real <button>, so the browser handles focus, Enter and Space.
- While loading it sets aria-busy="true" instead of disabled, so focus stays put and screen readers hear that it is busy.
- Labels are near-black on the pale finishes and near-white on the dark ones, far above 4.5:1 in both themes. The secondary finish is only faintly edged on a white page, so its label, not its edge, says it is a button: keep labels as verbs.
- Disabled buttons use the native disabled attribute: they drop to 45% opacity and leave the Tab order. If you disable one, say why nearby; better, keep it enabled and explain the problem after a press.
Guidelines
Do
- Start labels with a verb: “Create task”, “Save changes”.
- Use one primary button per view.
- Keep the label short enough to fit on one line on a phone.
Don’t
- Don’t use a button to go to another page; use a link styled as a button.
- Don’t write “Click here” or “Submit”.
- Don’t disable the main action to signal a form error; let the press explain what to fix.
In use
Used on this site
Related
- Icon buttonA square button with just an icon. The label is required: it is the accessible name and the tooltip.
- Key capsKeyboard keys cut from the same metal as the buttons, and shortcuts written as key caps.
- SpinnerThe loader icon turning once every 900 ms, only when motion is allowed. Decorative next to words; give it a label when it stands alone.
Source
The component’s own files, exactly as they run on this site.
/** * App buttons: the same lit-metal finish as the site buttons (styles/luxe.css), in denser sizes. * * <Button>Save</Button> graphite (silver in dark mode) * <Button variant="secondary" icon="plus">New task</Button> pearl * <Button variant="ghost" size="sm">Cancel</Button> * <IconButton icon="x" label="Close" /> icon only: the label is the accessible name + tooltip * * Sizes: xs 24 · sm 28 · md 32 (default) · lg 36 · xl 40 (auth screens). All clear the 24px target minimum. */import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from "react"import { cx } from "../../components/cx"import { Icon, type IconName } from "../icons/Icon"import { comboKeys, useIsApple } from "../lib/hotkeys"
export type ButtonVariant = "primary" | "secondary" | "ghost" | "outline" | "inverse"export type ButtonSize = "xs" | "sm" | "md" | "lg" | "xl"
const variants: Record<ButtonVariant, string> = { primary: "lux-btn-ink",