Skip to content

Icon action

An icon-only button with its tooltip, accessible name and pressed state built in.

Status
Beta
Category
Actions
Adoption
Not used yet
import { IconAction } from "@oration/canon/components/icon-action";
packages/canon/src/components/icon-action.tsx
import { Button } from "@oration/canon/components/button";import { IconAction } from "@oration/canon/components/icon-action";import { Kbd } from "@oration/canon/components/kbd";import { Textarea } from "@oration/canon/components/textarea";import { toast } from "@oration/canon/components/toast";import { BracesIcon, PaperclipIcon, SlashIcon } from "lucide-react";import * as React from "react";export function Hero() {    const id = React.useId();    const [draft, setDraft] = React.useState(        "Hi Aisha, the payment for INV-20407 went out this morning.",    );    const [files, setFiles] = React.useState<string[]>([]);    return (        <div className="flex w-full max-w-lg flex-col overflow-hidden rounded-xl bg-card text-left shadow-border">            <label htmlFor={id} className="sr-only">                Reply to Orchard Street            </label>            <Textarea                id={id}                value={draft}                onChange={(event) => setDraft(event.target.value)}                className="min-h-20 resize-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent"            />            {files.length ? (                <ul className="flex flex-wrap gap-1.5 px-3">                    {files.map((file) => (                        <li                            key={file}                            className="flex h-6 items-center rounded-md bg-muted px-2 font-mono text-xs"                        >                            {file}                        </li>                    ))}                </ul>            ) : null}            <div className="flex items-center gap-0.5 p-2">                <IconAction                    label="Attach a file"                    onClick={() =>                        setFiles((list) => [                            ...list,                            `remittance-PAY-8800${list.length}.csv`,                        ])                    }                >                    <PaperclipIcon aria-hidden="true" />                </IconAction>                <IconAction                    label="Insert a macro"                    shortcut={<Kbd>/</Kbd>}                    onClick={() => setDraft((text) => `${text}\n\n/`)}                >                    <SlashIcon aria-hidden="true" />                </IconAction>                <IconAction                    label="Insert a variable"                    onClick={() =>                        setDraft((text) => `${text} {{remittance_date}}`)                    }                >                    <BracesIcon aria-hidden="true" />                </IconAction>                <Button                    type="button"                    size="sm"                    className="ml-auto"                    onClick={() =>                        toast.add({                            type: "success",                            title: "Reply sent to Aisha Bello",                            description: files.length                                ? `${files.length} attachment${files.length === 1 ? "" : "s"} included.`                                : undefined,                        })                    }                >                    Send                </Button>            </div>        </div>    );}

Usage#

Icon action is an icon-only button with its tooltip, accessible name and pressed state built in: one label becomes both the aria-label and the tooltip. It is a 28px ghost button in Slate Meta by default, for composers, call bars, toolbars and row actions where space is tight. Pass active and it becomes a toggle. The common mistake is an icon nobody can guess, such as a custom glyph for Reconcile; when the action needs a word to be understood, use a button with a label.

When to use

  • For familiar actions in a composer or editor toolbar: attach a file, insert a macro, insert a variable.
  • For call controls that toggle, such as mute and hold, with active.
  • For row actions at the end of a table row or list item, at icon-xs.
  • For actions with a keyboard shortcut you want to teach, through shortcut.

When not to use

  • When the action needs a word to be understood, or is the main action of the view. Use Button
  • To open a menu or popover. Icon action doesn't forward the props a trigger needs; use a Button with a Tooltip. Use Dropdown menu
  • To copy a value. The ready-made version confirms with a check. Use Copy button
  • For a set of formatting toggles that act as one group. Use Toggle group

Icon-only buttons carry a name

label is required. It is announced as the button's name and shown in the tooltip, so sighted and screen reader users get the same words.

The One Filled Button Rule

Icon actions are never the filled button. Variants stop at ghost, outline and secondary, so a toolbar of them never competes with the view's primary action.

Anatomy#

