Agents (llms.txt)

Morph Icon

One icon, many shapes. Every glyph is exactly three SVG lines, so any icon can fluidly morph into any other.

'use client';

import { useState } from 'react';
import {
  type IconName,
  iconNames,
  MorphIcon,
} from '@/foundations/icons/morph-icon';
import { cn } from '@/lib/utils/classnames';

export default function MorphIconPreview() {
  const [icon, setIcon] = useState<IconName>('menu');

  return (
    <div className="flex flex-col items-center gap-6">
      {/* Selected icon — big circular accent tile */}
      <div className="grid size-24 place-items-center rounded-full bg-accent text-accent-foreground">
        <MorphIcon icon={icon} size={36} strokeWidth={1.75} />
      </div>

      {/* Picker — bordered tiles, one per icon */}
      <div className="grid max-w-md grid-cols-8 gap-1.5">
        {iconNames.map((name) => {
          const active = icon === name;
          return (
            <button
              key={name}
              type="button"
              aria-label={name}
              aria-pressed={active}
              onClick={() => setIcon(name)}
              className={cn(
                'grid size-9 cursor-pointer place-items-center rounded-lg border outline-none',
                'transition-colors duration-(--duration-hover) ease-out',
                'focus-visible:ring-(length:--ring-width) ring-ring',
                active
                  ? 'border-accent bg-surface-overlay text-foreground ring-2 ring-accent'
                  : 'border-border bg-background text-foreground-secondary hover:bg-surface-overlay hover:text-foreground'
              )}
            >
              <MorphIcon icon={name} size={16} />
            </button>
          );
        })}
      </div>
    </div>
  );
}

Dependencies

Source Code

'use client';

import {
  motion,
  type Transition,
  useReducedMotion,
  useSpring,
} from 'motion/react';
import { useEffect, useMemo, useRef } from 'react';
import { cn } from '@/lib/utils/classnames';

interface IconLine {
  x1: number;
  y1: number;
  x2: number;
  y2: number;
  opacity?: number;
}

interface IconDefinition {
  lines: [IconLine, IconLine, IconLine];
  rotation?: number;
  /** Icons sharing a group rotate between members instead of morphing endpoints. */
  group?: string;
}

type IconName =
  | 'menu'
  | 'cross'
  | 'plus'
  | 'minus'
  | 'equals'
  | 'asterisk'
  | 'more'
  | 'check'
  | 'play'
  | 'pause'
  | 'download'
  | 'upload'
  | 'external'
  | 'arrow-right'
  | 'arrow-down'
  | 'arrow-left'
  | 'arrow-up'
  | 'chevron-right'
  | 'chevron-down'
  | 'chevron-left'
  | 'chevron-up'
  | 'grip'
  | 'slash'
  | 'corner';

const CENTER = 7;
const VIEWBOX_SIZE = 14;

const collapsed: IconLine = {
  x1: CENTER,
  y1: CENTER,
  x2: CENTER,
  y2: CENTER,
  opacity: 0,
};

// §10 one-off: expo-out stroke morph — not in motion.css (no shared token).
const defaultTransition: Transition = {
  ease: [0.19, 1, 0.22, 1],
  duration: 0.4,
};

const arrowLines: [IconLine, IconLine, IconLine] = [
  { x1: 2, y1: 7, x2: 12, y2: 7 },
  { x1: 7.5, y1: 2.5, x2: 12, y2: 7 },
  { x1: 7.5, y1: 11.5, x2: 12, y2: 7 },
];

const chevronLines: [IconLine, IconLine, IconLine] = [
  { x1: 5, y1: 2.5, x2: 9.5, y2: 7 },
  { x1: 5, y1: 11.5, x2: 9.5, y2: 7 },
  collapsed,
];

const plusLines: [IconLine, IconLine, IconLine] = [
  { x1: 7, y1: 2, x2: 7, y2: 12 },
  { x1: 2, y1: 7, x2: 12, y2: 7 },
  collapsed,
];

