Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Acting icons

New

A toolbar and its glyph set, where every icon acts out its meaning when hovered and when pressed.

acting-icons/acting-icons.tsx
Live · interactive
Toolbar16pt glyphs in 36pt inset caps · hover to preview · press to act
The setEach glyph acts out its meaning · spring damping 0.66 · 180–460 ms · interruptible

Tools 12

  • hover · leans on its tip

    press · the tip dips, a ring spreads out

  • hover · the T steps aside, a caret blinks

    press · stamps down

  • hover · a corner lifts

    press · its lines write themselves in

  • hover · a sun climbs over the ridge

    press · the frame breathes, sun flares

  • hover · the tip glides and leaves a stroke

    press · taps the page

  • hover · tiles switch

    press · tiles settle in

  • hover · stray tiles snap to the guide

    press · the guide pulses, tiles nudge in

  • hover · lens sweeps, a glint crosses

    press · sharpens

  • hover · plus spins 90°

    press · lens swells

  • hover · the minus draws in to a dash

    press · lens shrinks

  • hover · content grows out to the frame

    press · corners grip

The 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 Tidy or Fit explains 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:

Terminal
npx shadcn@latest add https://brunodelorence.com/r/acting-icons.json

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

canvas-tools.tsx
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.
PropTypeDefaultDescription
defaultTool'select' | 'text' | 'note' | 'image' | 'link' | 'draw''select'The tool each toolbar starts with.
defaultHoverGlyphName—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.
defaultDownGlyphName—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.
autoplaybooleantrueWhile 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.
classNamestring—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.

acting-icons.tsx
// 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.module.css
/* 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 {
glyphs.tsx
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;
spring.ts
/* * 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 -
demo-pointer.tsx
'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-autoplay.ts
'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.