Domphy

pathMorph

A Labs block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call pathMorph() with no arguments for a working demo, or edit the code below live.

Implementation notes

Both glyphs hand-authored as two 4-point polygons with a fixed index correspondence (left bar's right edge collapses into the triangle apex; right bar's 4 points all converge on that same apex), morphed via a manual requestAnimationFrame point-lerp (ease-out cubic, no overshoot to avoid mid-morph self-intersection) rather than relying on WAAPI's d animation, whose cross-engine support is inconsistent and which jsdom's Element.animate() no-ops entirely. Exact upstream point geometry was never viewed (clean-room), so the glyph silhouettes are an independent approximation of 'rounded/skewed pause bars' and 'play triangle', not a pixel match.

Status: ported · Reference: Aceternity UI original

// Aceternity UI "SVG Path Morphing" — clean-room reimplementation from the
// public behavior/visual spec only (no upstream source viewed or copied). A
// toggle icon button whose glyph reshapes continuously between a two-bar
// "pause" silhouette and a right-pointing "play" triangle.
//
// Reliable cross-browser interpolation of an SVG `d` attribute needs both
// shapes to share the same point count/order — the spec's own research note
// says as much. Rather than trust the Web Animations API to animate the raw
// `d` string (support for animating `d` as a CSS property is inconsistent
// across engines, and jsdom's `Element.animate` is a no-op stub that would
// silently skip the whole effect), both glyphs are authored here as two
// 4-point polygons with a fixed index correspondence, and the morph is driven
// by a manual `requestAnimationFrame` tween that lerps each point pair and
// writes the resulting "M/L/L/L Z" string to `d` every frame — the same
// "no bundled spring/tween engine, so drive it by hand" tradeoff
// tracingBeam.ts/smoothCursor.ts/numberTicker.ts make for other effects that
// need to animate something other than `transform`/`opacity`.
//
// Left bar -> its right edge collapses into the triangle's apex (index 1 and
// 2 both map to the apex point), so its left edge continues to read as the
// triangle's flat left edge. Right bar -> all four of its points converge on
// that same apex, so it visually "flies in" and merges with the left bar's
// tip rather than fading independently — the "bars visually merge/reshape
// into the triangle" read the spec describes.

import type { DomphyElement, ElementNode, Listener, StyleObject } from "@domphy/core";
import { toState, type ValueOrState } from "@domphy/core";
import { type ThemeColor, themeColor, themeSpacing } from "@domphy/theme";

type MorphPoint = [number, number];

export interface PathMorphProps {
  /** Playing/paused value. Pass a `State<boolean>` for controlled external control (the
   * click handler still toggles it); a plain boolean seeds an internal, uncontrolled state.
   * Defaults to `true` (renders the two-bar "pause" glyph at rest, matching the reference demo). */
  playing?: ValueOrState<boolean>;
  onToggle?: (playing: boolean) => void;
  /** Button side length, in `themeSpacing` units. Defaults to `11` (~44px at the base font size). */
  sizeUnits?: number;
  /** Color family for the button's dark track and its light glyph. Defaults to `"neutral"`. */
  color?: ThemeColor;
  /** Morph duration, in ms. Defaults to `240` — "well under half a second". */
  duration?: number;
  ariaLabel?: { play: string; pause: string };
  style?: StyleObject;
}

// Both glyphs authored on a shared 0-24 viewBox, as two 4-point polygons with
// a fixed index correspondence (see file header for the point-mapping story).
const PAUSE_LEFT_BAR: MorphPoint[] = [
  [7.1, 5],
  [11.3, 5],
  [10.1, 19],
  [5.9, 19],
];
const PAUSE_RIGHT_BAR: MorphPoint[] = [
  [13.9, 5],
  [18.1, 5],
  [16.9, 19],
  [12.7, 19],
];
const PLAY_TRIANGLE_APEX: MorphPoint = [18.5, 12];
const PLAY_FROM_LEFT_BAR: MorphPoint[] = [[7.5, 5], PLAY_TRIANGLE_APEX, PLAY_TRIANGLE_APEX, [7.5, 19]];
const PLAY_FROM_RIGHT_BAR: MorphPoint[] = [
  PLAY_TRIANGLE_APEX,
  PLAY_TRIANGLE_APEX,
  PLAY_TRIANGLE_APEX,
  PLAY_TRIANGLE_APEX,
];

function lerpPoints(from: MorphPoint[], to: MorphPoint[], t: number): MorphPoint[] {
  return from.map(([fromX, fromY], index) => {
    const [toX, toY] = to[index];
    return [fromX + (toX - fromX) * t, fromY + (toY - fromY) * t] as MorphPoint;
  });
}

function pointsToPath(points: MorphPoint[]): string {
  const [[startX, startY], ...rest] = points;
  const lines = rest.map(([x, y]) => `L ${x.toFixed(2)} ${y.toFixed(2)}`).join(" ");
  return `M ${startX.toFixed(2)} ${startY.toFixed(2)} ${lines} Z`;
}

