Button
since v0.1.0Primary interactive element — use for all user-initiated actions.
Variants:
primary(default) — ink fill, the everyday button. Use for most actions.brand— orange CTA. Reserve for the one ember moment per surface. Opt-in.outline— hairline border on transparent. Lower-emphasis alternative.ghost— text-only. Lowest emphasis.destructive— hairline + error tint. Destructive or irreversible actions.
Pass href to render as an <a> automatically, or as={Link} for Next.js Link.
For non-<button> elements, disabled and loading set aria-disabled, block pointer events, and no-op onClick.
While loading, the control sets aria-busy and adds an sr-only "Loading…" affordance so the busy state is announced (the spinner SVG itself is aria-hidden).
Install
Add the package and import the component.
pnpm add @hey-mike/tungstenpnpm add @hey-mike/tungstenimport { Button } from '@hey-mike/tungsten';import { Button } from '@hey-mike/tungsten';Preview
Same fixtures used by the visual-regression suite.
Usage
apps/docs/app/snapshots/button/page.tsx
import { Button } from '@hey-mike/tungsten';
import { VariantGrid } from '../_components/VariantGrid';
export default function ButtonSnapshot() {
return (
<VariantGrid
title="Button"
variants={[
{ label: 'primary', node: <Button>Primary</Button> },
{ label: 'brand', node: <Button variant="brand">Brand</Button> },
{ label: 'outline', node: <Button variant="outline">Outline</Button> },
{ label: 'ghost', node: <Button variant="ghost">Ghost</Button> },
{ label: 'destructive', node: <Button variant="destructive">Destructive</Button> },
{ label: 'sm', node: <Button size="sm">Small</Button> },
{ label: 'md', node: <Button size="md">Medium</Button> },
{ label: 'lg', node: <Button size="lg">Large</Button> },
{ label: 'disabled', node: <Button disabled>Disabled</Button> },
{ label: 'loading', node: <Button loading>Saving…</Button> },
]}
/>
);
}
import { Button } from '@hey-mike/tungsten';
import { VariantGrid } from '../_components/VariantGrid';
export default function ButtonSnapshot() {
return (
<VariantGrid
title="Button"
variants={[
{ label: 'primary', node: <Button>Primary</Button> },
{ label: 'brand', node: <Button variant="brand">Brand</Button> },
{ label: 'outline', node: <Button variant="outline">Outline</Button> },
{ label: 'ghost', node: <Button variant="ghost">Ghost</Button> },
{ label: 'destructive', node: <Button variant="destructive">Destructive</Button> },
{ label: 'sm', node: <Button size="sm">Small</Button> },
{ label: 'md', node: <Button size="md">Medium</Button> },
{ label: 'lg', node: <Button size="lg">Large</Button> },
{ label: 'disabled', node: <Button disabled>Disabled</Button> },
{ label: 'loading', node: <Button loading>Saving…</Button> },
]}
/>
);
}
Props
Surface specific to <Button />.
| Prop | Type | Default | Description |
|---|---|---|---|
| as | ElementType | — | Render as a different element. Pass as={Link} for Next.js Link. |
| variant | "primary" | "brand" | "outline" | "ghost" | "destructive" | primary | Visual treatment. |
| size | "sm" | "md" | "lg" | md | Control height + padding. |
| loading | boolean | false | Shows a spinner and sets aria-busy. Blocks onClick while true. |
| iconBefore | ReactNode | — | Leading icon rendered in a pill. |
| iconAfter | ReactNode | — | Trailing icon rendered in a pill. |
Used in recipes
Compositions from the /recipes reference that use this component.
- →
Dual-path CTA
Dashboard next-action band with a recommended primary path (brand-overlay gradient + brand-tinted border) and a secondary review path. Both rest the action against a meta pill so the user sees mode, duration, or queue state before committing.
- →
Followup panel
Post-evaluation follow-up question, collapsed by default. Uses native <details> for the toggle so the section is keyboard-operable without JS.
- →
Login form
Minimal email + password sign-in form built on FormProvider + FormField. Email is required and format-checked; the submit handler only fires onValid when both fields pass.
- →
Multi-step wizard
A two-step wizard sharing one FormProvider across steps. Step navigation is local recipe state; field values and validation stay owned by FormProvider — the thin layer is not extended for step state. Per-step Next validates only that step’s fields before advancing.
- →
Profile edit form
Pre-filled profile form using FormProvider defaultValues, mixing Input, Select, and Textarea controls — all wired through FormField with no per-control glue.
- →
Row card
Interactive list row used on /problems and similar list views. Hover lifts -2px and reveals a faint gradient-mask border in the brand hue. Header carries level + topic + tag badges with a meta line and trailing action button.
- →
Settings form
Account settings panel grouping several FormFields with help text under section headings. Shows FormField composing labelled controls in a denser, sectioned layout.
- →
Signup form
Account creation form demonstrating cross-field validation: the confirm-password field is checked against password via FormProvider’s form-level validate(values).