Play Button

A circular media toggle whose glyph morphs between a play triangle and pause bars, with a ghost or frosted over-video surface.

buttonpath-morph
"use client";

import * as React from "react";
import {
  animate,
  motion,
  useMotionValue,
  useReducedMotion,
  type Transition,
} from "motion/react";

import { cn } from "@/lib/utils";

// YouTube's play button is one `<path>` whose `d` is rewritten frame by frame,
// and the shape of that data is the whole trick: the play triangle is split
// down x=17 into two sub-paths, each written as a four-corner rounded quad.
// The pause icon is the same two quads with different corners, so the morph is
// a straight lerp of eight points instead of a path diff — the triangle's left
// half straightens into the first bar while its tip unfolds into the second.
//
// The play numbers below are recovered from the player's own path data: the
// corner vertices sit at (7.5, 3) / (7.5, 33) with a 3.93 tangent length, and
// the tip is a degenerate corner pair at (33, 18).

const ICON_VIEW_BOX = 36;
const EPSILON = 1e-4;

type Point = readonly [number, number];
type Corner = { readonly point: Point; readonly radius: number };
/** Corners run top-left → bottom-left → bottom-right → top-right. */
type Shape = readonly Corner[];

const PLAY_SHAPES: readonly [Shape, Shape] = [
  [
    { point: [7.5, 3], radius: 3.93 },
    { point: [7.5, 33], radius: 3.93 },
    { point: [17, 27.4], radius: 0 },
    { point: [17, 8.6], radius: 0 },
  ],
  [
    { point: [17, 8.6], radius: 0 },
    { point: [17, 27.4], radius: 0 },
    { point: [33, 18], radius: 0 },
    { point: [33, 18], radius: 0 },
  ],
];

const PAUSE_SHAPES: readonly [Shape, Shape] = [
  [
    { point: [8.5, 4.5], radius: 2.5 },
    { point: [8.5, 31.5], radius: 2.5 },
    { point: [15.5, 31.5], radius: 2.5 },
    { point: [15.5, 4.5], radius: 2.5 },
  ],
  [
    { point: [20.5, 4.5], radius: 2.5 },
    { point: [20.5, 31.5], radius: 2.5 },
    { point: [27.5, 31.5], radius: 2.5 },
    { point: [27.5, 4.5], radius: 2.5 },
  ],
];

// A morph is only a lerp while both icons keep the same corner count.
if (
  process.env.NODE_ENV !== "production" &&
  PLAY_SHAPES.some((shape, index) => shape.length !== PAUSE_SHAPES[index].length)
) {
  throw new Error("play-button: play and pause shapes must share a corner count.");
}

function lerp(from: number, to: number, progress: number) {
  return from + (to - from) * progress;
}

function round(value: number) {
  return Math.round(value * 100) / 100;
}

function coords([x, y]: Point) {
  return `${round(x)} ${round(y)}`;
}

/**
 * Offset, as a fraction of the tangent length, that turns a corner fillet into
 * a true circular arc. `interior` is the angle between the two edges meeting at
 * the corner; the player's own control points fall out of this exactly.
 */
function arcControlFraction(interior: number) {
  return (
    1 -
    (4 / 3) * Math.tan((Math.PI - interior) / 4) * Math.tan(interior / 2)
  );
}

type Fillet = {
  entry: Point;
  control1: Point;
  control2: Point;
  exit: Point;
};

function filletCorner(previous: Point, corner: Corner, next: Point): Fillet {
  const [vx, vy] = corner.point;
  const inX = previous[0] - vx;
  const inY = previous[1] - vy;
  const outX = next[0] - vx;
  const outY = next[1] - vy;
  const inLength = Math.hypot(inX, inY);
  const outLength = Math.hypot(outX, outY);
  // Neighbouring fillets must not overlap, and mid-morph an edge can collapse
  // to nothing (the play tip is two coincident corners), so clamp before use.
  const tangent = Math.min(corner.radius, inLength / 2, outLength / 2);
  const degenerate: Fillet = {
    entry: corner.point,
    control1: corner.point,
    control2: corner.point,
    exit: corner.point,
  };

  if (tangent <= EPSILON) return degenerate;

  const ux = inX / inLength;
  const uy = inY / inLength;
  const wx = outX / outLength;
  const wy = outY / outLength;
  const interior = Math.acos(Math.min(1, Math.max(-1, ux * wx + uy * wy)));

  // Folded back on itself, or straight through: no corner left to round.
  if (interior <= EPSILON || interior >= Math.PI - EPSILON) return degenerate;

  const pull = tangent * arcControlFraction(interior);

  return {
    entry: [vx + ux * tangent, vy + uy * tangent],
    control1: [vx + ux * pull, vy + uy * pull],
    control2: [vx + wx * pull, vy + wy * pull],
    exit: [vx + wx * tangent, vy + wy * tangent],
  };
}

