DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
ButtonIconButtonLinkLabelBadgeStatusChipAvatarShortcutSpinnerSkeletonProgressMeter
AI agentsContributing
  1. Components
  2. IconButton

IconButton

A square button that renders a single icon, with optional external label and notification badge.

$ pnpm add @tessinaui/ui

When to use

  • An icon that is unambiguous on its own: a close, a search magnifier, an overflow. If the glyph needs explaining, it is the wrong control.
  • Space is tight. A row of icon-only actions in a toolbar, a card corner or a table row, where text labels would crowd the layout.
  • A familiar glyph for a frequent action, one the app uses the same way everywhere, so people learn it once.
  • A notification count on an icon. showBadge and badgeCount overlay the library Badge on the corner.
  • A visible label beside the icon when the glyph alone is not enough. Pass label and labelPosition instead of switching components.
  • A name for every one. aria-label is required. Pair the button with a Tooltip so a sighted user hovering an unfamiliar glyph is not left guessing.

When not to use

  • When a word says it better. If the icon needs explaining or the action is important, use a Button with a label.
  • For a two-state control such as bold, mute or favourite. Use ToggleButton with size="icon", which keeps aria-pressed; IconButton does not.
  • For navigation. A button does something, a link goes somewhere. Use Link, or render into an <a> with render.
  • For a destructive action on its own. An icon alone is a quiet place for something irreversible; label it, and confirm before it runs.
  • For many actions in a row. Move them into a Toolbar or a DropdownMenu.
  • One icon for several meanings. Each glyph should mean one thing across the app.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

icon takes any React node, so pass the icon component your app already uses, or an inline SVG. The package exports no icon set, and the import below is one example.

example.tsx
import { IconButton } from "@tessinaui/ui";
import { Bell } from "lucide-react";

<IconButton icon={<Bell />} aria-label="Notifications" />

Examples

Default

A basic icon button with the default primary variant.

Variants

Four visual styles — primary, secondary, ghost, and outline.

Intents

Semantic colors for actions — error, warning, success, and info.

Sizes

Six square sizes, 2xs through xl (24 to 96px).

Badge

Overlay a notification badge, optionally with a count. This is the library Badge — showBadge alone renders a bare dot, and a badgeCount renders a count pill that caps at 99+. Pass a string (badgeCount="99+") to bypass the cap.

States

Default, disabled, and loading states.

Skeleton

RTL

dir is inherited from the page or a container, and the prop wins when set. Under RTL the badge moves to the top-left corner and an external label mirrors with the row: labelPosition="start" sits right of the button. A directional glyph is yours to flip with rtl:rotate-180.

API Reference

Props

The IconButton component extends native HTML button attributes (excluding children) and includes:

PropTypeDefaultDescription
iconReactNode—The icon rendered inside the button
aria-labelstring—Accessible name. Required — or pass aria-labelledby instead; the type accepts either
variant"primary" | "secondary" | "ghost" | "outline""primary"The visual style variant
intent"none" | "error" | "warning" | "success" | "info""none"The semantic intent color
size"2xs" | "xs" | "sm" | "md" | "lg" | "xl""md"Square size on the family scale: 24 / 32 / 40 / 48 / 56 / 96 px. 2xs–sm carry a 44 px tap extension on touch screens
rounded"none" | "sm" | "md" | "lg" | "xl" | "full""full"Corner radius; the badge follows it
tone"default" | "on-color"inherited from <Surface>High-contrast styling for a coloured or dark surface
labelPosition"none" | "top" | "bottom" | "start" | "end""none"Position of the external text label
labelstring—Visible label text (used when labelPosition !== "none")
showBadgebooleanfalseShow a notification badge on the top-trailing corner
badgeCountstring | number—Badge content. Numbers get count semantics and cap at 99+; strings render verbatim. Omit it for a bare dot
loadingbooleanfalseOverlays a spinner, sets aria-busy + aria-disabled, swallows activation. The button keeps focus and its size; it is not natively disabled
renderReact.ReactElement—Render into another element — a <button>-like host, or an <a> which keeps link semantics (see Button → As a link). The element's own ref, onClick and onKeyDown are merged, not replaced
tapEdge"none" | "inline-start" | "inline-end""none"Declares that the button sits flush against a layout edge, so its phone tap extension grows inward instead of across that edge. Logical, so it follows dir. Sizes md and up ignore it
dir"ltr" | "rtl"—Text direction. Unset, the button and its label inherit the page's