const icons: Record<IconName, IconDefinition> = {
  menu: {
    lines: [
      { x1: 2, y1: 3.5, x2: 12, y2: 3.5 },
      { x1: 2, y1: 7, x2: 12, y2: 7 },
      { x1: 2, y1: 10.5, x2: 12, y2: 10.5 },
    ],
  },
  cross: { lines: plusLines, rotation: 45, group: 'plus-cross' },
  plus: { lines: plusLines, rotation: 0, group: 'plus-cross' },
  minus: {
    lines: [{ x1: 2, y1: 7, x2: 12, y2: 7 }, collapsed, collapsed],
  },
  equals: {
    lines: [
      { x1: 2, y1: 5, x2: 12, y2: 5 },
      { x1: 2, y1: 9, x2: 12, y2: 9 },
      collapsed,
    ],
  },
  asterisk: {
    lines: [
      { x1: 7, y1: 2, x2: 7, y2: 12 },
      { x1: 2.67, y1: 4.5, x2: 11.33, y2: 9.5 },
      { x1: 11.33, y1: 4.5, x2: 2.67, y2: 9.5 },
    ],
  },
  more: {
    lines: [
      { x1: 3, y1: 7, x2: 3, y2: 7 },
      { x1: 7, y1: 7, x2: 7, y2: 7 },
      { x1: 11, y1: 7, x2: 11, y2: 7 },
    ],
  },
  check: {
    lines: [
      { x1: 2, y1: 7.5, x2: 5.5, y2: 11 },
      { x1: 5.5, y1: 11, x2: 12, y2: 3 },
      collapsed,
    ],
  },
  play: {
    lines: [
      { x1: 4, y1: 2.5, x2: 4, y2: 11.5 },
      { x1: 4, y1: 2.5, x2: 11.5, y2: 7 },
      { x1: 4, y1: 11.5, x2: 11.5, y2: 7 },
    ],
  },
  pause: {
    lines: [
      { x1: 4, y1: 2.5, x2: 4, y2: 11.5 },
      { x1: 10, y1: 2.5, x2: 10, y2: 11.5 },
      collapsed,
    ],
  },
  download: {
    lines: [
      { x1: 7, y1: 2, x2: 7, y2: 10 },
      { x1: 3.5, y1: 6.5, x2: 7, y2: 10 },
      { x1: 10.5, y1: 6.5, x2: 7, y2: 10 },
    ],
  },
  upload: {
    lines: [
      { x1: 7, y1: 12, x2: 7, y2: 4 },
      { x1: 3.5, y1: 7.5, x2: 7, y2: 4 },
      { x1: 10.5, y1: 7.5, x2: 7, y2: 4 },
    ],
  },
  external: {
    lines: [
      { x1: 3, y1: 11, x2: 11, y2: 3 },
      { x1: 5, y1: 3, x2: 11, y2: 3 },
      { x1: 11, y1: 3, x2: 11, y2: 9 },
    ],
  },
  'arrow-right': { lines: arrowLines, rotation: 0, group: 'arrow' },
  'arrow-down': { lines: arrowLines, rotation: 90, group: 'arrow' },
  'arrow-left': { lines: arrowLines, rotation: 180, group: 'arrow' },
  'arrow-up': { lines: arrowLines, rotation: -90, group: 'arrow' },
  'chevron-right': { lines: chevronLines, rotation: 0, group: 'chevron' },
  'chevron-down': { lines: chevronLines, rotation: 90, group: 'chevron' },
  'chevron-left': { lines: chevronLines, rotation: 180, group: 'chevron' },
  'chevron-up': { lines: chevronLines, rotation: -90, group: 'chevron' },
  grip: {
    lines: [
      { x1: 7, y1: 3, x2: 7, y2: 3 },
      { x1: 7, y1: 7, x2: 7, y2: 7 },
      { x1: 7, y1: 11, x2: 7, y2: 11 },
    ],
  },
  slash: {
    lines: [{ x1: 11, y1: 3, x2: 3, y2: 11 }, collapsed, collapsed],
  },
  corner: {
    lines: [
      { x1: 4, y1: 3, x2: 4, y2: 11 },
      { x1: 4, y1: 11, x2: 11, y2: 11 },
      collapsed,
    ],
  },
};

interface AnimatedLineProps {
  line: IconLine;
  transition: Transition;
}

const AnimatedLine = ({ line, transition }: AnimatedLineProps) => (
  <motion.line
    animate={{
      x1: line.x1,
      y1: line.y1,
      x2: line.x2,
      y2: line.y2,
      opacity: line.opacity ?? 1,
    }}
    transition={transition}
    strokeLinecap="round"
  />
);

interface MorphIconProps {
  icon: IconName;
  size?: number;
  className?: string;
  strokeWidth?: number;
  transition?: Transition;
}

