Skip to content
Nizivo UI

IconButton

A square button that shows only an icon. Its required label is the button's accessible name and, by default, the text of a tooltip shown on hover and keyboard focus.

Server component, no client JS

Import

import { IconButton, IconButtonStatic } from '@nizivo/react/icon-button';

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

Static or interactive

Split. The default tooltip ships the tooltip leaf and the Tooltip code (+20,802 B gzip under Turbopack, once per page). tooltip={false} runs no client code, but Turbopack and next dev still ship that code, because the shell imports the leaf; IconButtonStatic, from the same import path, imports no client module. How to choose.

In a client app (a Vite or React Router single-page app, or a 'use client' file) it is an ordinary React component: event handlers, refs and state work as usual. In a client app.

import { IconButton, IconButtonStatic } from '@nizivo/react/icon-button';

<IconButton label="Delete product" icon={<Trash />} />; // the tooltip shows "Delete product"
<IconButtonStatic aria-label="Delete product" icon={<Trash />} />; // no tooltip, no client module

Use IconButtonStatic only where something visible already names the action for sighted users, such as a column header; otherwise keep the tooltip. It takes no label, tooltip or shortcut (TypeScript rejects them); pass aria-keyshortcuts yourself to announce a shortcut.

Bundle size with Turbopack

<IconButton tooltip={false}> renders its button on the server and runs no client code, and production webpack ships none of the tooltip code for it. Turbopack and next dev ship every client module a page's server code imports, rendered or not, so the IconButton's tooltip leaf and the Tooltip code still reach the page. Measured on the Next fixture (gzip of the page's client chunks, a page with one <IconButton tooltip={false}> minus a page with one Button): Turbopack production +20,802 B, production webpack 0 B, next dev +62,742 B (unminified), the same as a page that shows the tooltip. IconButtonStatic avoids it: its module imports no client code, so a page that renders only IconButtonStatic ships none of the tooltip code in any mode (the fixture's /icon-button-static page: 0 B over the Button page under Turbopack, webpack and next dev). On a page that already loads the Tooltip code for something else, it saves nothing. Dialog.Close and AlertDismiss write the IconButton markup themselves, so they do not import the tooltip.

Pending

pending replaces the glyph with a spinner and keeps the button focusable with aria-disabled, swallowing clicks. The spinner turns once every 800 ms (--nz-duration-spin). Under Reduced motion it stops upright. A Button then shows "Loading…" after its label, but an icon-only button has no room for text, so nothing visible says it is loading: when the wait is more than a moment, say so in words next to it (a status line such as "Deleting…"), or use a Button with a label. This differs from Figma's rule ("Spinner and skeleton loops stop; a text label shows") and is listed under Deviations.

Examples

Basic

Secondary (the default), tertiary and primary. Each shows its label in a tooltip on hover and focus.

import type { JSX } from 'react';
import { Inline } from '@nizivo/react/inline';
import { IconButton } from '@nizivo/react/icon-button';

/** Stand-ins for `@nizivo/icons` glyphs (20 px Lucide strokes). */
function Bell(): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0M3.26 15.33A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.67C19.41 13.96 18 12.5 18 8A6 6 0 0 0 6 8c0 4.5-1.41 5.96-2.74 7.33" />
    </svg>
  );
}

function Trash(): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
    </svg>
  );
}

function Plus(): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M5 12h14M12 5v14" />
    </svg>
  );
}

export function IconButtonExample(): JSX.Element {
  return (
    <Inline gap="2">
      <IconButton label="Notifications" icon={<Bell />} />
      <IconButton label="Delete product" icon={<Trash />} variant="tertiary" />
      <IconButton label="Add product" icon={<Plus />} variant="primary" />
    </Inline>
  );
}

Sizes

32, 40 and 48 px. tooltip={false} renders on the server with no client code.

import type { JSX } from 'react';
import { Inline } from '@nizivo/react/inline';
import { IconButton } from '@nizivo/react/icon-button';

function Plus(): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M5 12h14M12 5v14" />
    </svg>
  );
}

