Morphing Icon

A compact secondary icon button that smoothly morphs shared SVG line primitives between icon states.

The production button cycles through the target icon set with requestAnimationFrame-driven SVG line morphing and keyboard-operable button semantics. The React and standalone HTML examples expose the same provider-ready behavior with their own styling and wiring.

Constraints

The component keeps a compact 44px hit area, uses exact transition properties, and applies the same 0.97 press feedback used across the site.

The implementation uses three shared SVG line slots. Icons that need fewer strokes collapse the extra slots to invisible center points, so every state can animate through the same primitive model.

Behavior

The normalized morph is implemented across the four provider-ready outputs. Each transition interpolates three shared SVG line slots and uses requestAnimationFrame to write intermediate SVG attributes frame by frame; reduced-motion users move directly to the target frame.

Implementation

Switch target output, copy the provider-ready source snippet, or render that variant in isolation.

"use client";import { useEffect, useRef, useState } from "react";import { Button } from "@/components/ui/button";type IconName =  | "menu"  | "close"  | "plus"  | "minus"  | "check"  | "arrow-right"  | "play"  | "pause"  | "chevron-down"  | "chevron-left"  | "chevron-up"  | "more-horizontal";type StrokePrimitive = {  x1: number;  y1: number;  x2: number;  y2: number;  opacity: number;};type IconFrame = {  rotation: number;  strokes: [StrokePrimitive, StrokePrimitive, StrokePrimitive];};const collapsedStroke: StrokePrimitive = {  x1: 12,  y1: 12,  x2: 12,  y2: 12,  opacity: 0,};const iconSequence: IconName[] = [  "menu",  "close",  "plus",  "minus",  "check",  "arrow-right",  "play",  "pause",  "chevron-down",  "chevron-left",  "chevron-up",  "more-horizontal",];const iconFrames: Record<IconName, IconFrame> = {  menu: {    rotation: 0,    strokes: [      { x1: 5, y1: 7, x2: 19, y2: 7, opacity: 1 },      { x1: 5, y1: 12, x2: 19, y2: 12, opacity: 1 },      { x1: 5, y1: 17, x2: 19, y2: 17, opacity: 1 },    ],  },  close: {    rotation: 45,    strokes: [      { x1: 12, y1: 5, x2: 12, y2: 19, opacity: 1 },      { x1: 5, y1: 12, x2: 19, y2: 12, opacity: 1 },      collapsedStroke,    ],  },  plus: {    rotation: 0,    strokes: [      { x1: 12, y1: 5, x2: 12, y2: 19, opacity: 1 },      { x1: 5, y1: 12, x2: 19, y2: 12, opacity: 1 },      collapsedStroke,    ],  },  minus: {    rotation: 0,    strokes: [      collapsedStroke,      { x1: 5, y1: 12, x2: 19, y2: 12, opacity: 1 },      collapsedStroke,    ],  },  check: {    rotation: 0,    strokes: [      { x1: 5.5, y1: 12.5, x2: 9.75, y2: 16.75, opacity: 1 },      { x1: 9.75, y1: 16.75, x2: 18.5, y2: 7.25, opacity: 1 },      collapsedStroke,    ],  },  "arrow-right": {    rotation: 0,    strokes: [      { x1: 5, y1: 12, x2: 18.5, y2: 12, opacity: 1 },      { x1: 13.5, y1: 7, x2: 18.5, y2: 12, opacity: 1 },      { x1: 13.5, y1: 17, x2: 18.5, y2: 12, opacity: 1 },    ],  },  play: {    rotation: 0,    strokes: [      { x1: 8, y1: 6, x2: 17, y2: 12, opacity: 1 },      { x1: 17, y1: 12, x2: 8, y2: 18, opacity: 1 },      { x1: 8, y1: 18, x2: 8, y2: 6, opacity: 1 },    ],  },  pause: {    rotation: 0,    strokes: [      { x1: 9, y1: 6, x2: 9, y2: 18, opacity: 1 },      { x1: 15, y1: 6, x2: 15, y2: 18, opacity: 1 },      collapsedStroke,    ],  },  "chevron-down": {    rotation: 0,    strokes: [      { x1: 6, y1: 9, x2: 12, y2: 15, opacity: 1 },      { x1: 18, y1: 9, x2: 12, y2: 15, opacity: 1 },      collapsedStroke,    ],  },  "chevron-left": {    rotation: 0,    strokes: [      { x1: 15, y1: 6, x2: 9, y2: 12, opacity: 1 },      { x1: 15, y1: 18, x2: 9, y2: 12, opacity: 1 },      collapsedStroke,    ],  },  "chevron-up": {    rotation: 0,    strokes: [      { x1: 6, y1: 15, x2: 12, y2: 9, opacity: 1 },      { x1: 18, y1: 15, x2: 12, y2: 9, opacity: 1 },      collapsedStroke,    ],  },  "more-horizontal": {    rotation: 0,    strokes: [      { x1: 6, y1: 12, x2: 6, y2: 12, opacity: 1 },      { x1: 12, y1: 12, x2: 12, y2: 12, opacity: 1 },      { x1: 18, y1: 12, x2: 18, y2: 12, opacity: 1 },    ],  },};export interface MorphingIconShadcnProps {  icons?: IconName[];  value?: IconName;  defaultValue?: IconName;  className?: string;  onValueChange?: (value: IconName) => void;  "aria-label"?: string;}function usePrefersReducedMotion() {  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);  useEffect(() => {    const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");    setPrefersReducedMotion(mediaQuery.matches);    function handleChange() {      setPrefersReducedMotion(mediaQuery.matches);    }    mediaQuery.addEventListener("change", handleChange);    return () => {      mediaQuery.removeEventListener("change", handleChange);    };  }, []);  return prefersReducedMotion;}function cn(...classes: Array<string | undefined>) {  return classes.filter(Boolean).join(" ");}function getNextIcon(icons: IconName[], currentValue: IconName) {  const index = icons.indexOf(currentValue);  return icons[(index + 1) % icons.length] ?? icons[0] ?? "menu";}function easeInOutCubic(value: number) {  return value < 0.5    ? 4 * value * value * value    : 1 - Math.pow(-2 * value + 2, 3) / 2;}function interpolateNumber(from: number, to: number, progress: number) {  return from + (to - from) * progress;}function interpolateStroke(  from: StrokePrimitive,  to: StrokePrimitive,  progress: number,): StrokePrimitive {  return {    opacity: interpolateNumber(from.opacity, to.opacity, progress),    x1: interpolateNumber(from.x1, to.x1, progress),    x2: interpolateNumber(from.x2, to.x2, progress),    y1: interpolateNumber(from.y1, to.y1, progress),    y2: interpolateNumber(from.y2, to.y2, progress),  };}function interpolateFrame(  from: IconFrame,  to: IconFrame,  progress: number,): IconFrame {  return {    rotation: interpolateNumber(from.rotation, to.rotation, progress),    strokes: [      interpolateStroke(from.strokes[0], to.strokes[0], progress),      interpolateStroke(from.strokes[1], to.strokes[1], progress),      interpolateStroke(from.strokes[2], to.strokes[2], progress),    ],  };}function formatNumber(value: number) {  return Number(value.toFixed(3)).toString();}function applyFrameToElements(  frame: IconFrame,  group: SVGGElement | null,  lines: Array<SVGLineElement | null>,) {  group?.setAttribute(    "transform",    `rotate(${formatNumber(frame.rotation)} 12 12)`,  );  frame.strokes.forEach((stroke, index) => {    const line = lines[index];    if (!line) {      return;    }    line.setAttribute("x1", formatNumber(stroke.x1));    line.setAttribute("y1", formatNumber(stroke.y1));    line.setAttribute("x2", formatNumber(stroke.x2));    line.setAttribute("y2", formatNumber(stroke.y2));    line.setAttribute("opacity", formatNumber(stroke.opacity));  });}export function MorphingIconShadcn({  icons = iconSequence,  value,  defaultValue = "menu",  className,  onValueChange,  "aria-label": ariaLabel = "Cycle icon",}: MorphingIconShadcnProps) {  const [internalValue, setInternalValue] = useState<IconName>(defaultValue);  const shouldReduceMotion = usePrefersReducedMotion();  const currentValue = value ?? internalValue;  const frame = iconFrames[currentValue] ?? iconFrames.menu;  const [initialFrame] = useState<IconFrame>(() => frame);  const animatedFrameRef = useRef<IconFrame>(frame);  const groupRef = useRef<SVGGElement>(null);  const lineRefs = useRef<Array<SVGLineElement | null>>([]);  const rafRef = useRef<number | null>(null);  useEffect(() => {    const fromFrame = animatedFrameRef.current;    if (rafRef.current !== null) {      window.cancelAnimationFrame(rafRef.current);      rafRef.current = null;    }    if (fromFrame === frame) {      applyFrameToElements(frame, groupRef.current, lineRefs.current);      return;    }    if (shouldReduceMotion) {      animatedFrameRef.current = frame;      applyFrameToElements(frame, groupRef.current, lineRefs.current);      return;    }    let startedAt: number | null = null;    const duration = 320;    function updateFrame(now: number) {      startedAt ??= now;      const linearProgress = Math.min((now - startedAt) / duration, 1);      const easedProgress = easeInOutCubic(linearProgress);      const nextFrame =        linearProgress >= 1          ? frame          : interpolateFrame(fromFrame, frame, easedProgress);      animatedFrameRef.current = nextFrame;      applyFrameToElements(nextFrame, groupRef.current, lineRefs.current);      if (linearProgress < 1) {        rafRef.current = window.requestAnimationFrame(updateFrame);      } else {        animatedFrameRef.current = frame;        rafRef.current = null;      }    }    rafRef.current = window.requestAnimationFrame(updateFrame);    return () => {      if (rafRef.current !== null) {        window.cancelAnimationFrame(rafRef.current);        rafRef.current = null;      }    };  }, [frame, shouldReduceMotion]);  function cycleIcon() {    const nextValue = getNextIcon(icons, currentValue);    if (value === undefined) {      setInternalValue(nextValue);    }    onValueChange?.(nextValue);  }  return (    <Button      type="button"      aria-label={ariaLabel}      data-state={currentValue}      onClick={cycleIcon}      variant="secondary"      size="icon"      className={cn(        "size-11 cursor-pointer rounded-xl transition-[transform,background-color] duration-200 ease-out will-change-transform active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100",        className,      )}    >      <svg        aria-hidden="true"        viewBox="0 0 24 24"        className="pointer-events-none size-5 overflow-visible"        fill="none"        stroke="currentColor"        strokeLinecap="round"        strokeLinejoin="round"        strokeWidth="2.25"      >        <g ref={groupRef} transform={`rotate(${initialFrame.rotation} 12 12)`}>          {initialFrame.strokes.map((stroke, index) => (            <line              key={index}              ref={(line) => {                lineRefs.current[index] = line;              }}              x1={stroke.x1}              y1={stroke.y1}              x2={stroke.x2}              y2={stroke.y2}              opacity={stroke.opacity}              vectorEffect="non-scaling-stroke"            />          ))}        </g>      </svg>    </Button>  );}export default MorphingIconShadcn;

Copy

Copy actions use the same icon morph for compact controls and rounded text buttons. The React provider previews intentionally stay shared with the production CopyButton while their snippets show provider-specific styling; the standalone HTML + CSS variant uses its own preview.

Lucide picker

Select a supported Lucide set and cycle the preview through the same normalized stroke slots used by the production component.

Click icons to add/remove · Click preview to cycle