Icon Button
A square or round icon-only action button — a thin Button wrapper that requires an accessible label.
- Status
- Since
0.1.0- Accessibility pattern
- native button (aria-label required)
Last updated
Install
Add Icon Button from the VegaStack registry. The CLI verifies the item's integrity hash before writing it.
pnpm dlx shadcn@latest add @vegastack/icon-buttonThe same command installs the registry items it composes: @vegastack/button.
Usage
import { IconButton } from "@/components/ui/icon-button";
import { Plus } from "lucide-react";
<IconButton aria-label="Add item">
<Plus />
</IconButton>;IconButton is a thin wrapper over Button: it forces icon-only
geometry and requires aria-label, since the icon carries no visible text. It is the only
sanctioned icon-only path — Button has no icon size tier — so an unlabeled icon control cannot
compile.
Examples
Variants
Every Button shape passes straight through: solid, soft, outline,
ghost, link.
Tones
tone passes through too, so a status-coloured icon action needs no special variant. The one
forbidden cell is the same one Button forbids: tone="destructive" never takes variant="solid".
Shape
shape="round" is the sanctioned way to get a circular control — a rounded-full override on a
Button is not. Reserve it for avatar-adjacent and media-transport chrome; product chrome stays
square.
Sizes
xs (24), sm (28), md (32), lg (40) — the same --size-* vocabulary every other control
uses, pinned square.
States
Idle, disabled, and loading — both flags inherited from Button. disabled renders
aria-disabled and keeps the control focusable and hoverable so a Tooltip can explain why it is
unavailable; loading stacks a spinner over the icon, sets aria-busy, and holds the square open.
Playground
Try the pass-through variant × tone matrix, the size scale, shape, and the disabled / loading
states, then copy the generated JSX.
<IconButton aria-label="Add item">
<Plus />
</IconButton>API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
aria-label* | string | — | Accessible name announced to assistive tech (required — the icon has no visible text). |
children | React.ReactNode | — | The icon to render. Pass a single lucide-react (or @vegastack/design/icons)
element — it is sized automatically by the chosen size. Optional only so the control can be
composed through Base UI render, where the host supplies the children. |
className | string | ((state: ButtonState) => string | undefined) | — | Classes or a Base UI state resolver merged with the button variants. |
data-loading | string | — | Loading-state marker for wrapper components that reflect a host-owned pending
state onto a composed Button without its loading visuals (e.g. SplitButton's
chevron half). The Button's own loading prop always wins when set. |
data-slot | string | 'button' | Slot marker for wrapper components that compose Button through Base UI
render and need their own generated registry slot. |
loading | boolean | false | Shows a spinner over the label, disables interaction, and sets aria-busy. The
label keeps its box at opacity: 0, so the button's width does not move across
the flip and its accessible name survives. |
shape | IconButtonShape | 'square' | Outline shape. round is the sanctioned way to get a circular control — a rounded-full
override on a Button is not. |
size | IconButtonSize | 'md' | Square size, from the one --size-* vocabulary. |
variant and tone come from Button and are typed as a
pair, so the forbidden solid + destructive cell is a compile error here too.
Icon-only links
IconButton is an action. Navigation stays a real <a> — routing an anchor through it would put
role="button" on a link. Style the anchor instead, exactly as you would a text link that should
look like a button:
import { cn } from "@vegastack/design";
import { buttonVariants } from "@/components/ui/button";
import { iconButtonGeometry } from "@/components/ui/icon-button";
<a
href="/settings"
aria-label="Go back"
className={cn(
buttonVariants({ variant: "ghost", size: "sm" }),
iconButtonGeometry("sm"),
)}
>
<ChevronLeft aria-hidden />
</a>;Accessibility
- An accessible name is mandatory — pass
aria-label. The build fails to type-check without one, so an unlabeled icon button can never ship. - Renders a native
<button type="button">; keyboard Enter / Space activate it. :focus-visibleshows a 2px ring (outline-ring) — neveroutline: none.loadingsetsaria-busy="true"and disables interaction;disabledsetsaria-disabledand keeps the control focusable, so a Tooltip can say why.
| Key | Action |
|---|---|
| Enter | Activate the button |
| Space | Activate the button |
| Tab | Move focus to / from the button |
| Contract | States tested |
|---|---|
| Behaviour | default, disabled, loading |
| Accessibility | labeled |
| Visual | default, loading |