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
  • Accessibility
  • Figma Buttons 8:2884
  • packages/ui/src/components/Button

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.

tsx
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

bash
pnpm add @nuton/ui @nuton/tokens @remixicon/react

Peer 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

tsx
// 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:

tsx
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

tsx
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.
  • type defaults to "button". Set type="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

IntentFilledStrokeLighterGhost
Neutral
Primary
Error
IntentMeaningFigma Type
neutralThe default for most actions. Filled neutral is the main action on a page.Default
primaryThe 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
errorDestructive or irreversible actions: delete, remove, leave.Error

Sizes

sm · 36px
md · 44px
lg · 52px
SizeHeightText insetIcon insetIcon gapIcon-onlyType
sm361410436 × 36Label/Small (14/20), icons 20
md441612644 × 44
lg522016852 × 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

Radius 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

States and interactions

DefaultResting state.
HoverShown here statically. Applies only on devices with hover (@media (hover: hover)).
FocusKeyboard focus adds the intent's focus ring. Tab to this button to see it. Below 3:1 today (C5).
DisabledWeak-50 fill and Disabled-500 text for every intent. Can't be focused; prefer explaining why an action is unavailable.

 

LoadingClick it. The spinner replaces the leading icon, aria-busy is set, clicks are ignored and focus stays on the button.
No distinct style
PressedFigma defines no pressed state, so a press shows the hover style. A pressed style needs a design decision (C27).

State tokens

Each intent and appearance sets component tokens. Override them on a wrapper only for a documented exception.

TokenNeutral filledPrimary filledError filledStroke / ghost / lighter
--nu-button-bgAction/Primary/Bg (Strong-950)State/Primary/BaseState/Error/Basenone / none / State/<intent>/Lighter (neutral: Soft-100)
--nu-button-bg-hoverSurface-800Primary/700Error/700Stroke and ghost: State/<intent>/Lighter (neutral Weak-50). Lighter: State/<intent>/Light (neutral Soft-200)
--nu-button-fgWhiteWhiteWhiteState/<intent>/Base (neutral: Strong-950)
--nu-button-focus-shadowbuttons/important-focusbuttons/primary-focusbuttons/error-focusSame 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

For example, Biology 101.

 

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 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

Button props
PropTypeDefaultDescription
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.
leadingIconReactNode—Icon before the label. Rendered aria-hidden.
trailingIconReactNode—Icon after the label. Rendered aria-hidden. Ignored when iconOnly.
iconOnlybooleanfalseSquare button showing children as the icon. Needs aria-label or aria-labelledby (development warning otherwise).
loadingbooleanfalseSpinner, aria-busy and aria-disabled; clicks are ignored; stays focusable.
disabledbooleanfalseNative disabled. Removes the button from the tab order.
type'button' | 'submit' | 'reset''button'Native type. Defaults to button so it never submits by accident.
refRef<HTMLButtonElement>—Forwarded to the native button.
…restButtonHTMLAttributes—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

KeyBehaviour
TabMoves focus to the button. Disabled buttons are skipped; loading buttons stay focusable.
Enter / SpaceActivates 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 propertyFigma valuesCode
CornersRounded · Smooth · Sharpcorners
TypeDefault · Primary · Errorintent: neutral · primary · error (D2)
StyleFilled · Stroke · Lighter · Ghostappearance
SizeSmall (28) · Medium (32) · Medium (40), labels wrongsize: sm · md · lg = 36 · 44 · 52 (C4)
StateDefault · Hover · Focus · DisabledInteractive states, disabled, plus loading (code-only)
Only Icon · Left / Right IconOff · OniconOnly, 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

Do and don't

DoUse one filled neutral button per view. It marks the next step; everything else steps down in emphasis.
Don'tDon't make every action filled. When everything is emphasised, nothing is.
DoWrite a verb and an object. People know exactly what will happen before they click.
Don'tDon't use vague labels. “OK” or “Yes” forces people to re-read the question.
DoName icon-only buttons. The aria-label is the only name a screen reader gets.
Don'tDon't use icon-only for uncommon actions. An unfamiliar icon with no text is a guess. Add a label.

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 from appearance; intent is meaning.
  • Navigating from onClick instead of using a link.