ArchiveE
  1. Button. A square ghost Button, 28px with 8px corners by default. Here in its active state, with the Well Gray fill.
  2. Icon. 16px (12px at icon-xs), Slate Meta at rest, ink on hover and when active.
  3. Tooltip. Ink fill with 12px Plane text, 6px corners, showing label. Opens on hover and keyboard focus.
  4. Shortcut. Optional shortcut node, usually a Kbd, after the label.

Examples#

Sizes

24, 28, 32 and 36px squares. The default icon-sm (28px) suits toolbars and composers; icon-xs is for table rows.

icon-xs
icon-sm
icon
icon-lg
import { IconAction } from "@oration/canon/components/icon-action";import { toast } from "@oration/canon/components/toast";import { DownloadIcon } from "lucide-react";export function Sizes() {    const sizes = ["icon-xs", "icon-sm", "icon", "icon-lg"] as const;    return (        <div className="flex items-end gap-4">            {sizes.map((size) => (                <div key={size} className="flex flex-col items-center gap-2">                    <IconAction                        label="Download statement"                        size={size}                        variant="outline"                        onClick={() =>                            toast.add({ title: "Statement downloaded" })                        }                    >                        <DownloadIcon aria-hidden="true" />                    </IconAction>                    <span className="font-mono text-xs text-muted-foreground">                        {size}                    </span>                </div>            ))}        </div>    );}

Variants

Ghost by default, so a row of actions stays quiet. Outline and secondary when the action needs an edge, such as on an image or a busy surface. There's no filled variant.

ghost
outline
secondary
import { IconAction } from "@oration/canon/components/icon-action";import { toast } from "@oration/canon/components/toast";import { ClockIcon } from "lucide-react";export function Variants() {    const variants = ["ghost", "outline", "secondary"] as const;    return (        <div className="flex items-end gap-6">            {variants.map((variant) => (                <div key={variant} className="flex flex-col items-center gap-2">                    <IconAction                        label="Snooze until Monday"                        variant={variant}                        onClick={() =>                            toast.add({                                title: "Snoozed until Monday",                                description:                                    "INV-20411 comes back to your queue on Oct 5.",                            })                        }                    >                        <ClockIcon aria-hidden="true" />                    </IconAction>                    <span className="font-mono text-xs text-muted-foreground">                        {variant}                    </span>                </div>            ))}        </div>    );}

As a toggle

Pass active to make it a toggle: it sets aria-pressed and keeps the Well Gray fill. Swap the icon and the label with the state, as the call bar does.

Tomás Ferreira, Halcyon

On call, 04:12

import { Button } from "@oration/canon/components/button";import { IconAction } from "@oration/canon/components/icon-action";import { toast } from "@oration/canon/components/toast";import { MicIcon, MicOffIcon, PauseIcon, PhoneOffIcon, PlayIcon } from "lucide-react";import * as React from "react";export function Toggle() {    const [muted, setMuted] = React.useState(false);    const [held, setHeld] = React.useState(false);    return (        <div className="flex items-center gap-3 rounded-xl bg-card py-2 pr-2 pl-4 text-left shadow-border">            <div className="min-w-0">                <p className="text-13 font-medium">Tomás Ferreira, Halcyon</p>                <p className="text-xs text-muted-foreground tabular-nums">                    {held ? "On hold" : "On call"}, 04:12                </p>            </div>            <div className="ml-4 flex items-center gap-0.5">                <IconAction                    label={muted ? "Unmute" : "Mute"}                    active={muted}                    onClick={() => setMuted((m) => !m)}                >                    {muted ? (                        <MicOffIcon aria-hidden="true" />                    ) : (                        <MicIcon aria-hidden="true" />                    )}                </IconAction>                <IconAction                    label={held ? "Resume call" : "Hold call"}                    active={held}                    onClick={() => setHeld((h) => !h)}                >                    {held ? (                        <PlayIcon aria-hidden="true" />                    ) : (                        <PauseIcon aria-hidden="true" />                    )}                </IconAction>                <Button                    type="button"                    variant="destructive"                    size="sm"                    className="ml-1"                    onClick={() =>                        toast.add({                            title: "Call ended",                            description: "Logged to Halcyon's timeline.",                        })                    }                >                    <PhoneOffIcon data-icon="inline-start" aria-hidden="true" />                    End                </Button>            </div>        </div>    );}

