Components

Getting started

Components121

More

Kinetic SVG icon transition primitive and button with scale, rotate, and 3D horizon flip animations.

Used forMedia players & audio controlsNavigation headersTheme & mode switchersPassword fields

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/icon-morph.json

Where it’s used

  • Media players & audio controls

    Play/Pause and Mute/Unmute state swaps with harmonic spring settle.

  • Navigation headers

    Mobile hamburger menu morphing into an X close button.

  • Theme & mode switchers

    Sun/Moon toggling with 3D horizon flip.

  • Password fields

    Eye to EyeOff toggle with soft depth blur.

What it handles for you

Standard icon swaps feel instant and disconnected, while Framer Motion icon morphs add heavy bundle weight. IconMorph provides native CSS/WAAPI kinetic transitions with zero bundle cost.

Instead of wiring upabrupt SVG swapsheavy framer-motion icon wrappersplain boolean ternary icons
  1. Three morph modes

    Supports scale with soft depth blur, planar rotate (0° → 90°), and 3D horizon flip (rotateY).

  2. Standalone & button variants

    Use as a standalone kinetic icon primitive (IconMorph) or as an interactive button (IconMorphButton).

  3. Concentric hairline pill

    Button variant includes outline, ghost, and solid styles with active scale damping.

  4. Harmonic spring curve

    Transitions use cubic-bezier(0.22, 1.18, 0.36, 1) for a subtle 3% organic breath.

Overview

A micro-interaction primitive designed for icon state changes (Play/Pause, Menu/Close, Eye/EyeOff, Sun/Moon, Copy/Check). Instead of sudden swaps, icons morph continuously with spring settling, soft blurs, and rotational or 3D flips using 0 kB external animation dependencies.

States

inactive
Displays the "from" icon with full opacity.
active
Smoothly transitions into the "to" icon using the chosen morph mode.

Usage

usage.tsx
import { IconMorph, IconMorphButton } from "@/components/ui/icon-morph";import { Play, Pause } from "lucide-react";// As a standalone kinetic icon<IconMorph from={<Play size={16} />} to={<Pause size={16} />} active={isPlaying} />// As an interactive button<IconMorphButton  label="Toggle playback"  active={isPlaying}  onActiveChange={setIsPlaying}  from={<Play size={15} />}  to={<Pause size={15} />}/>

Props

PropTypeDescription
fromReact.ReactNodeFirst icon shown when inactive.
toReact.ReactNodeSecond icon shown when active.
activebooleandefault falseActive toggle state.
mode"scale" | "rotate" | "flip"default "scale"Kinetic transition mode.
sizenumberdefault 16Icon bounding dimension in pixels.
variant"outline" | "ghost" | "solid"default "outline"Button style (IconMorphButton only).

Accessibility and motion

  • Pure CSS transitions with will-change: transform ensure 60fps performance on all devices.
  • Perspective is applied automatically for 3D flip mode.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs ui-interaction-motion.

Source

