interior[.]dev

Action Feedback·01.6

Icon Morph

Play/pause, menu/close as one mechanism.

icon-morph

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Usage

stats.tsx
"use client";

import { useState } from "react";
import { IconMorph } from "@/components/interior/icon-morph";

export function PlayerBar() {
  const [playing, setPlaying] = useState(false);
  const [open, setOpen] = useState(false);

  return (
    <div className="flex items-center gap-2">
      <IconMorph
        preset="play-pause"
        semantics="pressed"
        showLabel
        active={playing}
        onActiveChange={(i) => setPlaying(i === 1)}
      />

      <IconMorph
        preset="menu-close"
        semantics="expanded"
        active={open}
        onActiveChange={(i) => setOpen(i === 1)}
      />

      <IconMorph
        labels={["Expand", "Collapse"]}
        shapes={[
          { d: ["M 5 12 L 19 12", "M 12 5 L 12 19"], rotate: 0 },
          { d: ["M 5 12 L 19 12", "M 5 12 L 19 12"], rotate: 180 },
        ]}
      />
    </div>
  );
}

Source

components/interior/icon-morph.tsx
"use client";

import { useCallback, useMemo, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const;
const CROSSFADE = { type: "spring", stiffness: 260, damping: 34, mass: 0.8 } as const;
const INSTANT = { duration: 0 } as const;

const NUMBER = /-?\d*\.?\d+/g;
const CENTER = "12";

export type MorphShape = {
  d: readonly string[];
  rotate?: number;
};

export type IconMorphMode = "stroke" | "fill";

export type IconMorphPreset =
  | "menu-close"
  | "play-pause"
  | "plus-minus"
  | "check-close";

export type IconMorphSlot = {
  key: number;
  d: string;
  visible: boolean;
};

export type IconMorphSemantics = "label" | "pressed" | "expanded";

export const iconMorphPresets: Record<
  IconMorphPreset,
  { mode: IconMorphMode; labels: readonly string[]; shapes: readonly MorphShape[] }
> = {
  "menu-close": {
    mode: "stroke",
    labels: ["Menu", "Close"],
    shapes: [
      {
        rotate: 0,
        d: ["M 4 7 L 20 7", "M 4 12 L 20 12", "M 4 17 L 20 17"],
      },
      {
        rotate: 90,
        d: ["M 6.5 6.5 L 17.5 17.5", "M 12 12 L 12 12", "M 6.5 17.5 L 17.5 6.5"],
      },
    ],
  },
  "play-pause": {
    mode: "fill",
    labels: ["Play", "Pause"],
    shapes: [
      {
        d: [
          "M 8 5 L 14 8.5 L 14 15.5 L 8 19 Z",
          "M 14 8.5 L 20 12 L 20 12 L 14 15.5 Z",
        ],
      },
      {
        d: [
          "M 8 5 L 11.5 5 L 11.5 19 L 8 19 Z",
          "M 15 5 L 18.5 5 L 18.5 19 L 15 19 Z",
        ],
      },
    ],
  },
  "plus-minus": {
    mode: "stroke",
    labels: ["Add", "Remove"],
    shapes: [
      { rotate: 0, d: ["M 5 12 L 19 12", "M 12 5 L 12 19"] },
      { rotate: 180, d: ["M 5 12 L 19 12", "M 5 12 L 19 12"] },
    ],
  },
  "check-close": {
    mode: "stroke",
    labels: ["Confirm", "Cancel"],
    shapes: [
      { d: ["M 5 12.5 L 10 17.5 L 19.5 7", "M 12 12 L 12 12 L 12 12"] },
      { d: ["M 6.5 6.5 L 12 12 L 17.5 17.5", "M 17.5 6.5 L 12 12 L 6.5 17.5"] },
    ],
  },
};

function isCollapsed(d: string): boolean {
  const nums = d.match(NUMBER);
  if (!nums || nums.length < 4) return false;
  return nums.every((n, i) => n === nums[i % 2]);
}

function normalize(shapes: readonly MorphShape[]): IconMorphSlot[][] {
  const slots = shapes.reduce((most, s) => Math.max(most, s.d.length), 0);

  return shapes.map((shape) =>
    Array.from({ length: slots }, (_, i) => {
      const own = shape.d[i];
      const sibling = shapes.find((s) => s.d[i] !== undefined)?.d[i] ?? "";
      const d = own ?? sibling.replace(NUMBER, CENTER);
      return { key: i, d, visible: !isCollapsed(d) };
    }),
  );
}

function toIndex(value: number | boolean): number {
  return typeof value === "boolean" ? (value ? 1 : 0) : Math.trunc(value);
}

export type UseIconMorphOptions = {
  preset?: IconMorphPreset;
  shapes?: readonly MorphShape[];
  mode?: IconMorphMode;
  labels?: readonly string[];
  active?: number | boolean;
  defaultActive?: number | boolean;
  onActiveChange?: (index: number) => void;
};

export function useIconMorph({
  preset = "menu-close",
  shapes,
  mode,
  labels,
  active,
  defaultActive = 0,
  onActiveChange,
}: UseIconMorphOptions = {}) {
  const base = iconMorphPresets[preset];
  const source = shapes ?? base.shapes;
  const names = labels ?? base.labels;
  const count = source.length;

  const [internal, setInternal] = useState(() => toIndex(defaultActive));
  const reduced = useReducedMotion();

  const raw = active === undefined ? internal : toIndex(active);
  const index = count === 0 ? 0 : Math.min(Math.max(raw, 0), count - 1);

  const frames = useMemo(() => normalize(source), [source]);

  const setIndex = useCallback(
    (next: number) => {
      if (count === 0) return;
      const wrapped = ((next % count) + count) % count;
      if (active === undefined) setInternal(wrapped);
      onActiveChange?.(wrapped);
    },
    [active, count, onActiveChange],
  );

  const toggle = useCallback(() => setIndex(index + 1), [setIndex, index]);

  return {
    index,
    count,
    slots: frames[index] ?? [],
    rotate: source[index]?.rotate ?? 0,
    mode: mode ?? base.mode,
    label: names[index] ?? "",
    labels: names,
    transition: reduced ? INSTANT : CELL,
    labelTransition: reduced ? INSTANT : CROSSFADE,
    setIndex,
    toggle,
  };
}

export type IconMorphProps = UseIconMorphOptions & {
  size?: number;
  strokeWidth?: number;
  showLabel?: boolean;
  semantics?: IconMorphSemantics;
  disabled?: boolean;
  className?: string;
};

export function IconMorph({
  size = 20,
  strokeWidth = 1.75,
  showLabel = false,
  semantics = "label",
  disabled = false,
  className = "",
  ...options
}: IconMorphProps) {
  const {
    index,
    slots,
    rotate,
    mode,
    label,
    labels,
    transition,
    labelTransition,
    toggle,
  } = useIconMorph(options);

  const stroked = mode === "stroke";

  return (
    <motion.button
      type="button"
      disabled={disabled}
      onClick={toggle}
      aria-label={label}
      aria-pressed={semantics === "pressed" ? index === 1 : undefined}
      aria-expanded={semantics === "expanded" ? index === 1 : undefined}
      whileTap={disabled ? undefined : { y: 1 }}
      transition={transition}
      className={`inline-flex h-9 shrink-0 select-none items-center justify-center gap-2 rounded-[9px] border border-stone-200 bg-white text-[13px] font-medium text-stone-700 outline-none focus-visible:ring-2 focus-visible:ring-stone-400 disabled:opacity-50 dark:border-white/[0.16] dark:bg-[#1D1D1A] dark:text-stone-200 dark:focus-visible:ring-white/30 ${
        showLabel ? "px-3" : "w-9"
      } ${className}`}
      style={{ touchAction: "manipulation" }}
    >
      <motion.span
        aria-hidden="true"
        initial={false}
        animate={{ rotate }}
        transition={transition}
        className="grid shrink-0 place-items-center"
        style={{ width: size, height: size }}
      >
        <svg
          viewBox="0 0 24 24"
          width={size}
          height={size}
          focusable="false"
          fill={stroked ? "none" : "currentColor"}
          stroke={stroked ? "currentColor" : "none"}
          strokeWidth={stroked ? strokeWidth : undefined}
          strokeLinecap="round"
          strokeLinejoin="round"
          style={{ display: "block" }}
        >
          {slots.map((slot) => (
            <motion.path
              key={slot.key}
              initial={false}
              animate={{ d: slot.d, opacity: slot.visible ? 1 : 0 }}
              transition={transition}
            />
          ))}
        </svg>
      </motion.span>

      {showLabel && (
        <span aria-hidden="true" className="grid">
          {labels.map((text, i) => (
            <motion.span
              key={i}
              initial={false}
              animate={{
                opacity: i === index ? 1 : 0,
                y: i === index ? 0 : i < index ? -3 : 3,
              }}
              transition={labelTransition}
              className="col-start-1 row-start-1 whitespace-nowrap"
            >
              {text}
            </motion.span>
          ))}
        </span>
      )}
    </motion.button>
  );
}

Props

preset"menu-close"
"menu-close" | "play-pause" | "plus-minus" | "check-close"

Built-in shape pair. Each preset ships its paths on a shared command signature so the geometry interpolates.

shapes
readonly MorphShape[]

Your own states: `{ d: string[]; rotate?: number }` per state. Two or more; the button cycles through them.

modepreset's mode
"stroke" | "fill"

Whether the paths are stroked outlines or filled bodies. Play/pause is filled, the rest are stroked.

labelspreset's labels
readonly string[]

One per state. Becomes the accessible name, and the visible text when showLabel is set.

active
number | boolean

Controlled state index. A boolean maps to 0 and 1. Omit to let the component own its state.

defaultActive0
number | boolean

Starting state when uncontrolled.

onActiveChange
(index: number) => void

Fires with the next index on activation, in both controlled and uncontrolled mode.

semantics"label"
"label" | "pressed" | "expanded"

Which ARIA state the second index reports: none, aria-pressed, or aria-expanded.

showLabelfalse
boolean

Renders the labels beside the icon. All of them share one grid cell, so the button is as wide as the longest.

size20
number

Icon box in px. The 24-unit viewBox scales to it; the button stays 36px tall.

strokeWidth1.75
number

Stroke mode only.

disabledfalse
boolean

Blocks activation and the press displacement.

className""
string

Appended last, so any of the button chrome can be overridden.