Acting icons
NewA toolbar and its glyph set, where every icon acts out its meaning when hovered and when pressed.
acting-icons/acting-icons.tsxThe live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A desktop-style tool palette where every glyph is clicked often and a little life on hover tells which one is about to act.
- Icon-only buttons whose meaning is worth previewing: a hover pose that acts out
TidyorFitexplains it before the press. - Modes against one-shot commands: tools stay lit while in use (
aria-pressed), commands only sink for the press. - Toolbars that have to read in both a light and a dark tone, with the same glyphs and motion in each.
When not to use
- Icons that sit beside a text label. The words already explain them; keep the glyph still.
- Dense lists or table rows with an icon per row: a dozen springs moving at once is noise. Use static icons.
- Touch-first screens: there is no hover, so the pose only flashes on a tap. Label the buttons instead.
- Destructive or rare commands. Put them in a menu with words, where a playful press can’t be misread.
- A toolbar with more than a dozen or so commands: group the rest in an overflow menu rather than adding caps.
You can add this component with the shadcn CLI or by hand:
Into components/acting-icons of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/acting-icons.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { ActingIcons, type Command, type Tool } from './acting-icons';export function CanvasTools({ onTool, onCommand,}: { onTool: (tool: Tool) => void; onCommand: (command: Command) => void;}) { return ( <ActingIcons autoplay={false} defaultTool="select" onToolChange={(tool) => onTool(tool)} onCommand={onCommand} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
A section of a desktop app’s design system: a toolbar of glyphs in inset caps, in a light and a dark tone, and the set of twelve tools below it. Each glyph acts out what it does: the pointer tilts onto its tip, the T steps aside for a blinking caret, the links pull apart, the tiles trade sides. Every move is on a spring with a damping of 0.66 and can be interrupted.
- Each glyph is drawn on a 24 grid with a 1.7 rounded stroke and split into parts, such as the arrow and its ring, the T and its caret, or the corners of a frame. Each part is a small SVG inside two HTML boxes, and only the boxes move, with transform, opacity and clip-path, so no SVG is restyled or laid out again while a glyph acts.
- A hover pose is a CSS transition between two transforms that each part keeps in custom properties. Its easing is a spring with a damping ratio of 0.66, sampled once into a CSS linear() curve that serves every duration from 180 to 460 ms; a hover that ends halfway springs back from wherever the glyph is.
- A press plays with the Web Animations API: a kick pulls the same spring towards a pose for a moment and lets it go, composited with “add” so a second press lands on top of the first. One-way effects, like the ring leaving the arrow’s point or the note’s lines writing themselves in, fly through a few poses with clip-path and opacity.
- Fit’s content grows to its frame without its stroke getting thicker: it is drawn in nine pieces, four corners that move out, four edges that move and stretch along their length, and a tint that scales underneath.
- Each cap’s key is a small layer of its own: hovered, it brightens under a light sheen; pressed, or while its tool is in use, a sunk face fades in over it, so only that key repaints. At rest nothing runs.
- The stage and the set are light-dark() tokens on the root, guarded in @supports, so they follow the page’s color-scheme, or the colorScheme prop; each toolbar declares its own tone’s colours, so the light toolbar stays light and the dark one dark, and at night the set’s caps take the dark toolbar’s look.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultTool | 'select' | 'text' | 'note' | 'image' | 'link' | 'draw' | 'select' | The tool each toolbar starts with. |
defaultHover | GlyphName | — | A glyph drawn hovered at first, in its hover pose in each toolbar and in the set, held until a pointer leaves its cap. Turns the demo off. |
defaultDown | GlyphName | — | A glyph whose cap is drawn pressed down at first, held until it is released; the press’s motion itself plays only on a real press. Turns the demo off. |
only | 'light' | 'dark' | 'set' | — | Draws only one part of the stage: the light toolbar, the dark one, or the set. Without it, all three. |
onToolChange | (tool: Tool, toolbar: 'light' | 'dark') => void | — | Called when the reader picks a tool, with the toolbar it was picked in. |
onCommand | (command: 'layout' | 'tidy' | 'zoom-out' | 'fit' | 'zoom-in') => void | — | Called when the reader presses a command. |
onSearch | (query: string) => void | — | Called with the query when the reader presses Enter in a search field. |
autoplay | boolean | true | While on screen and untouched, a ghost pointer tours both toolbars and the set, hovering and pressing caps. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the stage. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useId, useRef, useState, type CSSProperties, type FocusEvent, type KeyboardEvent, type PointerEvent,} from 'react';import { DemoPointer, runDemo, type Demo } from './demo-pointer';import { Glyph, pressGlyph, type GlyphName } from './glyphs';import { SPRING } from './spring';import { useAutoplay } from './use-autoplay';import styles from './acting-icons.module.css';/* * Acting icons: a desktop app's toolbar and the glyphs it is made of, each of which acts out its * meaning when hovered and when pressed. */* Acting icons (acting-icons.tsx): a page of a desktop app's design system, on a warm light grey stage, or a near-black one in the dark scheme. A toolbar of glyphs in inset caps, light and dark, then the set, each glyph in its cap with what it does when hovered and pressed. In a narrow stage the toolbars scroll sideways and the set falls into two columns, then one. Colours. Every colour is a token, light first as a plain value; those that follow the scheme come again as light-dark(light, dark) pairs inside @supports, which a build that rewrites light-dark() for older browsers leaves as written (rewritten, the pairs would need a color-scheme the same build had rewritten too, and on a page that sets none every colour would drop). The stage and the set follow the color-scheme in force: the page's, or the one the colorScheme prop sets (data-color-scheme); without light-dark() they stay light. Each toolbar keeps its tone whatever the scheme, with colours of its own; in the dark scheme the set's caps take the dark toolbar's. Where a dark cap's shadow has another shape, not only other colours, its pair lists the layers of both, each transparent in the other scheme. Memory. A phone keeps a page's pixels in tiles the width of the screen, and a tile painted again costs a second buffer. So each cap's key is a small layer of its own (will-change), and what changes while a glyph acts stays inside it: the face fading to its pressed look, the parts moving. Nothing that changes while the demo plays needs a layout: the parts move by transform, opacity and clip-path on HTML boxes around SVG drawings that never change, and the pointer is a box that moves on its own layer. The stage does not clip its content: a rounded clip around so many layers costs a phone more memory. */.root {import type { CSSProperties, ReactNode } from 'react';import { EASE_OUT, KICK } from './spring';import styles from './acting-icons.module.css';/* * The twelve glyphs and how each acts out its meaning. Every glyph is drawn on a 24 grid with a * 1.7 stroke, rounded, and split into parts: the arrow and the ring it sends out, the T and its * caret, the four corners of a frame. Each part is its own small SVG in two HTML boxes, one in the * other: the outer box takes the hover pose, the inner one plays the press. The drawings never * change; only the boxes move, with transforms, opacity and clip-path, so a browser repaints a few * small boxes at most and never lays an SVG out again while a glyph acts. * * - A hover pose is a CSS transition between two transforms the part keeps in custom properties * (--rest, --hover), on the spring of spring.ts. The cap around the glyph switches it. * - A press is played by `pressGlyph`, with the Web Animations API: a kick, the spring pulled * towards a pose and let go, which adds itself to whatever moves already; or a path through a * few poses (a ring that leaves, lines written in), with an ease-out. *//** A pose, on the 24 grid: moves in grid units, a turn in degrees, scales, opacity, a reveal. */type Pose = { x?: number; y?: number; r?: number;/* * The springs every glyph moves on. They share one damping ratio, 0.66: a move overshoots its * target by about 6% and settles, whatever its length. With the damping fixed, a spring's curve * is the same at every speed once its time is counted up to the moment it settles, so one curve, * sampled once, serves every duration from 180 to 460 ms: * * - SPRING, a CSS `linear()` easing, carries a glyph to its hover pose and back. A CSS transition * starts again from wherever the glyph is when the pointer leaves halfway, so every move can be * interrupted. * - KICK is a press: the spring is pulled towards a pose for a moment and let go, so it dips, * comes back past its rest a little and settles. Presses play with the Web Animations API and * add themselves to whatever already moves, so a second press lands on top of the first. *//** The damping ratio of every spring: 1 would settle without overshooting. */const DAMPING = 0.66;const WOBBLE = Math.sqrt(1 - DAMPING * DAMPING);/** A spring let go at 0 towards 1, after `t` (in units of 1 / its natural frequency). */const step = (t: number) => t <= 0 ? 0 : 1 -'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, * glide the pointer onto a cap, press it. Stopping the demo (the reader takes over, or it scrolls * away) ends the step in progress at once: its promise rejects, which ends the script right there, * `stopped` lets go of what the script held, and the pointer fades out. * * Nothing the pointer does needs a layout: it glides by an animation the compositor plays, the * press scales a box that keeps a transform at rest (a box that gains or loses one is laid out * again), and the arrow and the hand are HTML boxes that swap by their visibility (a change of * style inside an SVG lays the SVG out again). */export type Point = { x: number; y: number };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */ moveTo: (target: Element, at?: Point) => Promise<void>; /** Presses and lets go; `action` runs as the pointer goes down. */'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- Each toolbar is a role="toolbar" with a single tab stop; the arrow keys, Home and End move along its caps. The six tools are toggle buttons (aria-pressed), and a tool the reader picks is announced in a polite live region.
- Enter and Space press a cap and play its press, as a click does. Keyboard focus shows the hover pose with a visible outline, and ⌘K or Ctrl+K inside the stage focuses the search field of the toolbar in use.
- The set is a list of buttons, each described by its hover and press lines; the arrow keys move through the grid by rows and columns.
- Under reduced motion poses appear at once, presses don’t play and the demo never runs. Glyphs and the demo pointer are aria-hidden.