function shapeToPath(shape: Shape) {
  const count = shape.length;
  const fillets = shape.map((corner, index) =>
    filletCorner(
      shape[(index + count - 1) % count].point,
      corner,
      shape[(index + 1) % count].point,
    ),
  );

  let path = `M ${coords(fillets[count - 1].exit)}`;

  for (const fillet of fillets) {
    path += ` L ${coords(fillet.entry)} C ${coords(fillet.control1)} ${coords(
      fillet.control2,
    )} ${coords(fillet.exit)}`;
  }

  return `${path} Z`;
}

function lerpShape(from: Shape, to: Shape, progress: number): Shape {
  return from.map((corner, index) => ({
    point: [
      lerp(corner.point[0], to[index].point[0], progress),
      lerp(corner.point[1], to[index].point[1], progress),
    ] as Point,
    radius: lerp(corner.radius, to[index].radius, progress),
  }));
}

/** `0` draws the play triangle, `1` draws the pause bars. */
export function playPauseIconPath(progress: number) {
  const clamped = Math.min(1, Math.max(0, progress));

  return PLAY_SHAPES.map((shape, index) =>
    shapeToPath(lerpShape(shape, PAUSE_SHAPES[index], clamped)),
  ).join(" ");
}

// The glyph is already on screen and changes shape in place, so it eases in and
// out rather than out-only. 200ms is long enough to read the unfold and short
// enough to stay under the press.
const MORPH_TRANSITION: Transition = {
  duration: 0.2,
  ease: [0.65, 0, 0.35, 1],
};

// The player's bezel: a disc that leaves the button on every toggle. It exits,
// so it eases out, and it runs past the 300ms UI ceiling on purpose — nothing
// waits on it, and cutting it short reads as a flicker instead of a pulse.
//
// It has to ramp in over the first frames instead of arriving at full strength.
// On the ghost surface the disc is currentColor against the page, so appearing
// at peak in a single frame reads as a flash rather than a ripple.
const PULSE_PEAK_OPACITY = 0.14;
const PULSE_RAMP = 0.08;

const PULSE_TRANSITION: Transition = {
  duration: 0.5,
  ease: [0.16, 1, 0.3, 1],
  times: [0, PULSE_RAMP, 1],
};

// Matches the expanding slider's control box: a 24px glyph in a 40px surface.
// The icon is sized as a ratio so overriding `--play-button-size` scales both.
const SURFACE_SIZE = 40;
const ICON_SIZE = 24;
const ICON_RATIO = `${(ICON_SIZE / SURFACE_SIZE) * 100}%`;

const useIsomorphicLayoutEffect =
  typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;

export type PlayButtonProps = Omit<
  React.ComponentPropsWithoutRef<"button">,
  "aria-label" | "children"
> & {
  /** Controlled playing state. */
  playing?: boolean;
  /** Initial playing state for uncontrolled usage. */
  defaultPlaying?: boolean;
  /** Called after the button requests a playing-state change. */
  onPlayingChange?: (playing: boolean) => void;
  /** Diameter in px, also published as `--play-button-size` for CSS overrides. */
  size?: number;
  /** Accessible name while paused. */
  playLabel?: string;
  /** Accessible name while playing. */
  pauseLabel?: string;
  /** Opt into the player's bezel: a disc that expands off the button on every toggle. */
  pulseOnToggle?: boolean;
  /** `ghost` sits on the page; `frosted` is the player's over-video glass. */
  surface?: "ghost" | "frosted";
};