With a shortcut

shortcut renders after the label in the tooltip. Wire the key yourself; the component only shows it.

import { IconAction } from "@oration/canon/components/icon-action";import { Kbd } from "@oration/canon/components/kbd";import { toast } from "@oration/canon/components/toast";import { cn } from "@oration/canon/lib/utils";import { ArchiveIcon, StarIcon, XIcon } from "lucide-react";import * as React from "react";export function WithShortcut() {    const [starred, setStarred] = React.useState(false);    return (        <div className="flex items-center gap-0.5">            <IconAction                label="Archive"                shortcut={<Kbd>E</Kbd>}                onClick={() => toast.add({ title: "INV-20411 archived" })}            >                <ArchiveIcon aria-hidden="true" />            </IconAction>            <IconAction                label={starred ? "Remove star" : "Star"}                shortcut={<Kbd>S</Kbd>}                active={starred}                onClick={() => setStarred((s) => !s)}            >                <StarIcon                    aria-hidden="true"                    className={cn(starred && "fill-current")}                />            </IconAction>            <IconAction                label="Close"                shortcut={<Kbd>Esc</Kbd>}                onClick={() => toast.add({ title: "Closed INV-20411" })}            >                <XIcon aria-hidden="true" />            </IconAction>        </div>    );}

Row actions

Extra-small actions at the end of a row, quiet until the row is hovered or focused. Each label names the row it acts on.

  • PAY-88004Northwind Freight$18,240.00
  • PAY-88003Halcyon$4,120.50
  • PAY-88002Orchard Street$960.00
import { IconAction } from "@oration/canon/components/icon-action";import { toast } from "@oration/canon/components/toast";import { DownloadIcon, ExternalLinkIcon } from "lucide-react";export function RowActions() {    const rows = [        {            id: "PAY-88004",            supplier: "Northwind Freight",            amount: "$18,240.00",        },        { id: "PAY-88003", supplier: "Halcyon", amount: "$4,120.50" },        { id: "PAY-88002", supplier: "Orchard Street", amount: "$960.00" },    ];    return (        <div className="w-full max-w-lg overflow-hidden rounded-xl bg-card text-left shadow-border">            <ul className="divide-y divide-border">                {rows.map((row) => (                    <li                        key={row.id}                        className="group/row flex h-10 items-center gap-3 pr-2 pl-3 text-13 hover:bg-muted/50"                    >                        <span className="w-20 font-mono text-xs text-muted-foreground">                            {row.id}                        </span>                        <span className="flex-1 truncate">{row.supplier}</span>                        <span className="font-medium tabular-nums">                            {row.amount}                        </span>                        <span className="flex items-center opacity-60 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100">                            <IconAction                                label={`Download remittance for ${row.supplier}`}                                size="icon-xs"                                onClick={() =>                                    toast.add({                                        title: `${row.id} remittance downloaded`,                                    })                                }                            >                                <DownloadIcon aria-hidden="true" />                            </IconAction>                            <IconAction                                label={`Open ${row.id}`}                                size="icon-xs"                                onClick={() =>                                    toast.add({ title: `Opening ${row.id}` })                                }                            >                                <ExternalLinkIcon aria-hidden="true" />                            </IconAction>                        </span>                    </li>                ))}            </ul>        </div>    );}

States#

Rest
Hover
Focus
Active
Disabled
import { IconAction } from "@oration/canon/components/icon-action";import { MicIcon } from "lucide-react";export function StatesMatrix() {    const states = [        { name: "Rest", className: "", active: false, disabled: false },        {            name: "Hover",            className: "bg-muted text-foreground",            active: false,            disabled: false,        },        {            name: "Focus",            className: "border-ring ring-3 ring-ring/40",            active: false,            disabled: false,        },        { name: "Active", className: "", active: true, disabled: false },        { name: "Disabled", className: "", active: false, disabled: true },    ];    return (        <div className="grid w-full grid-cols-5 gap-2" inert>            {states.map((state) => (                <div                    key={state.name}                    className="flex flex-col items-center gap-2"                >                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <IconAction                        label="Mute"                        active={state.active}                        disabled={state.disabled}                        className={state.className}                    >                        <MicIcon aria-hidden="true" />                    </IconAction>                </div>            ))}        </div>    );}
States
StateTreatment
RestTransparent with a Slate Meta icon. Outline adds the hairline and control shadow.
HoverWell Gray fill and an ink icon over 150ms; the tooltip opens.
Focus visibleIndigo border and a 3px Focus Indigo ring at 40%; the tooltip opens.
PressedScales to 0.96 while held, when motion is allowed.
ActiveWith active, the Well Gray fill and ink icon stay on and aria-pressed="true" is set.
Disabled50% opacity, no pointer events and no tooltip, so nothing explains why.

