Icon morph
A row of round buttons whose icons are one SVG with two states. Every line animates to its new place: menu and plus fold into a close X, the chevron flips, the search handle grows into the X while the lens shrinks away.
Code
1. Install
# No packages beyond React.
2. Files
"use client";
// IconMorph: a row of round buttons whose icons are one SVG with two states. Every line animates to its new place:
// menu and plus fold into a close X, the chevron flips, the search handle grows into the X while the lens shrinks away.
import { useState, type CSSProperties } from "react";
export type IconMorphCue = "unfold" | "fold";
export type IconMorphKind = "menu" | "plus" | "chevron" | "search";
export type IconMorphProps = {
/** Which icons to show, in order. */
kinds?: IconMorphKind[];
/** Controlled state: true shows the morphed (open) shape. */
on?: boolean;
/** Uncontrolled initial state. */
defaultOn?: boolean;
/** Called with the new state when any button is pressed. */
onChange?: (on: boolean) => void;
/** Sound hook: "unfold" when an icon morphs open, "fold" when it folds back. */
onSound?: (cue: IconMorphCue) => void;
/** Icon size in px. */
size?: number;
/** Icon stroke width. */
strokeWidth?: number;
className?: string;
style?: CSSProperties;
};
function Icon({ kind, on, size, strokeWidth }: { kind: IconMorphKind; on: boolean; size: number; strokeWidth: number }) {
return (
<svg className="tim-svg" data-kind={kind} data-on={on || undefined} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{kind === "menu" && (
<>
<line className="tim-a" x1="4" y1="7" x2="20" y2="7" />
<line className="tim-b" x1="4" y1="12" x2="20" y2="12" />
<line className="tim-c" x1="4" y1="17" x2="20" y2="17" />
</>
)}
{kind === "plus" && (
<g className="tim-g">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</g>
)}
{kind === "chevron" && (
<g className="tim-g">
<path d="M6 9 L12 15 L18 9" />
</g>
)}
{kind === "search" && (
<>
<circle className="tim-o" cx="10.5" cy="10.5" r="6.5" />
<line className="tim-h" x1="15.5" y1="15.5" x2="20" y2="20" />
<line className="tim-x" x1="6" y1="18" x2="18" y2="6" />
</>
)}
</svg>
);
}
export function IconMorph({ kinds = ["menu", "plus", "chevron", "search"], on: ctrl, defaultOn = false, onChange, onSound, size = 20, strokeWidth = 2, className, style }: IconMorphProps) {
const [inner, setInner] = useState(defaultOn);
const on = ctrl ?? inner;
const toggle = () => {
onSound?.(on ? "fold" : "unfold");
if (ctrl === undefined) setInner(!on);
onChange?.(!on);
};
return (
<div className={["tim-row", className].filter(Boolean).join(" ")} style={style}>
{kinds.map((k) => (
<button key={k} type="button" className="tim-btn" aria-label={k} aria-pressed={on} onClick={() => toggle()}>
<Icon kind={k} on={on} size={size} strokeWidth={strokeWidth} />
</button>
))}
</div>
);
}
.tim-row,
.tim-row * {
box-sizing: border-box;
}
:where(.tim-row) button {
font: inherit;
color: inherit;
background: none;
border: 0;
padding: 0;
margin: 0;
}
.tim-svg line,
.tim-svg path,
.tim-svg circle,
.tim-svg g {
transform-origin: 12px 12px;
transform-box: view-box;
transition: transform 0.42s cubic-bezier(.24, 1.34, .38, 1), opacity 0.16s cubic-bezier(.22, 1, .36, 1), stroke-dashoffset 0.42s cubic-bezier(.22, 1, .36, 1);
}
/* menu -> X */
.tim-svg[data-kind="menu"][data-on] .tim-a {
transform: rotate(45deg) translateY(5px) scaleX(1.06);
}
.tim-svg[data-kind="menu"][data-on] .tim-b {
opacity: 0;
transform: scaleX(0.2);
}
.tim-svg[data-kind="menu"][data-on] .tim-c {
transform: rotate(-45deg) translateY(-5px) scaleX(1.06);
}
/* plus -> X */
.tim-svg[data-kind="plus"][data-on] .tim-g {
transform: rotate(135deg) scale(1.21);
}
/* chevron flips */
.tim-svg[data-kind="chevron"][data-on] .tim-g {
transform: rotate(180deg);
}
/* search -> X: handle grows into one stroke, circle shrinks away, hidden second stroke appears */
.tim-svg[data-kind="search"] .tim-x {
stroke-dasharray: 17;
stroke-dashoffset: 17;
opacity: 0;
}
.tim-svg[data-kind="search"][data-on] .tim-o {
transform: scale(0);
opacity: 0;
}
.tim-svg[data-kind="search"] .tim-h {
transform-box: fill-box;
transform-origin: center;
vector-effect: non-scaling-stroke;
}
.tim-svg[data-kind="search"][data-on] .tim-h {
transform: translate(-5.75px, -5.75px) scale(2.67);
}
.tim-svg[data-kind="search"][data-on] .tim-x {
stroke-dashoffset: 0;
opacity: 1;
transition-delay: 0.1s;
}
/* the round line buttons the icons sit in */
.tim-row {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 14px;
font-family: var(--ts-font, system-ui, sans-serif);
}
.tim-btn {
height: 40px;
width: 44px;
padding: 0;
border: 0;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
-webkit-tap-highlight-color: transparent;
background: var(--ts-raised);
color: var(--ts-fg);
box-shadow: var(--ts-edge);
transition: scale 0.22s cubic-bezier(.22, 1, .36, 1), background 0.16s cubic-bezier(.22, 1, .36, 1);
}
.tim-btn:hover {
background: color-mix(in srgb, var(--ts-fg) 6%, var(--ts-raised));
}
.tim-btn:active {
scale: 0.96;
}
.tim-btn:focus-visible {
outline: 0;
box-shadow: 0 0 0 2px var(--ts-raised), 0 0 0 4px var(--ts-accent);
}
/*
* Teseria tokens — the only colours, fonts and corner radii a block reads.
*
* Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
* the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
* any ancestor, or the OS setting unless [data-theme="light"] is set.
*
* Surfaces page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
* Text fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
* Lines border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
* dark; composes with other shadows)
* Signals accent · focus · success · warning · danger
* Corners controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
* (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
*/
:root {
--ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
--ts-r-panel: 24px;
--ts-r-pop: 18px;
--ts-r-inner: 12px;
--ts-page: #ffffff;
--ts-raised: #ffffff;
--ts-sunken: #f3f2f0;
--ts-tint-1: #f5f4f2;
--ts-tint-2: #ecebe8;
--ts-tint-3: #e3e1dd;
--ts-fg: #161514;
--ts-fg-rgb: 22, 21, 20;
--ts-fg-secondary: #3a3836;
--ts-fg-tertiary: #585552;
--ts-fg-muted: #6d6a66;
--ts-fg-faint: #89857f;
--ts-fg-inverse: #ffffff;
--ts-border: rgba(22, 21, 20, 0.1);
--ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);
--ts-accent: #3446eb;
--ts-focus: #3446eb;
--ts-success: #13804a;
--ts-warning: #a9650b;
--ts-danger: #df474e;
}
:root[data-theme="dark"] {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--ts-page: #0b0a09;
--ts-raised: #24221f;
--ts-sunken: #0d0c0b;
--ts-tint-1: #131211;
--ts-tint-2: #1b1a18;
--ts-tint-3: #2b2926;
--ts-fg: #f6f5f3;
--ts-fg-rgb: 246, 245, 243;
--ts-fg-secondary: #bab8b4;
--ts-fg-tertiary: #9e9b97;
--ts-fg-muted: #8e8b86;
--ts-fg-faint: #72706c;
--ts-fg-inverse: #0b0a09;
--ts-border: rgba(246, 245, 243, 0.1);
--ts-edge: 0 0 #0000;
--ts-accent: #3446eb;
--ts-focus: #7f8cff;
--ts-success: #3ccf80;
--ts-warning: #efa130;
--ts-danger: #df474e;
}
}
3. Usage
import { IconMorph } from "./IconMorph";
import "./IconMorph.css";
export default function Demo() {
return <IconMorph />;
}