Icon action
An icon-only button with its tooltip, accessible name and pressed state built in.
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
Anatomy#
- Button. A square ghost Button, 28px with 8px corners by default. Here in its active state, with the Well Gray fill.
- Icon. 16px (12px at
icon-xs), Slate Meta at rest, ink on hover and when active. - Tooltip. Ink fill with 12px Plane text, 6px corners, showing
label. Opens on hover and keyboard focus. - Shortcut. Optional
shortcutnode, usually aKbd, 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.
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.
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#
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> );}| State | Treatment |
|---|---|
| Rest | Transparent with a Slate Meta icon. Outline adds the hairline and control shadow. |
| Hover | Well Gray fill and an ink icon over 150ms; the tooltip opens. |
| Focus visible | Indigo border and a 3px Focus Indigo ring at 40%; the tooltip opens. |
| Pressed | Scales to 0.96 while held, when motion is allowed. |
| Active | With active, the Well Gray fill and ink icon stay on and aria-pressed="true" is set. |
| Disabled | 50% opacity, no pointer events and no tooltip, so nothing explains why. |
Behavior#
labelsets thearia-labeland the tooltip text. Change it with the state for toggles: Mute and Unmute.activeisaria-pressed. Leave it undefined for a plain action; passtrueorfalseto make it a toggle.onClicktakes no arguments. Use a Button with a Tooltip if you need the event.shortcutis 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 norefand doesn't spread other props, so it can't be therendertarget of a menu, popover or dialog trigger.
Do and don't#
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
labelthrougharia-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-xsis 24px, the minimum target size. Useicon-smor larger on touch layouts.- Row actions that fade until hover must stay visible on focus (
group-focus-within), as in the example.
| Keys | Action |
|---|---|
| Tab | Moves focus to the button and opens its tooltip. |
| Enter | Activates the action or flips the toggle. |
| Space | Activates the action or flips the toggle. |
| Esc | Closes the tooltip. |
Design tokens#
| Token | Used for |
|---|---|
--muted-foreground | Icon at rest |
--muted | Hover and active fill |
--foreground | Icon on hover and active; tooltip fill |
--background | Tooltip text |
--ring | Focus border and 3px ring at 40% |
--radius-md | 8px 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.
| Prop | Type | Default | Description |
|---|---|---|---|
labelRequired | string | No default | The accessible name and the tooltip text. |
childrenRequired | ReactNode | No default | The icon, with aria-hidden. |
onClick | () => void | No default | Called on activation. |
active | boolean | No default | Sets aria-pressed and keeps the Well Gray fill. Undefined means not a toggle. |
disabled | boolean | No default | Dims 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. |
shortcut | ReactNode | No default | Shown after the label in the tooltip, usually a Kbd. |
className | string | No default | Merged 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.