Behavior#

  • label sets the aria-label and the tooltip text. Change it with the state for toggles: Mute and Unmute.
  • active is aria-pressed. Leave it undefined for a plain action; pass true or false to make it a toggle.
  • onClick takes no arguments. Use a Button with a Tooltip if you need the event.
  • shortcut is display only. Bind the key yourself, and keep the key and the tooltip in sync.
  • It renders its own Tooltip, so don't wrap it in another one. It accepts no ref and doesn't spread other props, so it can't be the render target of a menu, popover or dialog trigger.

Do and don't#

Do. Use icon actions for actions people recognize from the icon alone: attach, download, close.
Don't. Hide uncommon actions behind abstract icons. Nobody guesses that braces mean Reconcile with bank feed.

Content#

  • Labels start with a verb and name the object when it isn't obvious: Attach a file, Download statement.
  • In repeated rows, name the row: Download remittance for Halcyon, so each button has a distinct name.
  • For toggles, the label says what pressing will do now: Mute when live, Unmute when muted.
  • Use the shortcut people actually press: E, /, Esc. No modifier glyphs you haven't bound.

Accessibility#

  • The name comes from label through aria-label. The tooltip repeats it for sighted users and opens on keyboard focus too.
  • Toggles expose aria-pressed, so screen readers announce pressed or not pressed.
  • Mark the icon aria-hidden="true"; the label names the action.
  • The shortcut is only in the tooltip, so screen reader users don't hear it. Mention it in help or onboarding as well.
  • icon-xs is 24px, the minimum target size. Use icon-sm or larger on touch layouts.
  • Row actions that fade until hover must stay visible on focus (group-focus-within), as in the example.
Keyboard interactions
KeysAction
TabMoves focus to the button and opens its tooltip.
EnterActivates the action or flips the toggle.
SpaceActivates the action or flips the toggle.
EscCloses the tooltip.

Design tokens#

Design tokens
TokenUsed for
--muted-foregroundIcon at rest
--mutedHover and active fill
--foregroundIcon on hover and active; tooltip fill
--backgroundTooltip text
--ringFocus border and 3px ring at 40%
--radius-md8px corners at icon-xs and icon-sm

API reference#

IconAction

A Button with aria-label, aria-pressed and a Tooltip. It doesn't spread other props.

Props of IconAction
PropTypeDefaultDescription
labelRequiredstringNo defaultThe accessible name and the tooltip text.
childrenRequiredReactNodeNo defaultThe icon, with aria-hidden.
onClick() => voidNo defaultCalled on activation.
activebooleanNo defaultSets aria-pressed and keeps the Well Gray fill. Undefined means not a toggle.
disabledbooleanNo defaultDims to 50% and blocks interaction and the tooltip.
size"icon-xs" | "icon-sm" | "icon" | "icon-lg""icon-sm"24, 28, 32 or 36px.
variant"ghost" | "outline" | "secondary""ghost"Button variant. No filled option.
shortcutReactNodeNo defaultShown after the label in the tooltip, usually a Kbd.
classNamestringNo defaultMerged onto the button.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

It accepts no ref and doesn't spread props, so it can't be a menu, popover or dialog trigger, and it can't take aria-expanded, aria-controls or type.

A disabled icon action shows no tooltip (no focusableWhenDisabled), so people can't learn what it is or why it's off.

The shortcut isn't exposed to assistive tech; there's no aria-keyshortcuts.

Only the contact center uses it (6 files), while about 59 product files compose Tooltip by hand, many of them around an icon Button.