export const PlayButton = React.forwardRef<HTMLButtonElement, PlayButtonProps>(
  function PlayButton(
    {
      playing,
      defaultPlaying = false,
      onPlayingChange,
      size = SURFACE_SIZE,
      playLabel = "Play",
      pauseLabel = "Pause",
      pulseOnToggle = false,
      surface = "ghost",
      className,
      style,
      type = "button",
      disabled,
      onClick,
      ...props
    },
    ref,
  ) {
    const [internalPlaying, setInternalPlaying] = React.useState(defaultPlaying);
    const [pulseKey, setPulseKey] = React.useState(0);
    const pathRef = React.useRef<SVGPathElement | null>(null);
    const shouldReduceMotion = useReducedMotion();
    const controlled = playing !== undefined;
    const isPlaying = controlled ? playing : internalPlaying;
    const progress = useMotionValue(isPlaying ? 1 : 0);

    // React must never re-patch `d`, or a toggle would snap to the end state
    // for a frame before the animation picked it up. Render the mount-time path
    // for SSR and hand the attribute to the motion value from then on.
    const initialPath = React.useRef<string | undefined>(undefined);
    initialPath.current ??= playPauseIconPath(isPlaying ? 1 : 0);

    useIsomorphicLayoutEffect(
      () =>
        progress.on("change", (value) => {
          pathRef.current?.setAttribute("d", playPauseIconPath(value));
        }),
      [progress],
    );

    useIsomorphicLayoutEffect(() => {
      const target = isPlaying ? 1 : 0;

      if (shouldReduceMotion) {
        progress.set(target);
        return;
      }

      // Animating the motion value (not a fresh tween) means a double-tap picks
      // up from wherever the morph currently is instead of jumping.
      const controls = animate(progress, target, MORPH_TRANSITION);

      return () => controls.stop();
    }, [isPlaying, progress, shouldReduceMotion]);

    const handleClick = React.useCallback(
      (event: React.MouseEvent<HTMLButtonElement>) => {
        onClick?.(event);

        if (event.defaultPrevented || disabled) return;

        const nextPlaying = !isPlaying;

        if (!controlled) {
          setInternalPlaying(nextPlaying);
        }

        if (pulseOnToggle) {
          setPulseKey((key) => key + 1);
        }

        onPlayingChange?.(nextPlaying);
      },
      [
        controlled,
        disabled,
        isPlaying,
        onClick,
        onPlayingChange,
        pulseOnToggle,
      ],
    );

    const frosted = surface === "frosted";

    return (
      <button
        ref={ref}
        type={type}
        disabled={disabled}
        onClick={handleClick}
        aria-label={isPlaying ? pauseLabel : playLabel}
        data-slot="play-button"
        data-state={isPlaying ? "playing" : "paused"}
        style={{
          ["--play-button-size" as string]: `${size}px`,
          width: "var(--play-button-size)",
          height: "var(--play-button-size)",
          ...style,
        }}
        className={cn(
          "relative inline-grid shrink-0 place-items-center rounded-full outline-none",
          // Hover tint and press scale share one short ease-out so the button
          // answers the pointer before the morph has visibly started.
          "transition-[background-color,transform] duration-100 ease-out",
          "active:scale-[0.94] disabled:pointer-events-none disabled:opacity-50",
          // currentColor keeps the ring readable on video in either surface.
          "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current",
          "motion-reduce:transition-none motion-reduce:active:scale-100",
          frosted
            ? "bg-black/30 text-white backdrop-blur-[16px] hover:bg-black/45"
            : "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50",
          className,
        )}
        {...props}
      >
        {pulseOnToggle && !shouldReduceMotion && pulseKey > 0 ? (
          <motion.span
            key={pulseKey}
            aria-hidden
            data-slot="play-button-pulse"
            className="pointer-events-none absolute inset-0 rounded-full bg-current"
            initial={{ opacity: 0, transform: "scale(0.9)" }}
            animate={{
              opacity: [0, PULSE_PEAK_OPACITY, 0],
              transform: ["scale(0.9)", "scale(1.02)", "scale(1.7)"],
            }}
            transition={PULSE_TRANSITION}
          />
        ) : null}
        <svg
          aria-hidden
          viewBox={`0 0 ${ICON_VIEW_BOX} ${ICON_VIEW_BOX}`}
          data-slot="play-button-icon"
          style={{ width: ICON_RATIO, height: ICON_RATIO }}
          className={cn(
            "fill-current",
            frosted && "drop-shadow-[0_0_2px_rgba(0,0,0,0.5)]",
          )}
        >
          <path ref={pathRef} d={initialPath.current} />
        </svg>
      </button>
    );
  },
);

Installation