/** Ease-out cubic — fast start, gentle settle, no overshoot (overshoot would make the
 * point-count-padded polygons above self-intersect mid-morph). */
function easeOutCubic(t: number): number {
  return 1 - (1 - t) ** 3;
}

/** Drives a single bar-to-triangle (or triangle-to-bar) polygon morph on one `<path>` element. */
function createPolygonMorphDriver(pathElement: SVGPathElement, restPoints: MorphPoint[], targetPoints: MorphPoint[]) {
  let currentPoints = restPoints;
  let rafId: number | null = null;

  const setPoints = (points: MorphPoint[]) => {
    currentPoints = points;
    pathElement.setAttribute("d", pointsToPath(points));
  };
  setPoints(restPoints);

  const animateTo = (toPlaying: boolean, durationMs: number) => {
    if (rafId !== null) cancelAnimationFrame(rafId);
    const from = currentPoints;
    const to = toPlaying ? restPoints : targetPoints;
    const startTime = performance.now();
    const step = (now: number) => {
      const elapsed = now - startTime;
      const t = durationMs <= 0 ? 1 : Math.min(1, elapsed / durationMs);
      setPoints(lerpPoints(from, to, easeOutCubic(t)));
      if (t < 1) {
        rafId = requestAnimationFrame(step);
      } else {
        rafId = null;
      }
    };
    rafId = requestAnimationFrame(step);
  };

  const dispose = () => {
    if (rafId !== null) cancelAnimationFrame(rafId);
  };

  return { animateTo, dispose };
}

/**
 * A toggle icon button whose glyph continuously reshapes between a two-bar
 * "pause" silhouette and a right-pointing "play" triangle, by directly
 * interpolating the underlying SVG path data. Call with no arguments for a
 * working demo — a dark circular button, morphing on click.
 */
function pathMorph(props: PathMorphProps = {}): DomphyElement<"button"> {
  const playingState = toState(props.playing ?? true);
  const sizeUnits = props.sizeUnits ?? 11;
  const color = props.color ?? "neutral";
  const duration = props.duration ?? 240;
  const labels = props.ariaLabel ?? { play: "Play", pause: "Pause" };

  const toggle = () => {
    const nextPlaying = !playingState.get();
    playingState.set(nextPlaying);
    props.onToggle?.(nextPlaying);
  };

  let leftBarDriver: ReturnType<typeof createPolygonMorphDriver> | null = null;
  let rightBarDriver: ReturnType<typeof createPolygonMorphDriver> | null = null;
  let releasePlayingListener: (() => void) | null = null;

  const leftBarPath: DomphyElement<"path"> = {
    path: null,
    d: pointsToPath(PAUSE_LEFT_BAR),
    fill: "currentColor",
    _onMount: (node: ElementNode) => {
      const element = node.domElement as SVGPathElement | null;
      if (!element || typeof requestAnimationFrame !== "function") return;
      leftBarDriver = createPolygonMorphDriver(element, PAUSE_LEFT_BAR, PLAY_FROM_LEFT_BAR);
      if (!playingState.get()) leftBarDriver.animateTo(false, 0);
    },
    _onRemove: () => leftBarDriver?.dispose(),
  };

  const rightBarPath: DomphyElement<"path"> = {
    path: null,
    d: pointsToPath(PAUSE_RIGHT_BAR),
    fill: "currentColor",
    _onMount: (node: ElementNode) => {
      const element = node.domElement as SVGPathElement | null;
      if (!element || typeof requestAnimationFrame !== "function") return;
      rightBarDriver = createPolygonMorphDriver(element, PAUSE_RIGHT_BAR, PLAY_FROM_RIGHT_BAR);
      if (!playingState.get()) rightBarDriver.animateTo(false, 0);
    },
    _onRemove: () => rightBarDriver?.dispose(),
  };

  return {
    button: [
      {
        svg: [leftBarPath, rightBarPath],
        viewBox: "0 0 24 24",
        role: "img",
        ariaHidden: "true",
        style: { width: "55%", height: "55%", display: "block" },
      } as DomphyElement<"svg">,
    ],
    type: "button",
    ariaLabel: (listener: Listener) => (playingState.get(listener) ? labels.pause : labels.play),
    onClick: toggle,
    dataTone: "shift-17",
    _onMount: (node: ElementNode) => {
      releasePlayingListener = playingState.addListener((nextPlaying: boolean) => {
        leftBarDriver?.animateTo(nextPlaying, duration);
        rightBarDriver?.animateTo(nextPlaying, duration);
      });
      node.addHook("Remove", () => releasePlayingListener?.());
    },
    style: {
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      width: themeSpacing(sizeUnits),
      height: themeSpacing(sizeUnits),
      border: "none",
      borderRadius: "50%",
      cursor: "pointer",
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit", color),
      color: (listener: Listener) => themeColor(listener, "shift-11", color),
      transition: "background-color 150ms ease",
      "&:hover": { backgroundColor: (listener: Listener) => themeColor(listener, "increase-1", color) },
      ...(props.style ?? {}),
    } as StyleObject,
  };
}

export { pathMorph };

← Back to Aceternity UI catalog