Skip to content

Morphing Hamburger Icon

A menu button whose three bars morph on springs into a close X, a back arrow or a minus, with a lit pill, press squash and a live panel in the demo. Inherits currentColor, controlled or uncontrolled, no dependencies.

DOM + CSS0 deps1 file · 11 KB TSXreduced-motion aware

Freeminimalplayful
Preview

Props

28
No runtime dependencies
"use client";

import { useEffect, useRef, useState } from "react";
import type { ButtonHTMLAttributes, MouseEvent } from "react";

type MenuMorph = "x" | "arrow" | "minus";
type Pose = { x: number; y: number; r: number; s: number; o: number };
type PoseSet = { t: Pose; m: Pose; b: Pose };
type Spring = { x: number; v: number };

export type MorphingMenuButtonProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "color" | "children"> & {
  /** Width of the three-bar icon in px; the button is size + 24. */
  size?: number;
  /** "ink" paints the bars with currentColor, "red" with a fixed red. */
  color?: "ink" | "red";
  /** Id of the panel this button controls (aria-controls). */
  menuId?: string;
  /** What the bars become when open. */
  morph?: MenuMorph;
  /** Controlled open state. Leave undefined for uncontrolled. */
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  openLabel?: string;
  closeLabel?: string;
};

const clamp01 = (v: number) => (v < 0 ? 0 : v > 1 ? 1 : v);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const r2 = (v: number) => Math.round(v * 100) / 100;
const COS45 = 0.7071;

function closedSet(w: number): PoseSet {
  const g = w * 0.3;
  return {
    t: { x: 0, y: -g, r: 0, s: 1, o: 1 },
    m: { x: 0, y: 0, r: 0, s: 1, o: 1 },
    b: { x: 0, y: g, r: 0, s: 1, o: 1 },
  };
}

function endSet(morph: MenuMorph, w: number): PoseSet {
  if (morph === "arrow") {
    const len = 0.56;
    const tip = -0.47 * w;
    const c = ((len * w) / 2) * COS45;
    return {
      t: { x: tip + c, y: -c, r: -45, s: len, o: 1 },
      m: { x: 0, y: 0, r: 0, s: 1, o: 1 },
      b: { x: tip + c, y: c, r: 45, s: len, o: 1 },
    };
  }
  if (morph === "minus") {
    return {
      t: { x: 0, y: 0, r: 0, s: 1, o: 0 },
      m: { x: 0, y: 0, r: 0, s: 0.72, o: 1 },
      b: { x: 0, y: 0, r: 0, s: 1, o: 0 },
    };
  }
  return {
    t: { x: 0, y: 0, r: 45, s: 0.88, o: 1 },
    m: { x: 0, y: 0, r: 0, s: 0.2, o: 0 },
    b: { x: 0, y: 0, r: -45, s: 0.88, o: 1 },
  };
}

function barFrame(a: Pose, b: Pose, p: number, hoverScale: number, w: number) {
  const po = clamp01(p * 1.4);
  const s = lerp(a.s, b.s, p) * hoverScale;
  const x = lerp(a.x, b.x, p) + (1 - hoverScale) * (w / 2) * (1 - clamp01(p));
  const y = lerp(a.y, b.y, p);
  const r = lerp(a.r, b.r, p);
  const o = lerp(a.o, b.o, po);
  return {
    transform: "translate(" + r2(x) + "px," + r2(y) + "px) rotate(" + r2(r) + "deg) scaleX(" + r2(s * 1000) / 1000 + ")",
    opacity: String(r2(o * 100) / 100),
  };
}

function frames(w: number, e: PoseSet, pt: number, pm: number, pb: number, hv: number) {
  const c = closedSet(w);
  return {
    t: barFrame(c.t, e.t, pt, 1, w),
    m: barFrame(c.m, e.m, pm, 1 - 0.3 * hv * (1 - clamp01(pm)), w),
    b: barFrame(c.b, e.b, pb, 1, w),
  };
}

const SPRINGS = {
  reduced: { t: [900, 60], m: [900, 60], b: [900, 60], hv: [900, 60], pr: [900, 60] },
  alive: { t: [230, 18], m: [340, 29], b: [195, 16], hv: [240, 22], pr: [420, 27] },
} as const;