export function IconButtonSizesExample(): JSX.Element {
  return (
    <Inline gap="2">
      <IconButton label="Add product" icon={<Plus />} size="sm" tooltip={false} />
      <IconButton label="Add product" icon={<Plus />} tooltip={false} />
      <IconButton label="Add product" icon={<Plus />} size="lg" tooltip={false} />
    </Inline>
  );
}

States

A toggle keeps its label and reports its state with pressed (aria-pressed); pending keeps the button focusable while the action runs; a disabled button has no tooltip.

import type { JSX } from 'react';
import { Inline } from '@nizivo/react/inline';
import { IconButton } from '@nizivo/react/icon-button';

function Trash(): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
    </svg>
  );
}

function Bold(): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8" />
    </svg>
  );
}

export function IconButtonStatesExample(): JSX.Element {
  return (
    <Inline gap="2">
      <IconButton label="Bold" icon={<Bold />} variant="tertiary" pressed={false} />
      <IconButton label="Bold" icon={<Bold />} variant="tertiary" pressed />
      <IconButton label="Delete product" icon={<Trash />} variant="critical" pending />
      <IconButton label="Delete product" icon={<Trash />} disabled />
    </Inline>
  );
}

Shortcut

The shortcut shows in the tooltip after the label: ⌘B on Apple devices, Ctrl+B elsewhere.

import type { JSX } from 'react';
import { IconButton } from '@nizivo/react/icon-button';

function Bold(): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M6 12h9a4 4 0 0 1 0 8H7a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1h7a4 4 0 0 1 0 8" />
    </svg>
  );
}

export function IconButtonShortcutExample(): JSX.Element {
  return (
    <IconButton
      label="Bold"
      icon={<Bold />}
      variant="tertiary"
      shortcut="mod+b"
      tooltip={{ side: 'bottom' }}
    />
  );
}

Static

IconButtonStatic in a Server Component: no tooltip and no client code on the page. The name is aria-label. The second renders as a link, as a row action in a server-rendered table might.

import type { ComponentPropsWithRef, JSX } from 'react';
import { Inline } from '@nizivo/react/inline';
import { IconButtonStatic } from '@nizivo/react/icon-button';

function Trash(): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
    </svg>
  );
}

function Plus(): JSX.Element {
  return (
    <svg
      viewBox="0 0 24 24"
      width="1em"
      height="1em"
      fill="none"
      stroke="currentColor"
      strokeWidth={1.92}
      strokeLinecap="round"
      strokeLinejoin="round"
    >
      <path d="M5 12h14M12 5v14" />
    </svg>
  );
}

