Button
Button triggers an action in the current view. Use it for the main and secondary actions on a page, in a form or in a dialog; use a link for navigation.
Beta until the focus indicator (C5) is resolved. Everything else on this page is implemented and tested.
Preview
Change the controls to try every supported combination. The preview renders the real component and the code updates with it.
import { Button } from '@nuton/ui'
<Button>Start structured learning</Button>Installation
Availability
@nuton/ui and @nuton/tokens are private workspace packages. They aren't published to a registry yet (D4: registry deferred; D12: distribution waits on the provenance review). Inside the Nuton monorepo, add them as workspace dependencies. The commands below apply once the packages are published.
1. Add the packages
pnpm add @nuton/ui @nuton/tokens @remixicon/reactnpm install @nuton/ui @nuton/tokens @remixicon/reactyarn add @nuton/ui @nuton/tokens @remixicon/reactPeer dependencies: react and react-dom 18 or later. @remixicon/react supplies the icons you pass to buttons (Remix Icon is Nuton's icon set, D1).
2. Load tokens and component styles once
// app/layout.tsx (or your app's entry file)
import '@nuton/tokens/tokens.css' // --nu-* design tokens
import '@nuton/ui/styles.css' // component styles (uses the tokens)3. Point the font tokens at your loaded fonts
Buttons use --nu-font-body (Inter). Load Inter yourself and map the token to it. This site uses next/font:
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'], variable: '--font-inter' })
<body className={inter.variable} style={{ '--nu-font-body': 'var(--font-inter), var(--nu-font-fallback-body)' }}>Usage
import { Button } from '@nuton/ui'
export function StartLesson({ onStart }: { onStart: () => void }) {
return <Button onClick={onStart}>Start lesson</Button>
}- The default is neutral, filled, md, smooth, Nuton's black pill. Use it for the one main action in a view.
typedefaults to"button". Settype="submit"on the button that submits a form.- Labels are a verb and an object, in sentence case: “Create space”, “Start lesson”.
- Pass Remix icons through
leadingIcon/trailingIcon. Never put an icon in the label text.
Variants and sizes
Two independent props define a button: intent says what the action means, appearance sets its emphasis. Every intent works with every appearance, so there's one component, not twelve.
Intent × appearance
| Intent | Meaning | Figma Type |
|---|---|---|
neutral | The default for most actions. Filled neutral is the main action on a page. | Default |
primary | The blue state colour (State/Primary/Base). Use it when an action calls for Nuton's interactive blue rather than the neutral black. It doesn't mean “main action” (D13). Guidance on when to choose blue is pending with design. | Primary |
error | Destructive or irreversible actions: delete, remove, leave. | Error |
Sizes
sm · 36pxmd · 44pxlg · 52px<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>| Size | Height | Text inset | Icon inset | Icon gap | Icon-only | Type |
|---|---|---|---|---|---|---|
sm | 36 | 14 | 10 | 4 | 36 × 36 | Label/Small (14/20), icons 20 |
md | 44 | 16 | 12 | 6 | 44 × 44 | |
lg | 52 | 20 | 16 | 8 | 52 × 52 |
Measured in the browser. Heights follow Figma's geometry; its labels (28/32/40) are wrong and are queued for correction (C4, FC-01). Insets were rebalanced at the owner's request (P1), a recorded deviation from Figma's fixed 12/8.
Corners
<Button corners="smooth">Smooth</Button> // default, pill
<Button corners="rounded">Rounded</Button> // 10px radius
<Button corners="sharp">Sharp</Button> // squareRadius tokens: smooth --nu-radius-full, rounded --nu-radius-10 (--nu-radius-8 for small icon-only), sharp --nu-radius-0. Use one corner style per product surface.
Icons
<Button leadingIcon={<RiAddLine />}>New lesson</Button>
<Button trailingIcon={<RiArrowRightLine />}>Continue</Button>
<Button iconOnly aria-label="Settings" appearance="stroke">
<RiSettings3Line />
</Button>States and interactions
@media (hover: hover)).
aria-busy is set, clicks are ignored and focus stays on the button.State tokens
Each intent and appearance sets component tokens. Override them on a wrapper only for a documented exception.
| Token | Neutral filled | Primary filled | Error filled | Stroke / ghost / lighter |
|---|---|---|---|---|
--nu-button-bg | Action/Primary/Bg (Strong-950) | State/Primary/Base | State/Error/Base | none / none / State/<intent>/Lighter (neutral: Soft-100) |
--nu-button-bg-hover | Surface-800 | Primary/700 | Error/700 | Stroke and ghost: State/<intent>/Lighter (neutral Weak-50). Lighter: State/<intent>/Light (neutral Soft-200) |
--nu-button-fg | White | White | White | State/<intent>/Base (neutral: Strong-950) |
--nu-button-focus-shadow | buttons/important-focus | buttons/primary-focus | buttons/error-focus | Same as the intent |
Composition examples
Action pair
One filled main action plus a lower-emphasis alternative. The main action goes last (on the right in left-to-right layouts).
Destructive confirmation
Form actions
Icon-only toolbar
A real toolbar also needs arrow-key movement between its buttons (WAI-ARIA toolbar). Without it, drop role="toolbar" and leave the buttons in the tab order.
Grouped options
When the buttons choose between options (one is selected), use Button group, which sets aria-pressed.
Navigation
Navigation must use a link (<a href>), so it can be opened in a new tab, copied and announced as a link. Button always renders <button> and has no link mode. Use Link button for navigation.
Gap: a link that looks like a button (C27)
The API has no way to give a link the filled-button look, for example a “Get started” call to action that navigates. Don't wrap a <Button> in an <a> (nested interactive elements), and don't navigate from onClick. Proposed fix, awaiting approval: an href or asChild prop that renders an <a> with the same styles.
API reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
intent | 'neutral' | 'primary' | 'error' | 'neutral' | What the action means (Figma Type). |
appearance | 'filled' | 'stroke' | 'lighter' | 'ghost' | 'filled' | Visual emphasis (Figma Style). |
size | 'sm' | 'md' | 'lg' | 'md' | Height 36 / 44 / 52px. |
corners | 'smooth' | 'rounded' | 'sharp' | 'smooth' | Corner style: pill, 10px, square. |
leadingIcon | ReactNode | — | Icon before the label. Rendered aria-hidden. |
trailingIcon | ReactNode | — | Icon after the label. Rendered aria-hidden. Ignored when iconOnly. |
iconOnly | boolean | false | Square button showing children as the icon. Needs aria-label or aria-labelledby (development warning otherwise). |
loading | boolean | false | Spinner, aria-busy and aria-disabled; clicks are ignored; stays focusable. |
disabled | boolean | false | Native disabled. Removes the button from the tab order. |
type | 'button' | 'submit' | 'reset' | 'button' | Native type. Defaults to button so it never submits by accident. |
ref | Ref<HTMLButtonElement> | — | Forwarded to the native button. |
…rest | ButtonHTMLAttributes | — | Any other native button attribute (onClick, aria-*, form, name…). |
Styling hooks
The component renders .nu-button with data-intent, data-appearance, data-size, data-corners, data-icon-only and data-loading. Use props rather than styling these attributes. The component tokens --nu-button-* (see State tokens) are the supported override points.
Accessibility
Keyboard
| Key | Behaviour |
|---|---|
| Tab | Moves focus to the button. Disabled buttons are skipped; loading buttons stay focusable. |
| Enter / Space | Activates the button (native behaviour). |
Screen readers
Role button. The name comes from the label, or from aria-label when icon-only. Icons are hidden. Loading is announced as busy.
Focus
Focus stays on the button while loading. The visible indicator is Figma's focus effect, below 3:1 today (C5). An accessible alternative token is ready but not applied.
Contrast and colour
Neutral and primary labels pass AA. Error buttons use Red 700 instead of Figma's State/Error/Base (4.15:1): labels 5.26:1 on white and the filled button, 4.6:1 on the lighter tint (C6). Intent is never the only cue: the label names the action.
Touch and pointer
The smallest button is 36 × 36, above the 24 × 24 minimum (WCAG 2.5.8). Hover styles apply only on hover-capable pointers.
You must: give icon-only buttons an aria-label; set type="submit" on submit buttons; use a link for navigation.
Figma mapping
| Figma property | Figma values | Code |
|---|---|---|
| Corners | Rounded · Smooth · Sharp | corners |
| Type | Default · Primary · Error | intent: neutral · primary · error (D2) |
| Style | Filled · Stroke · Lighter · Ghost | appearance |
| Size | Small (28) · Medium (32) · Medium (40), labels wrong | size: sm · md · lg = 36 · 44 · 52 (C4) |
| State | Default · Hover · Focus · Disabled | Interactive states, disabled, plus loading (code-only) |
| Only Icon · Left / Right Icon | Off · On | iconOnly, leadingIcon, trailingIcon |
Guidelines and related components
Use Button for
- Actions that change something here: save, submit, generate, delete.
- The main and secondary actions of a form or dialog.
- Starting a flow in place, such as opening a dialog or a menu.
Don't use Button for
- Going to another page. Use Link button or a link.
- Choosing one option from a set. Use Button group.
- A list of more than 2–3 secondary actions. Use a Dropdown menu.
Do and don't
Common mistakes
- Leaving a form's submit button as the default
type="button", so Enter in a field does nothing. - Disabling a button without saying why. Prefer keeping it enabled and showing a validation message on click.
- Using
intent="primary"to mean “most important”. Emphasis comes fromappearance; intent is meaning. - Navigating from
onClickinstead of using a link.