const MorphIcon = ({
  icon,
  size = 14,
  className,
  strokeWidth = 1.5,
  transition = defaultTransition,
}: MorphIconProps) => {
  const definition = icons[icon];
  const reducedMotion = useReducedMotion() ?? false;
  const prevDefinitionRef = useRef<IconDefinition>(definition);
  const activeTransition = reducedMotion ? { duration: 0 } : transition;

  const rotation = useSpring(definition.rotation ?? 0, activeTransition);

  // Same group → rotate between members. Different group → snap rotation
  // and let the line endpoints morph.
  const shouldRotate = useMemo(() => {
    const prev = prevDefinitionRef.current;
    return prev.group && definition.group && prev.group === definition.group;
  }, [definition]);

  useEffect(() => {
    if (shouldRotate) {
      rotation.set(definition.rotation ?? 0);
    } else {
      rotation.jump(definition.rotation ?? 0);
    }
    prevDefinitionRef.current = definition;
  }, [definition, shouldRotate, rotation]);

  return (
    <svg
      width={size}
      height={size}
      viewBox={`0 0 ${VIEWBOX_SIZE} ${VIEWBOX_SIZE}`}
      fill="none"
      stroke="currentColor"
      strokeWidth={strokeWidth}
      strokeLinejoin="round"
      className={cn('shrink-0', className)}
      aria-hidden="true"
    >
      <motion.g
        style={{
          rotate: rotation,
          transformOrigin: 'center',
        }}
      >
        <AnimatedLine
          line={definition.lines[0]}
          transition={activeTransition}
        />
        <AnimatedLine
          line={definition.lines[1]}
          transition={activeTransition}
        />
        <AnimatedLine
          line={definition.lines[2]}
          transition={activeTransition}
        />
      </motion.g>
    </svg>
  );
};

const iconNames = Object.keys(icons) as IconName[];

export type { IconName, MorphIconProps };
export { iconNames, MorphIcon };

MorphIcon is a tiny universal icon system inspired by Benji’s three-line experiments with Claude. Every shape — menu, plus, check, chevron, arrow, play, pause — is encoded as exactly three SVG <line> segments on a 14×14 canvas. Shapes that need fewer than three visible strokes collapse the extras to a zero-length point at the canvas center: invisible, but still tweenable into the next shape’s endpoints.

Because every icon shares the same three-line skeleton, any icon can morph into any other. motion.line animates each endpoint to its new coordinates; collapsed lines fade their opacity in parallel so they appear and disappear cleanly.

Group rotation

Some icons are the same shape rotated. The arrow set (right / down / left / up), the chevron set, and plus ↔ cross are all marked with a group. When you switch between members of the same group, the icon rotates rather than morphing endpoints. When you cross groups, rotation snaps and endpoints morph as usual. The result: chevron-right → chevron-down spins; chevron-right → check morphs.

Reduced motion

The component respects prefers-reduced-motion. When the user has it on, all transitions collapse to duration: 0 — icons swap instantly, no morph.

API Reference

Prop Default Type Description
icon * - IconName Name of the icon to render. Changing this triggers a morph or rotation.
size 14 number Pixel size of the icon, applied to width + height on the root <svg>.
strokeWidth 1.5 number Stroke width of each line.
transition { ease: [0.19, 1, 0.22, 1], duration: 0.4 } Transition Override the motion transition. The default is an expo-out curve — slow finish, no overshoot.
className - string Additional classes on the root <svg>.

Stroke colour follows currentColor, so wrapping the icon in any element that sets a text colour (text-foreground, text-accent-foreground, text-error, …) tints the icon accordingly.

The full list of names is exported as the iconNames constant — useful for building pickers or playgrounds.

Usage


          import { MorphIcon } from '@/foundations/icons/morph-icon/morph-icon';
import { useState } from 'react';

const [open, setOpen] = useState(false);

<button onClick={() => setOpen(!open)} aria-label={open ? 'Close' : 'Open menu'}>
  <MorphIcon icon={open ? 'cross' : 'menu'} size={20} />
</button>
        

Adding a new shape

Open morph-icon.tsx and extend the icons constant. Each entry is an IconDefinition with three IconLine segments on the 14×14 canvas. Use the exported collapsed line for any stroke you want to hide in this shape, and optionally assign a group + rotation so members of the same family rotate into each other instead of morphing.

Previous

Copy Button

Next

Roadmap