IconButton
A square button that renders a single icon, with optional external label and notification badge.
pnpm add @tessinaui/uiWhen 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.
showBadgeandbadgeCountoverlay the library Badge on the corner. - A visible label beside the icon when the glyph alone is not enough. Pass
labelandlabelPositioninstead of switching components. - A name for every one.
aria-labelis 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 keepsaria-pressed; IconButton does not. - For navigation. A button does something, a link goes somewhere. Use
Link, or render into an
<a>withrender. - 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
pnpm add @tessinaui/uiUsage
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.
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:
| Prop | Type | Default | Description |
|---|---|---|---|
icon | ReactNode | — | The icon rendered inside the button |
aria-label | string | — | 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 |
label | string | — | Visible label text (used when labelPosition !== "none") |
showBadge | boolean | false | Show a notification badge on the top-trailing corner |
badgeCount | string | number | — | Badge content. Numbers get count semantics and cap at 99+; strings render verbatim. Omit it for a bare dot |
loading | boolean | false | Overlays a spinner, sets aria-busy + aria-disabled, swallows activation. The button keeps focus and its size; it is not natively disabled |
render | React.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.
| Key | Result |
|---|---|
| Tab | Moves focus to the button |
| Enter / Space | Activates it |
type="button"by default, so it never submits a surrounding form.loadingsetsaria-busyand 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". (Anaria-labelon the badge span would be ignored by assistive technology, so it does not carry one.) - A visible
labelis hidden from assistive technology, so the name still comes fromaria-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:
<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.