Icon morphs
NewLine icons that act out what they mean: a house builds itself, an arrow drops into a tray, a screen turns into a phone, a battery charges.
icon-morphs/icon-morphs.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 showcase of motion: a landing section, a style guide or a portfolio piece that shows how icons can act out what they mean.
- Teaching what an action does before anyone takes it: the arrow lands in the tray, the battery fills.
- A gallery the reader can poke at: hover, focus or press a card and its pair plays once more.
- A still poster of the same drawings:
poseholds every icon on one frame, with no loop running.
When not to use
- Icons inside buttons, menus or toolbars. Use a static icon set; a 2 s animation there only slows the reader down.
- Progress or status that must be true: the battery here charges on a loop, not from a real level. Use a progress bar or a meter.
- Any icon outside these four pairs: the drawings are keyed by hand in
icons.ts. Draw a Lottie or an animated SVG for new ones. - Pages that already move a lot: four looping cards compete with any other animation for the eye.
You can add this component with the shadcn CLI or by hand:
Into components/icon-morphs of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/icon-morphs.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 { IconMorphs } from './icon-morphs';export function Showcase() { return <IconMorphs autoplay={false} />;}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.
Four grey cards, each with a pair of line icons that play a short, springy animation: one house goes up bar by bar while the other unfolds like a jointed ruler, a download arrow sinks into a tray that sags and springs back as its upload twin lifts off an arch, a screen tips over into a phone or narrows into one, and a battery fills through a cable or from a plug that knocks it sideways. They loop in the demo; hover, focus or press a card to play its pair again.
- Each icon is a pure function of time: given a frame, it returns the SVG paths to draw. One requestAnimationFrame loop asks every moving icon for its frame and writes the paths straight into a small pool of <path> elements, so React renders the cards once and the loop sleeps whenever nothing moves.
- The motion lives in keyed curves: values at given frames (sixtieths of a second), joined by a monotone cubic so a curve never overshoots its own keys. Wind-ups, squashes, overshoots and settles are all written into the keys.
- Everything is drawn on a 290-unit tile with lines 21 units thick, 7 px on the 97 px tile. Lines have round caps and joins; the trays and bars that change shape are filled outlines, their corners drawn as cubic quarter circles so they stay true under a rotation.
- Home (2.2 s): the left house grows from a dot into a bar, the bar rises into walls, the ridge lifts into a roof and the door bounces in, tall and narrow then short and wide. The right house is one jointed line: each wall, rafter, post and lintel swings on its own spring from the part it hangs on.
- Download · Upload (1.43 s): the arrow drops 77 units; the tray under it deepens and rounds off, then flings it 43 units back up while turning shallow and narrow, and both settle. Upload mirrors it: the base curls into an arch under the rising arrow, then drops flat and wide.
- PC · Phone (2.4 s): the left screen winds back 5°, turns a quarter, overshoots by 4° and settles as a phone, its stand climbing in to become the speaker; the right one narrows and grows tall instead. Halfway through, both play the same curves mirrored back to the screen.
- Battery (2.2 s): on the left a cable fades in from above and the charge rises with a sloshing surface, darkening as it fills, before the cable sinks away. On the right the charge slides in from the edge as a plug and knocks the battery 11 units sideways and 2° over, its cap swinging after it. Both drain from the top.
- Each pair keeps its own clock and rests on its finished pose: the house built, the arrows still, the screen, the battery charged. While on screen and untouched every pair loops; on any real input a moving pair finishes its loop and rests, and hovering, focusing or pressing a card plays it once more.
- Every colour is a light-dark() token on the root, so the cards follow the page’s color-scheme, or the colorScheme prop.
| Prop | Type | Default | Description |
|---|---|---|---|
autoplay | boolean | true | Loop every pair while the cards are on screen and untouched. Off, each pair rests and plays once when hovered, focused or pressed. |
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. |
pose | number | — | Holds every icon still at this point of its loop, from 0 (the finished, resting pose) to 1, for a poster or a screenshot. Turns the demo and the replays off. |
className | string | — | Extra classes for the root. |
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 } from 'react';import { LINE, PAIRS, type Icon, type Pair, type Shape } from './icons';import { useAutoplay } from './use-autoplay';import styles from './icon-morphs.module.css';/* * Icon morphs: four pairs of line icons on grey cards, each icon a small, playful animation — * a house that builds itself and one that unfolds, an arrow that drops into a tray and one that * springs off an arch, a screen that turns into a phone, a battery that charges and drains. * * - Every icon is a pure function of time (icons.ts): given a frame, it returns the SVG paths to * draw. One requestAnimationFrame loop asks each moving icon for its frame and writes the * paths straight into its <path> elements, so React renders the cards once and never again. * - Each pair keeps its own clock. While the demo is on screen and untouched, every pair loops. * Any real input hands the cards to the reader: a pair that is moving finishes its loop and * rests, and hovering, focusing or pressing a card plays its pair once more. * - At rest an icon shows its finished pose (the house built, the battery charged), which is also * what the server renders and what reduced motion keeps. * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's * color-scheme, or the one the colorScheme prop sets.// Copyright (c) 2026 Bruno Delorence. MIT License./* * The eight drawings of the icon morphs, and how each one moves. * * Every icon is a pure function of time: `draw(t)` returns the shapes to paint at frame `t` (the * clock counts in sixtieths of a second, so a 2.2 s loop is 132 frames). The shapes are plain * SVG path strings, stroked with the line weight or filled, so one generic painter can draw any * frame without React. * * Everything is drawn on a 290-unit tile with lines 21 units thick (7 px on the 97 px tile). * Motion comes from keyed curves: values at given frames, joined by a monotone cubic so a curve * never overshoots its own keys; anticipation, squash, overshoot and settle are all in the keys. *//** The line weight, in tile units. */export const LINE = 21;const HALF = LINE / 2;export type Shape = { /** 's' strokes the path with the line weight, 'f' fills it. */ kind: 's' | 'f'; d: string; opacity?: number;// Copyright (c) 2026 Bruno Delorence. MIT License.'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(/* Icon morphs (icon-morphs.tsx): four grey cards on a white stage, two white tiles on each with an animated line icon, and the pair's name under them. Two cards a row, or one on a narrow stage (a container query). Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). Memory. The drawings are SVG paths repainted in place by one animation loop, and only while a pair moves; each tile clips its own drawing, so a repaint stays inside its 97 px square. */.root { /* The stage, the cards, and the tiles on them. */ --surface: #fdfdfd; --card: #f2f2f2; --tile: #fefefe; /* The icons and the names. */ --ink: #2b2b2b; /* The stage's edge. */ --line: rgb(0 0 0 / 0.06); /* The same on both schemes: the focus ring. */ --focus: #387bff; container-type: inline-size; box-sizing: border-box;- The cards are a list of buttons named “Replay Home”, “Replay Download · Upload” and so on, each containing its visible name; Tab reaches them, and focusing or pressing one plays its pair.
- The drawings are decorative and hidden from assistive technology; the names say what each pair shows.
- Reduced motion shows every icon in its finished pose and never animates it. The loop pauses offscreen and in a hidden tab, and stops when the reader takes over.