/** 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 IconButtonStaticExample(): JSX.Element {
  return (
    <Inline gap="2">
      <IconButtonStatic aria-label="Add product" icon={<Plus />} variant="primary" />
      <IconButtonStatic
        aria-label="Delete product"
        icon={<Trash />}
        variant="tertiary"
        render={<Link href="/products/42/delete" />}
      />
    </Inline>
  );
}

Props

IconButton

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

NameTypeDefaultDescription
iconrequiredReactNode–

The glyph, decorative (aria-hidden): 16 px at sm, 20 px at md, 24 px at lg.

labelrequiredstring–

The action, in words: the button's accessible name (aria-label) and the tooltip text. Required, because the glyph alone names nothing. Keep it constant for a toggle ("Bold", not "Bold on"); pressed carries the state. An aria-labelledby you pass names the button instead, and the tooltip still shows label.

pendingbooleanfalse

An action is in flight: the spinner replaces the glyph, and the button stays focusable with aria-disabled and swallows clicks. The spinner turns once per --nz-duration-spin and stops under Reduced motion; an icon-only button has no room for the "Loading…" text Button shows then, so show the progress in words next to it when the wait is long (for example a status line).

pendingIndicatorReactNode–

Replaces the internal spinner glyph.

pressedboolean–

Makes it a toggle button: aria-pressed is written as given, so false is an unpressed toggle. Leave it out for a plain action.

renderRenderProp<IconButtonState>–

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

shortcutstring–

A keyboard shortcut shown in the tooltip after the label, with Kbd ("mod+b" is ⌘B on Apple platforms, Ctrl+B elsewhere). The app implements the shortcut; the button announces it through aria-keyshortcuts ("Meta+B Control+B"), with or without the tooltip.

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

32 / 40 / 48 px square.

tooltipboolean | IconButtonTooltipOptionstrue

Pairs the button with a Tooltip that shows label ("Icon-only buttons have an aria-label and a Tooltip", Figma). The tooltip is a client leaf; false renders the button with no client code running. Pass options to place it. Where visible text already names the action and the page should ship no JavaScript for the button, use IconButtonStatic: under Turbopack and next dev this module's import of the leaf ships the tooltip chain even with tooltip={false}.

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

Figma "Variant".

  • Also takes every attribute of <button> except aria-label, aria-pressed, children. (Omit<ComponentPropsWithRef<"button">, "children" | "aria-label" | "aria-pressed">)

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

NameTypeDefaultDescription
disabledrequiredboolean–
pendingrequiredboolean–
pressedrequiredboolean | undefined–

true or false for a toggle (pressed given); undefined for a plain action.

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

IconButtonStatic

A server-safe IconButton with no tooltip: the same <button>, classes and CSS as <IconButton tooltip={false}>, from a module that imports no client code.

Use it only where visible text already names the action for sighted users (a column header, a labelled row) and the page should ship no JavaScript for the button: a table row action that is a link (render={<a href="/orders/42/edit" />}) under a header that says what it does, a submit button in a Server Action form (type="submit" inside <form action={deleteProduct}>) beside its label. Under Turbopack and next dev, importing IconButton ships its tooltip code even with tooltip={false} (about 20 KB gzip in production); IconButtonStatic ships none.

An icon-only toolbar needs IconButton's tooltip, so a sighted user can read what each glyph does (Figma: "Icon-only buttons have an aria-label and a Tooltip").

aria-label is always required. You may also pass aria-labelledby pointing at visible text; it then names the button, with aria-label as the fallback. There is no tooltip, no shortcut (pass aria-keyshortcuts yourself, for example "Meta+B Control+B") and no label.

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

NameTypeDefaultDescription
aria-labelrequiredstring–

The action, in words: the button's accessible name. Required, because the glyph alone names nothing.

iconrequiredReactNode–

The glyph, decorative (aria-hidden): 16 px at sm, 20 px at md, 24 px at lg.

From IconButtonProps.

labelnever–

IconButtonStatic has no tooltip and no label: name it with aria-label.

pendingbooleanfalse

An action is in flight: the spinner replaces the glyph, and the button stays focusable with aria-disabled and swallows clicks. The spinner turns once per --nz-duration-spin and stops under Reduced motion; an icon-only button has no room for the "Loading…" text Button shows then, so show the progress in words next to it when the wait is long (for example a status line).

From IconButtonProps.

pendingIndicatorReactNode–

Replaces the internal spinner glyph.

From IconButtonProps.

pressedboolean–

Makes it a toggle button: aria-pressed is written as given, so false is an unpressed toggle. Leave it out for a plain action.

From IconButtonProps.

renderRenderProp<IconButtonState>–

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

From IconButtonProps.

shortcutnever–

IconButtonStatic has no tooltip to show a shortcut in: pass aria-keyshortcuts ("Meta+B Control+B") to announce one, or use IconButton with shortcut.

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

32 / 40 / 48 px square.

From IconButtonProps.

tooltipnever–

IconButtonStatic has no tooltip: use IconButton for one.

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

Figma "Variant".

From IconButtonProps.

  • Also takes every attribute of <button> except aria-label, aria-pressed, children. (Omit<ComponentPropsWithRef<"button">, "children" | "aria-label" | "aria-pressed">)

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

NameTypeDefaultDescription
disabledrequiredboolean–
pendingrequiredboolean–
pressedrequiredboolean | undefined–

true or false for a toggle (pressed given); undefined for a plain action.

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

Other exports

IconButtonProps type

import type { IconButtonProps } from '@nizivo/react/icon-button'

The props of IconButton, listed above.

IconButtonState type

import type { IconButtonState } from '@nizivo/react/icon-button'

The render state of IconButton, listed above.

IconButtonStaticProps type

import type { IconButtonStaticProps } from '@nizivo/react/icon-button'

IconButtonStatic's props: IconButton's, without the tooltip. aria-label is always required; an aria-labelledby pointing at visible text may be added, and then names the button, with aria-label as the fallback.

The props of IconButtonStatic, listed above.

IconButtonTooltipOptions type

import type { IconButtonTooltipOptions } from '@nizivo/react/icon-button'

Where the tooltip goes and when it opens; tooltip={{ side: 'bottom' }}.

NameTypeDefaultDescription
alignAlign
"start""center""end"
'center'
delaynumber400

Hover delay in ms; keyboard focus opens it at once.

sideTooltipSide
"top""bottom""start""end"
'top'

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
icon-button
.nz-icon-button
IconButton
root (takes render); interactive, focus: self; forced colours: highlight on [aria-pressed='true']
  • data-disabled (present or absent) ← disabled
    Mirrors the disabled attribute
  • 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
icon-button-icon
.nz-icon-button-icon
IconButton
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-icon-button-bg
  • default: var(--nz-bg-surface)
  • default: var(--_nz-icon-button-bg-pressed)
  • default: var(--nz-bg-brand-subtle)
  • 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-icon-button
--nz-icon-button-bg-disabled--nz-bg-disabled
  • background on .nz-icon-button:is(:disabled, [data-disabled])
--nz-icon-button-bg-hover
  • default: var(--nz-bg-hover)
  • default: var(--_nz-icon-button-bg-pressed)
  • default: var(--nz-bg-brand-subtle)
  • 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-icon-button:hover:not(:disabled, [data-disabled], [aria-disabled="true"]) @media (hover: hover)
--nz-icon-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-icon-button:active:not(:disabled, [data-disabled], [aria-disabled="true"])
--nz-icon-button-border
  • default: var(--nz-border-default)
  • default: var(--nz-border-brand)
  • data-variant="accent": transparent
  • data-variant="critical": transparent
  • data-variant="primary": transparent
  • data-variant="tertiary": transparent
  • border on .nz-icon-button
--nz-icon-button-border-hover
  • default: var(--nz-border-strong)
  • default: var(--nz-border-brand)
  • data-variant="accent": transparent
  • data-variant="critical": transparent
  • data-variant="primary": transparent
  • data-variant="tertiary": transparent
  • border-color on .nz-icon-button:active:not(:disabled, [data-disabled], [aria-disabled="true"])
  • border-color on .nz-icon-button:hover:not(:disabled, [data-disabled], [aria-disabled="true"]) @media (hover: hover)
--nz-icon-button-fg
  • default: var(--nz-text-primary)
  • default: var(--nz-text-brand)
  • 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-icon-button
--nz-icon-button-fg-disabled--nz-text-disabled
  • color on .nz-icon-button:is(:disabled, [data-disabled])
--nz-icon-button-radius
  • default: var(--nz-radius-md)
  • data-size="sm": var(--nz-radius-sm)
  • border-radius on .nz-icon-button
--nz-icon-button-size
  • 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-icon-button
  • inline-size on .nz-icon-button

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

Tokens this stylesheet reads (38)

--nz-bg-accent, --nz-bg-accent-hover, --nz-bg-brand, --nz-bg-brand-hover, --nz-bg-brand-pressed, --nz-bg-brand-subtle, --nz-bg-critical, --nz-bg-critical-hover, --nz-bg-disabled, --nz-bg-hover, --nz-bg-pressed, --nz-bg-surface, --nz-border-brand, --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-focus-halo, --nz-radius-md, --nz-radius-sm, --nz-scale-press, --nz-size-control-lg, --nz-size-control-md, --nz-size-control-sm, --nz-size-icon-lg, --nz-size-icon-md, --nz-size-icon-sm, --nz-text-brand, --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:

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

In forced-colours mode these states keep a visible system colour: icon-button[aria-pressed='true'] (highlight).

Keyboard

KeysAction
TabMoves focus to the button and shows its tooltip at once. The tooltip repeats the name, so the button is not described by it.
Enter, SpaceActivates the button. A pending button stays focusable and swallows the activation.
EscapeHides the tooltip; focus stays on the button.

Source: a native <button> (icon-button.tsx); the tooltip is react-aria-components 1.22.1 TooltipTrigger through Nizivo's Tooltip (icon-button.client.tsx). The Next fixture and the browser lane (test/browser/icon-button.spec.tsx) check showing on focus, Escape and the missing aria-describedby.

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