Teseria
All elements

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

Terminal
# No packages beyond React.

2. Files

IconMorph.tsx78 lines
"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>
  );
}
IconMorph.css99 lines
.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);
}
tokens.cssshared · add once
/*
 * 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

Demo.tsx
import { IconMorph } from "./IconMorph";
import "./IconMorph.css";

export default function Demo() {
  return <IconMorph />;
}