---
title: "IconButton"
description: "A button whose only visible content is an icon, so it always requires an accessible name you supply. Prefer a labelled button wherever there is room for a word."
url: "https://opsinjs.pensievelabs.org/components/icon-button"
source: "https://opsinjs.pensievelabs.org/components/icon-button.md"
section: "Components"
status: "shipped"
kind: "component"
category: "actions-and-forms"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["glyph button", "round button"]
usedIn: ["diabetes-medicines-app"]
implemented: true
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> `<StubNotice>` IS THE EXCEPTION, AND IT IS THE ONE TO READ. It is a
> paired element rather than a self-closing one, and the text between
> its opening and closing tags is prose an author wrote, reproduced
> below word for word. That prose is where this page says whether the
> component has been reviewed. Read the children, not only the
> attributes.

<StubNotice
  name="icon-button"
  status="shipped"
  questions="[
  &#x22;No contrast pair has been measured for the glyph against any variant's fill or for the focus ring, and Button's own unmeasured pairs are inherited.&#x22;,
  &#x22;Nobody has listened to this control in a screen reader, so whether the name set twice reads as one name or two is unheard.&#x22;,
  &#x22;Forced colours and prefers-contrast are unhandled, inherited from Button, and a quiet icon button carrying only ink may not survive a palette that replaces author ink.&#x22;,
]"
>
  Audited against WCAG 2.2 AA. Clinical review pending.
</StubNotice>

## Preview [#preview]

<ComponentPreview name="icon-button" />

## Installation [#installation]

<ComponentInstall name="icon-button" unbuilt="false" importPath="@/components/ui/icon-button" dependencies="[&#x22;lucide-react&#x22;]" registryDependencies="[&#x22;button&#x22;]" />

## Usage [#usage]

```tsx
import { IconButton } from "@/components/ui/icon-button"
```

```tsx
import { Search } from "lucide-react"

<IconButton
  icon={<Search />}
  label="Search readings"
  onClick={openSearch}
/>
```

## When to use it [#when-to-use-it]