All standard HTML button attributes are also supported (onClick, disabled, type, etc.).

Variants

Variant Options

  • primary: Filled button for main actions
  • secondary: Gray button for alternative actions
  • ghost: Transparent button with no background
  • outline: Button with border, fills on hover and pressed states

Intent Options

Intent controls the semantic color of the button, independent of the variant style:

  • none: Default color scheme
  • error: Red color scheme for destructive or error actions
  • warning: Yellow color scheme with dark text for warning actions
  • success: Green color scheme for success or confirmation actions
  • info: Blue color scheme for informational actions

Size Options

Squares on the button family's heights, so a md IconButton lines up with a md Button:

  • 2xs: 24 x 24 px
  • xs: 32 x 32 px
  • sm: 40 x 40 px
  • md: 48 x 48 px (default)
  • lg: 56 x 56 px
  • xl: 96 x 96 px

2xs, xs and sm are under the 44px touch minimum, so on phones and tablets they carry an invisible tap-area extension that reaches it without changing how big the button looks. The extension follows the pointer, not the screen width: it is on for a touch screen at any width and for a viewport under 768px, and off for a mouse, where it would overlap neighbours in a tight toolbar.

Rounded

Every painted corner tracks the prop — including the notification badge, which used to stay a circle on a square button.

On a coloured surface

Accessibility

VoiceOver + Safari: passed 2026-09-14. An icon-only button reads its label and role, "Notifications, button" — never "button" alone. NVDA and JAWS are untested.

Follows the WAI-ARIA Button pattern.

The name is required. aria-label (or aria-labelledby) is not optional and not a lint rule — it is in the type. The icon is aria-hidden, so a button without one announces nothing at all.

KeyResult
TabMoves focus to the button
Enter / SpaceActivates it
  • type="button" by default, so it never submits a surrounding form.
  • loading sets aria-busy and keeps focus; the spinner is decorative and the square never changes size.
  • The badge is visual only — put the count in the button's name: aria-label="Notifications, 3 unread". (An aria-label on the badge span would be ignored by assistive technology, so it does not carry one.)
  • A visible label is hidden from assistive technology, so the name still comes from aria-label. Start that name with the label's words (label="Inbox", aria-label="Inbox, 3 unread") so a voice-control user can say what they see (WCAG 2.5.3).
  • Focus ring: 2px at 3:1, offset painted with the page background.

Tap targets at a layout edge

Sizes 2xs–sm are smaller than 44px, so they carry an invisible ::before that grows the touch target to 44px. It is symmetric by default — 6px on each side of an xs button.

That pseudo-element still counts toward its ancestors' scrollable overflow. So a button mounted flush against a full-bleed edge — a CardRow action, a list row's trailing control — pushes its extension past that edge and the page picks up a few pixels of horizontal scroll: invisible, but real to a thumb on a phone. CSS cannot ask "am I at the edge", so the placement has to say so:

example.tsx
<IconButton
  size="xs"
  icon={<ArrowDownToLine />}
  aria-label="Download offline map"
  tapEdge="inline-end"
/>

The target stays 44×44 — an xs button biased to inline-end takes all 12px on its inline-start side instead of 6px per side. Reach for it whenever a small square sits against a container whose padding is zero; leave it alone otherwise, because a centred extension is the better target when there is room on both sides.

ButtonLink

On this page

When to useWhen not to useInstallationUsageExamplesDefaultVariantsIntentsSizesBadgeStatesSkeletonRTLAPI ReferencePropsVariantsVariant OptionsIntent OptionsSize OptionsRoundedOn a coloured surfaceAccessibilityTap targets at a layout edge