export function MorphingMenuButton({
  size = 28,
  menuId = "morphing-menu-panel",
  color = "ink",
  morph = "x",
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  openLabel = "Open menu",
  closeLabel = "Close menu",
  className = "",
  style,
  onClick,
  onPointerMove,
  onPointerDown,
  ...rest
}: MorphingMenuButtonProps) {
  const [inner, setInner] = useState(defaultOpen);
  const controlled = openProp !== undefined;
  const open = controlled ? openProp : inner;

  const btnRef = useRef<HTMLButtonElement>(null);
  const iconRef = useRef<HTMLSpanElement>(null);
  const topRef = useRef<HTMLSpanElement>(null);
  const midRef = useRef<HTMLSpanElement>(null);
  const botRef = useRef<HTMLSpanElement>(null);
  const live = useRef({ open, morph, size, hover: false, press: false });
  const wake = useRef<(() => void) | null>(null);

  // First-render bar styles, frozen so React never rewrites what the loop owns.
  const [init] = useState(() => frames(size, endSet(morph, size), open ? 1 : 0, open ? 1 : 0, open ? 1 : 0, 0));

  useEffect(() => {
    live.current.open = open;
    live.current.morph = morph;
    live.current.size = size;
    wake.current?.();
  }, [open, morph, size]);

  useEffect(() => {
    const btn = btnRef.current;
    const icon = iconRef.current;
    const top = topRef.current;
    const mid = midRef.current;
    const bot = botRef.current;
    if (!btn || !icon || !top || !mid || !bot) return;
    const L = live.current;
    const start = L.open ? 1 : 0;
    const sp = {
      t: { x: start, v: 0 } as Spring,
      m: { x: start, v: 0 } as Spring,
      b: { x: start, v: 0 } as Spring,
      hv: { x: 0, v: 0 } as Spring,
      pr: { x: 0, v: 0 } as Spring,
    };
    const cur = endSet(L.morph, L.size);
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduce = mq.matches;
    let raf = 0;
    let last = 0;
    let dirty = true;

    const step = (s: Spring, target: number, k: number, c: number, dt: number) => {
      const n = Math.max(1, Math.ceil(dt * 120));
      const h = dt / n;
      for (let i = 0; i < n; i++) {
        s.v += (-k * (s.x - target) - c * s.v) * h;
        s.x += s.v * h;
      }
      if (Math.abs(s.x - target) < 5e-4 && Math.abs(s.v) < 2e-3) {
        s.x = target;
        s.v = 0;
        return false;
      }
      return true;
    };
    const ease = (poseA: Pose, poseB: Pose, a: number) => {
      let moving = false;
      for (const key of ["x", "y", "r", "s", "o"] as const) {
        const d = poseB[key] - poseA[key];
        if (Math.abs(d) < 1e-3) poseA[key] = poseB[key];
        else {
          poseA[key] += d * a;
          moving = true;
        }
      }
      return moving;
    };

    const frame = (ts: number) => {
      raf = 0;
      const dt = last ? Math.min(0.05, (ts - last) / 1000) : 1 / 60;
      last = ts;
      const P = reduce ? SPRINGS.reduced : SPRINGS.alive;
      const target = L.open ? 1 : 0;
      let moving = dirty;
      dirty = false;
      if (step(sp.t, target, P.t[0], P.t[1], dt)) moving = true;
      if (step(sp.m, target, P.m[0], P.m[1], dt)) moving = true;
      if (step(sp.b, target, P.b[0], P.b[1], dt)) moving = true;
      if (step(sp.hv, L.hover && !reduce ? 1 : 0, P.hv[0], P.hv[1], dt)) moving = true;
      if (step(sp.pr, L.press && !reduce ? 1 : 0, P.pr[0], P.pr[1], dt)) moving = true;
      const goal = endSet(L.morph, L.size);
      const a = reduce ? 1 : 1 - Math.exp(-14 * dt);
      if (ease(cur.t, goal.t, a)) moving = true;
      if (ease(cur.m, goal.m, a)) moving = true;
      if (ease(cur.b, goal.b, a)) moving = true;
      const f = frames(L.size, cur, sp.t.x, sp.m.x, sp.b.x, sp.hv.x);
      top.style.transform = f.t.transform;
      top.style.opacity = f.t.opacity;
      mid.style.transform = f.m.transform;
      mid.style.opacity = f.m.opacity;
      bot.style.transform = f.b.transform;
      bot.style.opacity = f.b.opacity;
      icon.style.transform = "scale(" + r2((1 - 0.13 * sp.pr.x) * 1000) / 1000 + ")";
      if (moving) raf = requestAnimationFrame(frame);
      else last = 0;
    };
    const kick = () => {
      dirty = true;
      if (!raf) {
        last = 0;
        raf = requestAnimationFrame(frame);
      }
    };
    const onMq = () => {
      reduce = mq.matches;
      kick();
    };
    wake.current = kick;
    mq.addEventListener("change", onMq);
    kick();
    return () => {
      mq.removeEventListener("change", onMq);
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
      wake.current = null;
    };
  }, []);

  const toggle = (e: MouseEvent<HTMLButtonElement>) => {
    onClick?.(e);
    if (e.defaultPrevented) return;
    const next = !open;
    if (!controlled) setInner(next);
    onOpenChange?.(next);
  };
  const setPress = (v: boolean) => {
    live.current.press = v;
    wake.current?.();
  };
  const setHover = (v: boolean) => {
    live.current.hover = v;
    wake.current?.();
  };

  const thick = Math.max(2, Math.round(size * 0.09 * 10) / 10);
  const bar = color === "red" ? "#ef4444" : "currentColor";
  const barStyle = (f: { transform: string; opacity: string }) => ({
    top: "calc(50% - " + thick / 2 + "px)",
    width: size,
    height: thick,
    borderRadius: thick,
    background: bar,
    transform: f.transform,
    opacity: f.opacity,
  });

  return (
    <button
      type="button"
      data-morph={morph}
      {...rest}
      ref={btnRef}
      aria-label={open ? closeLabel : openLabel}
      aria-expanded={open}
      aria-controls={menuId}
      onClick={toggle}
      onPointerEnter={(e) => {
        if (e.pointerType !== "touch") setHover(true);
      }}
      onPointerLeave={() => {
        setHover(false);
        setPress(false);
      }}
      onPointerDown={(e) => {
        onPointerDown?.(e);
        setPress(true);
      }}
      onPointerUp={() => setPress(false)}
      onPointerCancel={() => setPress(false)}
      onKeyDown={(e) => {
        rest.onKeyDown?.(e);
        if (e.key === " " || e.key === "Enter") setPress(true);
      }}
      onKeyUp={(e) => {
        rest.onKeyUp?.(e);
        if (e.key === " " || e.key === "Enter") setPress(false);
      }}
      onBlur={(e) => {
        rest.onBlur?.(e);
        setPress(false);
      }}
      onPointerMove={(e) => {
        onPointerMove?.(e);
        if (e.pointerType === "touch") return;
        const el = btnRef.current;
        if (!el) return;
        const rect = el.getBoundingClientRect();
        const k = rect.width ? el.offsetWidth / rect.width : 1;
        el.style.setProperty("--mmb-x", (e.clientX - rect.left) * k + "px");
        el.style.setProperty("--mmb-y", (e.clientY - rect.top) * k + "px");
      }}
      className={
        "group relative inline-flex shrink-0 cursor-pointer touch-manipulation select-none items-center justify-center transition-[background-color] duration-200 hover:bg-[color-mix(in_srgb,currentColor_6%,transparent)] focus-visible:!outline-2 focus-visible:!outline-offset-2 focus-visible:!outline-[currentColor] " +
        className
      }
      style={{
        width: size + 24,
        height: size + 24,
        borderRadius: 9999,
        border: "1px solid",
        borderColor: "color-mix(in srgb, currentColor 22%, transparent)",
        WebkitTapHighlightColor: "transparent",
        ...style,
      }}
    >
      <span
        aria-hidden
        className="pointer-events-none absolute inset-0 opacity-0 transition-opacity duration-300 group-hover:opacity-100 motion-reduce:transition-none"
        style={{
          borderRadius: 9999,
          background:
            "radial-gradient(circle 46px at var(--mmb-x, 50%) var(--mmb-y, 50%), color-mix(in srgb, currentColor 14%, transparent), transparent)",
        }}
      />
      <span ref={iconRef} aria-hidden className="pointer-events-none relative block" style={{ width: size, height: size }}>
        <span ref={topRef} className="absolute left-0 block" style={barStyle(init.t)} />
        <span ref={midRef} className="absolute left-0 block" style={barStyle(init.m)} />
        <span ref={botRef} className="absolute left-0 block" style={barStyle(init.b)} />
      </span>
    </button>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this component

Three bars, no SVG paths and no icon swap: each bar is a span whose position, rotation, length and opacity are interpolated between a closed pose and an open pose. A morph prop picks the open pose: x folds the outer bars to the centre and rotates them 45 degrees while the middle bar shrinks away, arrow slides them to the left tip of the middle bar as an arrowhead, minus lets them converge and fade so one shorter bar remains. Each bar rides its own spring, with the bottom one slightly softer and the middle one stiffer, so the shape unfolds with a small stagger and overshoots a few degrees before settling. Switching morph while open glides from one open shape to the next instead of jumping. On hover the middle bar shortens to the right and a soft light follows the pointer across the pill; pressing squashes the icon. All motion is one requestAnimationFrame loop that writes straight to the DOM and sleeps at rest. Bars and border use currentColor, so the button works on light and dark pages without a prop; color red swaps in a fixed red. It is controlled (open and onOpenChange) or uncontrolled (defaultOpen), and every other button attribute passes through. The demo adds a menu panel with arrow-key navigation, Escape and click-outside.

Curator’s note

Morphing Hamburger Icon, rebuilt in October. The first version tweened three bars to an X with framer-motion; this one runs hand-written springs, offers three open shapes and needs nothing but React.

Pairs well with

Pro