Skip to content
Component installs need the registry setup— the Base UI shadcn project, the @vegastack namespace and the Cloudflare Access service token.
VegaStack Design

Icon Button

A square or round icon-only action button — a thin Button wrapper that requires an accessible label.

Status
stable
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-button

The 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

PropTypeDefaultDescription
aria-label*stringAccessible name announced to assistive tech (required — the icon has no visible text).
childrenReact.ReactNodeThe 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.
classNamestring | ((state: ButtonState) => string | undefined)Classes or a Base UI state resolver merged with the button variants.
data-loadingstringLoading-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-slotstring'button'Slot marker for wrapper components that compose Button through Base UI render and need their own generated registry slot.
loadingbooleanfalseShows 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.
shapeIconButtonShape'square'Outline shape. round is the sanctioned way to get a circular control — a rounded-full override on a Button is not.
sizeIconButtonSize'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.

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-visible shows a 2px ring (outline-ring) — never outline: none.
  • loading sets aria-busy="true" and disables interaction; disabled sets aria-disabled and keeps the control focusable, so a Tooltip can say why.
KeyAction
EnterActivate the button
SpaceActivate the button
TabMove focus to / from the button
ContractStates tested
Behaviourdefault, disabled, loading
Accessibilitylabeled
Visualdefault, loading

Do / Don't

Do
Always give an aria-label that names the action — 'Delete row', not 'Trash'.
Don't
Render an IconButton with text children — use a Button with an icon and a label instead.

On this page