IconButton
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.
Preview
icon-button · base base · style base-lyraOpen under the product themeInstallation
pnpm dlx shadcn@latest add @opsinjs/icon-button
The @opsinjs namespace is declared in your components.json. Everything it installs is code you then own. There is no runtime package to keep in step.
Usage
import { IconButton } from "@/components/ui/icon-button"import { Search } from "lucide-react"
<IconButton
icon={<Search />}
label="Search readings"
onClick={openSearch}
/>When to use it
Use it when
- 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.
- A repeated action in a list or table row, where a labelled button on every row would crowd the content out.
- A secondary control beside a labelled primary, where the reader has already taken in the row's context.
Do not use it when
- There is room for a word. A short verb beats a glyph almost everywhere.Use
buttoninstead. - The control turns something on or off, where a glyph reads as a state rather than an action.Use
switchinstead. - You were about to explain the icon through a hover tooltip. There is no hover on a phone.Use
tooltipinstead.
Anatomy
IconButtonThe root: a wrapper span carrying data-slot="icon-button" and no styling. className goes to the composed Button, not here.ButtonThe pressable element, a composed Button. Square at the 44pt floor, with fill, ink, boundary and focus ring inherited.Controlled byvariantIconButton.IconThe glyph and the only visible content. Always aria-hidden, inside Button's icon slot, with any child svg at 1em.Controlled byiconIconButton.NameThe accessible name, a visually hidden text node carrying label. The same string is also aria-label on the button.Controlled bylabel
Examples
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.
icon-button-toolbar · base base · style base-lyraOpen under the product themeIcon 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.
icon-button-list-row · base base · style base-lyraOpen under the product themeContent 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.
Do
label="Close the reading details" with a cross glyph. A verb and its
object reads well aloud.
Don’t
label="button", or no label at all. A screen reader announces "button"
and nothing else.
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
buttonelement through Button supplies Return, Space, the role and the tab stop. - The name is
aria-labelplus a visually hidden text node; a missing or whitespace-onlylabelraises 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.
| Key | Action | Notes |
|---|---|---|
| Tab | Moves focus to the icon button | One tab stop for the whole control. The glyph and name are parts, not stops. |
| Shift+Tab | Moves focus back out, to whatever precedes it | Reverse order matches the visual order. The component never moves focus of its own accord. |
| Enter | Activates the button | Platform behaviour on a real button element, inherited from Button. Nothing here intercepts it. |
| Space | Activates the button | Consumed by the focused button, so the page does not scroll. |
| Pair | Theme | APCA Lc | WCAG 2.2 | Floor |
|---|---|---|---|---|
| body text on the page | light | 101.6 | 17.18:1 | Pass |
| body text on the page | dark | -100.5 | 18.00:1 | Pass |
| body text on a card | light | 104.7 | 17.96:1 | Pass |
| body text on a card | dark | -99.6 | 16.32:1 | Pass |
| body text on the muted ground | light | 98.1 | 16.32:1 | Pass |
| body text on the muted ground | dark | -97.5 | 13.77:1 | Pass |
| secondary text on the page | light | 83.9 | 7.42:1 | Pass |
| secondary text on the page | dark | -80.7 | 13.44:1 | Pass |
| secondary text on a card | light | 87.0 | 7.76:1 | Pass |
| secondary text on a card | dark | -79.8 | 12.19:1 | Pass |
| secondary text on the muted ground | light | 80.4 | 7.05:1 | Pass |
| secondary text on the muted ground | dark | -77.8 | 10.28:1 | Pass |
| a hairline boundary on the page | light | 19.3 | 1.41:1 | Below floor |
| a hairline boundary on the page | dark | -8.2 | 1.90:1 | Below floor |
| a hairline boundary on a card | light | 22.4 | 1.47:1 | Below floor |
| a hairline boundary on a card | dark | -7.3 | 1.72:1 | Below floor |
| a hairline boundary on the muted ground | light | 15.8 | 1.34:1 | Below floor |
| a hairline boundary on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on the page | light | 69.8 | 4.52:1 | Pass |
| a placeholder boundary on the page | dark | -51.0 | 7.62:1 | Pass |
| a placeholder boundary on a card | light | 72.8 | 4.72:1 | Pass |
| a placeholder boundary on a card | dark | -50.1 | 6.91:1 | Pass |
| a placeholder boundary on the muted ground | light | 66.2 | 4.29:1 | Pass |
| a placeholder boundary on the muted ground | dark | -48.1 | 5.83:1 | Pass |
| a placeholder fill on the page | light | 8.3 | 1.18:1 | Below floor |
| a placeholder fill on the page | dark | -8.2 | 1.90:1 | Below floor |
| a placeholder fill on a card | light | 11.4 | 1.23:1 | Below floor |
| a placeholder fill on a card | dark | -7.3 | 1.72:1 | Below floor |
| a placeholder fill on the muted ground | light | 0.0 | 1.12:1 | Below floor |
| a placeholder fill on the muted ground | dark | 0.0 | 1.45:1 | Below floor |
| a placeholder boundary on its own fill | light | 58.9 | 3.84:1 | Pass |
| a placeholder boundary on its own fill | dark | -41.5 | 4.01:1 | Below floor |
| the card hairline on the page | light | 69.8 | 4.52:1 | Pass |
| the card hairline on the page | dark | -28.7 | 4.19:1 | Below floor |
| the card hairline on a card | light | 72.8 | 4.72:1 | Pass |
| the card hairline on a card | dark | -27.9 | 3.80:1 | Below floor |
These are the measured token pairs this component draws colour from, not a measurement of the component itself.
API reference
Prop
Type
Generated from IconButtonProps in registry/bases/base/icon-button.tsx.
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.