components/icon-morph.tsx
"use client";import * as React from "react";import {  INTERACTION_FOCUS,  useInteractionReducedMotion,} from "./ui-interaction-motion";export type IconMorphMode = "rotate" | "scale" | "flip";export interface IconMorphProps extends React.HTMLAttributes<HTMLSpanElement> {  /** The first icon (rendered when active is false). */  from: React.ReactNode;  /** The second icon (rendered when active is true). */  to: React.ReactNode;  /** Controls which icon is active. */  active?: boolean;  /** Morph animation style. Defaults to scale. */  mode?: IconMorphMode;  /** Icon bounding box size in pixels. Defaults to 16. */  size?: number;}export interface IconMorphButtonProps  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "children"> {  from: React.ReactNode;  to: React.ReactNode;  active?: boolean;  onActiveChange?: (next: boolean) => void;  mode?: IconMorphMode;  size?: "sm" | "md" | "lg";  variant?: "outline" | "ghost" | "solid";  label: string;}const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";const EASE_SETTLE = "cubic-bezier(0.22, 1.18, 0.36, 1)";/** * Standalone kinetic SVG icon morph primitive. */export function IconMorph({  from,  to,  active = false,  mode = "scale",  size = 16,  className = "",  style,  ...props}: IconMorphProps) {  const reduced = useInteractionReducedMotion();  // Mode-specific transforms  const getTransforms = () => {    if (reduced) {      return {        from: {          opacity: active ? 0 : 1,          transform: "none",        },        to: {          opacity: active ? 1 : 0,          transform: "none",        },      };    }    if (mode === "rotate") {      return {        from: {          opacity: active ? 0 : 1,          transform: active ? "rotate(90deg) scale(0.65)" : "rotate(0deg) scale(1)",          transition: `opacity 220ms ${EASE}, transform 320ms ${EASE_SETTLE}`,        },        to: {          opacity: active ? 1 : 0,          transform: active ? "rotate(0deg) scale(1)" : "rotate(-90deg) scale(0.65)",          transition: `opacity 220ms ${EASE}, transform 320ms ${EASE_SETTLE}`,        },      };    }    if (mode === "flip") {      return {        from: {          opacity: active ? 0 : 1,          transform: active ? "rotateY(180deg) scale(0.7)" : "rotateY(0deg) scale(1)",          transition: `opacity 180ms ${EASE}, transform 360ms ${EASE_SETTLE}`,        },        to: {          opacity: active ? 1 : 0,          transform: active ? "rotateY(0deg) scale(1)" : "rotateY(-180deg) scale(0.7)",          transition: `opacity 180ms ${EASE}, transform 360ms ${EASE_SETTLE}`,        },      };    }    // Default "scale" with soft blur    return {      from: {        opacity: active ? 0 : 1,        transform: active ? "scale(0.55)" : "scale(1)",        filter: active ? "blur(1.5px)" : "blur(0px)",        transition: `opacity 200ms ${EASE}, transform 300ms ${EASE_SETTLE}, filter 200ms ${EASE}`,      },      to: {        opacity: active ? 1 : 0,        transform: active ? "scale(1)" : "scale(0.55)",        filter: active ? "blur(0px)" : "blur(1.5px)",        transition: `opacity 200ms ${EASE}, transform 300ms ${EASE_SETTLE}, filter 200ms ${EASE}`,      },    };  };  const transforms = getTransforms();  return (    <span      className={`relative inline-flex items-center justify-center shrink-0 isolate select-none ${className}`}      style={{        width: size,        height: size,        perspective: mode === "flip" ? 600 : undefined,        ...style,      }}      aria-hidden="true"      {...props}    >      <span        className="absolute inset-0 flex items-center justify-center will-change-transform"        style={transforms.from}      >        {from}      </span>      <span        className="absolute inset-0 flex items-center justify-center will-change-transform"        style={transforms.to}      >        {to}      </span>    </span>  );}const buttonSizeStyles = {  sm: "size-7 rounded-full text-[12px]",  md: "size-8.5 rounded-full text-[13px]",  lg: "size-10 rounded-full text-[14px]",};const iconSizes = {  sm: 13,  md: 15,  lg: 17,};const buttonVariantStyles = {  outline:    "bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] hover:bg-accent active:scale-[0.96]",  ghost:    "bg-transparent text-muted-foreground hover:bg-accent hover:text-foreground active:scale-[0.96]",  solid:    "bg-primary text-primary-foreground hover:bg-primary/90 shadow-[0_1px_2px_rgba(48,93,222,0.18)] active:scale-[0.96]",};/** * Clickable interactive button housing the IconMorph primitive. */export function IconMorphButton({  from,  to,  active: controlledActive,  onActiveChange,  mode = "scale",  size = "md",  variant = "outline",  label,  className = "",  onClick,  ...props}: IconMorphButtonProps) {  const [internalActive, setInternalActive] = React.useState(false);  const active = controlledActive !== undefined ? controlledActive : internalActive;  const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {    const next = !active;    if (controlledActive === undefined) {      setInternalActive(next);    }    onActiveChange?.(next);    onClick?.(e);  };  const sizeClass = buttonSizeStyles[size];  const variantClass = buttonVariantStyles[variant];  const iconPx = iconSizes[size];  return (    <button      type="button"      aria-label={label}      aria-pressed={active}      onClick={handleClick}      className={`inline-flex items-center justify-center cursor-pointer transition-colors duration-150 select-none ${sizeClass} ${variantClass} ${INTERACTION_FOCUS} ${className}`}      {...props}    >      <IconMorph from={from} to={to} active={active} mode={mode} size={iconPx} />    </button>  );}export default IconMorph;

Registry item: /r/icon-morph.json