Skip to content
Nizivo UI

Button

A native button in five variants and three sizes, with a pending state that keeps focus. Compose it into triggers and links through render.

Server component, no client JS

Import

import { Button } from '@nizivo/react/button';

// Once per app (Getting started), after
// @nizivo/tokens/tokens.css and @nizivo/react/css/base.css
import '@nizivo/react/css/button.css';

Static or interactive

Server. It ships no client JavaScript when a Server Component renders it, in every Next.js build mode; its code is bundled only when a client component (a 'use client' file) imports it.

onClick works as on any <button>. In a client app (a Vite or React Router single-page app, or a 'use client' file) pass it, refs and state as usual (In a client app). Only a Server Component cannot pass it, which is React's rule for every component: there, use type="submit" in a <form action={serverAction}>, render={<a href="…" />}, or a client component that owns the handler. How to choose.

Pending

pending replaces the leading icon with a spinner, keeps the label, and keeps the button focusable with aria-disabled, swallowing clicks. The spinner turns once every 800 ms (--nz-duration-spin, Figma's duration/spin). Under Reduced motion (the OS setting, or data-nz-motion="reduce" on an ancestor) it stops, and the text "Loading…" shows in the label's place, so the wait is still visible without movement (Figma's Accessibility page: "Spinner and skeleton loops stop; a text label shows"). The text and the label share one space, so the button keeps its width when pending starts (it grows only if "Loading…" is wider than the label). Where the text does not fit, it ends in an ellipsis.

<>
  {/* Reduced motion: the spinner stops and "Loading…" replaces "Publishing" */}
  <Button pending>Publishing</Button>
  <Button pending labels={{ pending: 'Wird geladen…' }}>
    Veröffentlichen
  </Button>
</>

The label is only made transparent, so it stays in the accessible name, after the text on screen: "Loading… Publishing" (WCAG 2.5.3, the visible words are part of the name). Under default motion the text is not rendered visibly and the name is the label. Assistive technology also learns the state from aria-disabled. labels={{ pending: '' }} shows no text and keeps the label visible. The markup is the same in both motion modes; only CSS reads the preference.

Translate the text with labels.pending. It is set per instance: Button is a server component, so NizivoConfig's labels, which reach the client parts only, do not reach it. SubmitButton, its client twin, does read button.pending from them. A localised app wraps Button once and uses the wrapper everywhere (see Strings and locales):

import { Button, type ButtonProps } from '@nizivo/react/button';

const strings = { loading: 'Wird geladen…' }; // the app's own message catalogue

export function AppButton({ labels, ...props }: ButtonProps) {
  return <Button labels={{ pending: strings.loading, ...labels }} {...props} />;
}

Examples

Basic

Orders
import type { ComponentPropsWithRef, JSX } from 'react';
import { Button } from '@nizivo/react/button';

/** Stands in for a router link (Next `Link`, React Router `Link`). */
function Link({ children, ...props }: ComponentPropsWithRef<'a'>): JSX.Element {
  return <a {...props}>{children}</a>;
}

export function ButtonExample(): JSX.Element {
  return (
    <div>
      <Button variant="primary">Save</Button>
      <Button>Cancel</Button>
      <Button variant="accent">Upgrade</Button>
      <Button variant="critical">Remove</Button>
      <Button size="lg">Continue</Button>
      <Button variant="critical" size="sm" disabled>
        Delete
      </Button>
      <Button variant="tertiary" pending>
        Publishing
      </Button>
      <Button render={<Link href="/orders" />} fullWidth>
        Orders
      </Button>
    </div>
  );
}

Props

Server component Renders data-slot="button" with the class nz-button. Props: ButtonProps. Source: src/button/button.tsx.

NameTypeDefaultDescription
childrenReactNode–
fullWidthbooleanfalse

Fills the width of its container.

labelsPartial<ButtonLabels>–

Overrides Button's strings, for example labels={{ pending: 'Wird geladen…' }}; pending: '' shows no text and keeps the label visible. Translation is per instance: Button is a server part, so NizivoConfig labels does not reach it, and an app wraps Button once to pass its own. SubmitButton, its client twin, does read them (button.pending).

leadingIconReactNode–
pendingbooleanfalse

An action is in flight: the spinner replaces the leading icon, the label stays, and the button stays focusable with aria-disabled and swallows clicks. The spinner turns once per --nz-duration-spin; under Reduced motion it stops and labels.pending ("Loading…") shows in the label's place, in a button that keeps its width. The label stays in the accessible name, after that text ("Loading… Publish").

pendingIndicatorReactNode–

Replaces the internal spinner glyph.

renderRenderProp<ButtonState>–

Composition, for example render={<Link href="/orders" />}.

sizeControlSize
"sm""md""lg"
'md'

32 / 40 / 48 px.

trailingIconReactNode–
variantButtonVariant
"primary""secondary""tertiary""critical""accent"
'secondary'

Figma "Variant". One primary per view; secondary for everything else; tertiary inside dense rows.

  • Also takes every attribute of <button> except children. (Omit<ComponentPropsWithRef<"button">, "children">)

render takes an element, or, in client code, a function of the props and this state (ButtonState):

NameTypeDefaultDescription
disabledrequiredboolean–
pendingrequiredboolean–
sizerequiredControlSize
"sm""md""lg"
–
variantrequiredButtonVariant
"primary""secondary""tertiary""critical""accent"
–

Other exports

ButtonLabels type

import type { ButtonLabels } from '@nizivo/react/button'

Button's own strings (§16.4): serialisable, English defaults.

NameTypeDefaultDescription
pendingrequiredstring'Loading…'

The text a pending button shows in place of its label under Reduced motion, where the spinner stops (Figma: "Spinner and skeleton loops stop; a text label shows").

ButtonProps type

import type { ButtonProps } from '@nizivo/react/button'

The props of Button, listed above.

ButtonState type

import type { ButtonState } from '@nizivo/react/button'

The render state of Button, listed above.

Data attributes and slots

Style parts by class (.nz-<slot>) and state by data attribute. Attribute meanings come from contracts/vocabulary.json.

SlotRendered byData attributes
button
.nz-button
Button
root (takes render); interactive, focus: self
  • data-disabled (present or absent) ← disabled
    Mirrors the disabled attribute
  • data-full-width (present or absent) ← fullWidth
    The fullWidth prop: the control fills the inline size of its container
  • data-pending (present or absent) ← pending
    Data lifecycle: pending (§16.5); also an action in flight on Button and SubmitButton
  • data-size: "sm""md""lg" ← size
    The size prop; values in each component manifest
  • data-variant: "primary""secondary""tertiary""critical""accent" ← variant
    The variant prop; values are the lowercase Figma names in each component manifest
button-icon
.nz-button-icon
Button
inner element (CSS only)
none
button-label
.nz-button-label
Button
inner element (CSS only)
none
button-pending-label
.nz-button-pending-label
Button
inner element (CSS only)
none

CSS hooks

Set a hook on the component's class or any ancestor to restyle it without specificity games; leave it unset for the default shown.

HookDefaultRead as
--nz-button-bg
  • default: var(--nz-bg-surface)
  • data-variant="accent": var(--nz-bg-accent)
  • data-variant="critical": var(--nz-bg-critical)
  • data-variant="primary": var(--nz-bg-brand)
  • data-variant="tertiary": transparent
  • background on .nz-button
--nz-button-bg-disabled--nz-bg-disabled
  • background on .nz-button:is(:disabled, [data-disabled])
--nz-button-bg-hover
  • default: var(--nz-bg-hover)
  • data-variant="accent": var(--nz-bg-accent-hover)
  • data-variant="critical": var(--nz-bg-critical-hover)
  • data-variant="primary": var(--nz-bg-brand-hover)
  • background on .nz-button:hover:not(:disabled, [data-disabled], [aria-disabled="true"]) @media (hover: hover)
--nz-button-bg-pressed
  • default: var(--nz-bg-pressed)
  • data-variant="accent": var(--nz-bg-accent-hover)
  • data-variant="critical": var(--nz-bg-critical-hover)
  • data-variant="primary": var(--nz-bg-brand-pressed)
  • background on .nz-button:active:not(:disabled, [data-disabled], [aria-disabled="true"])
--nz-button-border
  • default: var(--nz-border-default)
  • data-variant="accent": transparent
  • data-variant="critical": transparent
  • data-variant="primary": transparent
  • data-variant="tertiary": transparent
  • border on .nz-button
--nz-button-border-hover
  • default: var(--nz-border-strong)
  • data-variant="accent": transparent
  • data-variant="critical": transparent
  • data-variant="primary": transparent
  • data-variant="tertiary": transparent
  • border-color on .nz-button:active:not(:disabled, [data-disabled], [aria-disabled="true"])
  • border-color on .nz-button:hover:not(:disabled, [data-disabled], [aria-disabled="true"]) @media (hover: hover)
--nz-button-fg
  • default: var(--nz-text-primary)
  • data-variant="accent": var(--nz-text-on-accent)
  • data-variant="critical": var(--nz-text-on-critical)
  • data-variant="primary": var(--nz-text-on-brand)
  • color on .nz-button
--nz-button-fg-disabled--nz-text-disabled
  • color on .nz-button:is(:disabled, [data-disabled])
--nz-button-font
  • default: var(--nz-font-label-m)
  • data-size="lg": var(--nz-font-label-l)
  • font on .nz-button
--nz-button-gap
  • default: var(--nz-space-2)
  • data-size="sm": var(--nz-space-1-5)
  • gap on .nz-button
--nz-button-height
  • default: var(--nz-size-control-md)
  • data-size="lg": var(--nz-size-control-lg)
  • data-size="sm": var(--nz-size-control-sm)
  • block-size on .nz-button
--nz-button-padding-inline
  • default: var(--nz-space-4)
  • data-size="lg": var(--nz-space-5)
  • data-size="sm": var(--nz-space-3)
  • padding-inline on .nz-button
--nz-button-radius
  • default: var(--nz-radius-md)
  • data-size="sm": var(--nz-radius-sm)
  • border-radius on .nz-button
--nz-button-shadow
  • default: none
  • data-variant="primary": var(--nz-elevation-button)
  • box-shadow on .nz-button
  • box-shadow on .nz-button:focus-visible

Layers: nz.components. Classes: .nz-button, .nz-button-icon, .nz-button-label, .nz-button-pending-label, .nz-glyph-spinner.

Tokens this stylesheet reads (42)

--nz-bg-accent, --nz-bg-accent-hover, --nz-bg-brand, --nz-bg-brand-hover, --nz-bg-brand-pressed, --nz-bg-critical, --nz-bg-critical-hover, --nz-bg-disabled, --nz-bg-hover, --nz-bg-pressed, --nz-bg-surface, --nz-border-default, --nz-border-strong, --nz-border-subtle, --nz-border-width-default, --nz-border-width-focus, --nz-duration-fast, --nz-duration-instant, --nz-duration-spin, --nz-easing-standard, --nz-elevation-button, --nz-focus-halo, --nz-font-label-l, --nz-font-label-m, --nz-radius-md, --nz-radius-sm, --nz-scale-press, --nz-size-control-lg, --nz-size-control-md, --nz-size-control-sm, --nz-size-icon-md, --nz-size-icon-sm, --nz-space-1-5, --nz-space-2, --nz-space-3, --nz-space-4, --nz-space-5, --nz-text-disabled, --nz-text-on-accent, --nz-text-on-brand, --nz-text-on-critical, --nz-text-primary

Accessibility

These parts are interactive. Each gets the focus outline and touch-action rule from @nizivo/react/css/base.css (the nz.base layer), so the floor survives a restyle:

  • button: draws the focus outline itself on keyboard focus.

Keyboard

KeysAction
TabMoves focus to the button. A pending button stays focusable (aria-disabled); a disabled one does not.
Enter, SpaceActivates the button. A pending button swallows the activation.

Source: a native <button> (button.tsx); with render={<a />} it is a link and only Enter activates it.

Figma

Node ids from design/component-map.json, which nz gen writes from component.json. The links open the Nizivo Figma library, which may ask you to sign in.

On this page