<WhenToUse
  use="[
  &#x22;A dense toolbar or card header where a universally understood glyph, such as a close cross, stands in for a word that would not fit.&#x22;,
  &#x22;A repeated action in a list or table row, where a labelled button on every row would crowd the content out.&#x22;,
  &#x22;A secondary control beside a labelled primary, where the reader has already taken in the row's context.&#x22;,
]"
  avoid="[
  { case: &#x22;There is room for a word. A short verb beats a glyph almost everywhere.&#x22;, instead: &#x22;button&#x22; },
  { case: &#x22;The control turns something on or off, where a glyph reads as a state rather than an action.&#x22;, instead: &#x22;switch&#x22; },
  { case: &#x22;You were about to explain the icon through a hover tooltip. There is no hover on a phone.&#x22;, instead: &#x22;tooltip&#x22; },
]"
/>

## Anatomy [#anatomy]

<Anatomy
  name="icon-button"
  parts="[
  {
    name: &#x22;IconButton&#x22;,
    describes: &#x22;The root: a wrapper span carrying data-slot=\&#x22;icon-button\&#x22; and no styling. className goes to the composed Button, not here.&#x22;,
  },
  {
    name: &#x22;Button&#x22;,
    describes: &#x22;The pressable element, a composed Button. Square at the 44pt floor, with fill, ink, boundary and focus ring inherited.&#x22;,
    prop: &#x22;variant&#x22;,
  },
  {
    name: &#x22;IconButton.Icon&#x22;,
    describes: &#x22;The glyph and the only visible content. Always aria-hidden, inside Button's icon slot, with any child svg at 1em.&#x22;,
    prop: &#x22;icon&#x22;,
  },
  {
    name: &#x22;IconButton.Name&#x22;,
    describes: &#x22;The accessible name, a visually hidden text node carrying label. The same string is also aria-label on the button.&#x22;,
    prop: &#x22;label&#x22;,
  },
]"
/>

## Examples [#examples]

### A toolbar of icon buttons [#a-toolbar-of-icon-buttons]

A header row where each glyph is one the reader already knows. Each control
still carries a distinct `label`, because a toolbar is where an unnamed icon
button disappears.

<ComponentPreview name="icon-button-toolbar" kind="example" align="start" />

### Icon buttons in a list row [#icon-buttons-in-a-list-row]

Repeated row actions, where a labelled Edit and Remove on every row would crowd
the reading out. Each `label` carries its row's context, so a voice-control user
reaches one control.

<ComponentPreview name="icon-button-list-row" kind="example" align="start" />

## Content guidelines [#content-guidelines]

The label is the whole message for a reader who does not see the glyph. Name
the action and its object, in sentence case, without terminal punctuation.
Choose a glyph the audience already knows, and let the glyph and the name
agree: a magnifier means search, a cross means close.

<DoDont>
  <DoDont.Do>
    **`label="Close the reading details"`** with a cross glyph. A verb and its
    object reads well aloud.
  </DoDont.Do>

  <DoDont.Dont>
    **`label="button"`, or no label at all.** A screen reader announces "button"
    and nothing else.
  </DoDont.Dont>
</DoDont>

## Accessibility [#accessibility]

**Checked by `pnpm run check:a11y` on every commit.** Every colour is a role
token, no type size is in `px`, and no banned word appears anywhere.

**Audited against WCAG 2.2 AA, source and rendered passes, author-run and not
independent.** Clinical review is pending. The audit found no critical, serious
or moderate defect in the source.

* A real `button` element through [Button](./button.mdx) supplies Return, Space,
  the role and the tab stop.
* The name is `aria-label` plus a visually hidden text node; a missing or
  whitespace-only `label` raises a development warning.
* The glyph is `aria-hidden`, never announced as a second, pictorial name.
* The target is a 44pt square in rem, with horizontal padding removed and
  separation left to the caller's `--opsin-target-separation`.

**Icon-only by design.** With no visible text, `aria-label` plus the hidden
text node is the correct and complete name route rather than a persistent
visible label. The docblock warns in development on a missing `label`.

**Not measured.** Contrast on every variant, the focus ring, and forced
colours, all inherited from Button.

<KeyboardTable
  name="icon-button"
  rows="[
  {
    keys: &#x22;Tab&#x22;,
    action: &#x22;Moves focus to the icon button&#x22;,
    notes: &#x22;One tab stop for the whole control. The glyph and name are parts, not stops.&#x22;,
  },
  {
    keys: &#x22;Shift + Tab&#x22;,
    action: &#x22;Moves focus back out, to whatever precedes it&#x22;,
    notes: &#x22;Reverse order matches the visual order. The component never moves focus of its own accord.&#x22;,
  },
  {
    keys: &#x22;Enter&#x22;,
    action: &#x22;Activates the button&#x22;,
    notes: &#x22;Platform behaviour on a real button element, inherited from Button. Nothing here intercepts it.&#x22;,
  },
  {
    keys: &#x22;Space&#x22;,
    action: &#x22;Activates the button&#x22;,
    notes: &#x22;Consumed by the focused button, so the page does not scroll.&#x22;,
  },
]"
/>

<ContrastReport component="icon-button" />

## API reference [#api-reference]

<PropsTable name="IconButtonProps" />

`icon` and `label` are both required. `variant` defaults to `secondary` over
`quiet`, since a bordered control is easier to recognise without a word inside
it. There is no `busy`, `busyLabel`, `fullWidth`, `iconPosition` or `type`.
`className` must never resolve a `--opsin-status-*` or `--opsin-category-*`
role; an IconButton carries no clinical axis.

## Related [#related]

* [Button](./button.mdx) is the labelled control this one defers to.
* [Switch](./switch.mdx) turns something on or off; an icon button acts and
  does not hold a setting.
* [Tooltip](./tooltip.mdx) supplements a name and is never the name itself.
