Sliding Play Button
A circular media toggle built from three overlapping SVG elements that compress, slide, expand, and settle together with an optional duration override.
actionsvg-overlap
"use client";
import * as React from "react";
import {
animate,
cubicBezier,
useMotionValue,
useReducedMotion,
type Transition,
} from "motion/react";
import { cn } from "@/lib/utils";
// The icon is three overlapping pieces, not one path morphing between states:
//
// 1. the play triangle stays in the DOM and scales into the right pause bar;
// 2. a narrow rounded rectangle starts at the triangle's left edge, then
// widens and travels right underneath it to become the right pause bar;
// 3. a second rounded rectangle grows vertically in the space it leaves.
//
// Drawing the triangle last is deliberate. In a debug palette it remains visible
// inside the right bar, like the reference; in production all three use
// `currentColor`, so the overlap resolves into a clean pause glyph.
const ICON_VIEW_BOX = 36;
const ICON_CENTER = ICON_VIEW_BOX / 2;
const EPSILON = 1e-4;
type Point = readonly [number, number];
type Corner = { readonly point: Point; readonly radius: number };
type Shape = readonly Corner[];
const BAR_TOP = 4.5;
const BAR_BOTTOM = 31.5;
const BAR_WIDTH = 7;
const BAR_HEIGHT = BAR_BOTTOM - BAR_TOP;
const BAR_RADIUS = 2.5;
const LEFT_SLOT_X = 8.5;
const RIGHT_SLOT_X = 21.5;
const SHIFT_DISTANCE = RIGHT_SLOT_X - LEFT_SLOT_X;
const TRIANGLE_LEFT = 7.5;
const TRIANGLE_RIGHT = 33;
const TRIANGLE_TOP = 3;
const TRIANGLE_BOTTOM = 33;
const TRIANGLE_BODY: Shape = [
{ point: [TRIANGLE_LEFT, TRIANGLE_TOP], radius: 3.93 },
{ point: [TRIANGLE_LEFT, TRIANGLE_BOTTOM], radius: 3.93 },
{ point: [17, 27.4], radius: 0 },
{ point: [17, 8.6], radius: 0 },
];
const TRIANGLE_TIP: Shape = [
{ point: [17, 8.6], radius: 0 },
{ point: [17, 27.4], radius: 0 },
{ point: [TRIANGLE_RIGHT, ICON_CENTER], radius: 0 },
{ point: [TRIANGLE_RIGHT, ICON_CENTER], radius: 0 },
];
type Fillet = {
entry: Point;
control1: Point;
control2: Point;
exit: Point;
};
function arcControlFraction(interior: number) {
return (
1 - (4 / 3) * Math.tan((Math.PI - interior) / 4) * Math.tan(interior / 2)
);
}
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);
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)));
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 round(value: number) {
return Math.round(value * 10000) / 10000;
}
function coords([x, y]: Point) {
return `${round(x)} ${round(y)}`;
}
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`;
}
const TRIANGLE_PATH = [TRIANGLE_BODY, TRIANGLE_TIP]
.map(shapeToPath)
.join(" ");
// Scaling the triangle around this offset origin maps its bounding box exactly
// onto the right pause slot. It therefore visibly remains the same object while
// it compresses and slides right, instead of being replaced by a new path.
const TRIANGLE_PAUSE_SCALE_X =
BAR_WIDTH / (TRIANGLE_RIGHT - TRIANGLE_LEFT);
const TRIANGLE_PAUSE_SCALE_Y =
BAR_HEIGHT / (TRIANGLE_BOTTOM - TRIANGLE_TOP);
const TRIANGLE_CENTER = (TRIANGLE_LEFT + TRIANGLE_RIGHT) / 2;
const RIGHT_SLOT_CENTER = RIGHT_SLOT_X + BAR_WIDTH / 2;
const TRIANGLE_ORIGIN_X =
(RIGHT_SLOT_CENTER - TRIANGLE_PAUSE_SCALE_X * TRIANGLE_CENTER) /
(1 - TRIANGLE_PAUSE_SCALE_X);
const TRIANGLE_ORIGIN = `${round(
(TRIANGLE_ORIGIN_X / ICON_VIEW_BOX) * 100,
)}% 50%`;
const LEFT_BAR_ORIGIN = `${round(
((LEFT_SLOT_X + BAR_WIDTH / 2) / ICON_VIEW_BOX) * 100,
)}% 50%`;
const RIGHT_BAR_ORIGIN = `${round((RIGHT_SLOT_X / ICON_VIEW_BOX) * 100)}% 50%`;
// The two bars share one physical timeline. During the approach, the right bar
// travels from the left slot while the new left bar grows behind it. Their
// transforms share one rebound rhythm and settle on the same final frame. The
// left bar uses a slightly softer vertical overshoot than the moving right bar.
const HIDDEN_BAR_SCALE = 0.5;
const REBOUND_PEAK_SCALE = 1.25;
const LEFT_BAR_REBOUND_PEAK_SCALE = 1.2;
const REBOUND_PEAK_AT = 0.6;
const DEFAULT_MORPH_DURATION = 0.24;
const MASTER_EASE = cubicBezier(0.23, 1, 0.32, 1);
const SETTLE_EASE = cubicBezier(0.42, 0, 0.58, 1);
const MIN_MORPH_DURATION = 0.1;
function clamp01(value: number) {
return Math.min(1, Math.max(0, value));
}
function subProgress(progress: number, [start, end]: readonly [number, number]) {
return clamp01((progress - start) / (end - start));
}
function lerp(from: number, to: number, progress: number) {
return from + (to - from) * progress;
}
function reboundProgress(progress: number) {
if (progress <= REBOUND_PEAK_AT) {
return (
REBOUND_PEAK_SCALE * MASTER_EASE(progress / REBOUND_PEAK_AT)
);
}
return lerp(
REBOUND_PEAK_SCALE,
1,
SETTLE_EASE((progress - REBOUND_PEAK_AT) / (1 - REBOUND_PEAK_AT)),
);
}
function reboundScale(
from: number,
progress: number,
peak = REBOUND_PEAK_SCALE,
) {
if (progress <= REBOUND_PEAK_AT) {
return lerp(
from,
peak,
MASTER_EASE(progress / REBOUND_PEAK_AT),
);
}
return lerp(
peak,
1,
SETTLE_EASE((progress - REBOUND_PEAK_AT) / (1 - REBOUND_PEAK_AT)),
);
}
function scaleY(value: number) {
return `scaleY(${round(value)})`;
}
function translateXScaleX(x: number, scale: number) {
return `translateX(${round((x / ICON_VIEW_BOX) * 100)}%) scaleX(${round(scale)})`;
}
function triangleScale(x: number, y: number) {
return `scaleX(${round(x)}) scaleY(${round(y)})`;
}
export type SlidingPlayPauseIconStyles = {
leftBar: { transform: string; opacity: number };
rightBar: { transform: string; opacity: number };
triangle: { transform: string; opacity: number };
};
type SlidingPlayPauseIconValues = {
leftBar: { scaleY: number; opacity: number };
rightBar: { translateX: number; scaleX: number; opacity: number };
triangle: { scaleX: number; scaleY: number; opacity: number };
};
const PLAY_ICON_VALUES: SlidingPlayPauseIconValues = {
leftBar: { scaleY: HIDDEN_BAR_SCALE, opacity: 0 },
rightBar: {
translateX: -SHIFT_DISTANCE,
scaleX: HIDDEN_BAR_SCALE,
opacity: 0,
},
triangle: { scaleX: 1, scaleY: 1, opacity: 1 },
};
const PAUSE_ICON_VALUES: SlidingPlayPauseIconValues = {
leftBar: { scaleY: 1, opacity: 1 },
rightBar: { translateX: 0, scaleX: 1, opacity: 1 },
triangle: {
scaleX: TRIANGLE_PAUSE_SCALE_X,
scaleY: TRIANGLE_PAUSE_SCALE_Y,
opacity: 1,
},
};
function copyIconValues(
values: SlidingPlayPauseIconValues,
): SlidingPlayPauseIconValues {
return {
leftBar: { ...values.leftBar },
rightBar: { ...values.rightBar },
triangle: { ...values.triangle },
};
}
function iconValuesToStyles(
values: SlidingPlayPauseIconValues,
): SlidingPlayPauseIconStyles {
return {
leftBar: {
transform: scaleY(values.leftBar.scaleY),
opacity: round(values.leftBar.opacity),
},
rightBar: {
transform: translateXScaleX(
values.rightBar.translateX,
values.rightBar.scaleX,
),
opacity: round(values.rightBar.opacity),
},
triangle: {
transform: triangleScale(
values.triangle.scaleX,
values.triangle.scaleY,
),
opacity: round(values.triangle.opacity),
},
};
}
function transitionIconValues(
progress: number,
playing: boolean,
from: SlidingPlayPauseIconValues,
): SlidingPlayPauseIconValues {
const elapsed = clamp01(progress);
const target = playing ? PAUSE_ICON_VALUES : PLAY_ICON_VALUES;
if (playing) {
const settle = reboundProgress(elapsed);
const visibility = MASTER_EASE(subProgress(elapsed, [0, 0.12]));
return {
leftBar: {
scaleY: reboundScale(
from.leftBar.scaleY,
elapsed,
LEFT_BAR_REBOUND_PEAK_SCALE,
),
opacity: lerp(
from.leftBar.opacity,
target.leftBar.opacity,
visibility,
),
},
rightBar: {
translateX: lerp(
from.rightBar.translateX,
target.rightBar.translateX,
settle,
),
scaleX: reboundScale(from.rightBar.scaleX, elapsed),
opacity: lerp(
from.rightBar.opacity,
target.rightBar.opacity,
visibility,
),
},
triangle: {
scaleX: lerp(from.triangle.scaleX, target.triangle.scaleX, settle),
scaleY: lerp(from.triangle.scaleY, target.triangle.scaleY, settle),
opacity: 1,
},
};
}
// Mirror the horizontal rebound on the way back to play: the right bar and
// triangle travel left past the play position, then settle right into it.
const settle = reboundProgress(elapsed);
const exit = MASTER_EASE(elapsed);
return {
leftBar: {
scaleY: lerp(from.leftBar.scaleY, target.leftBar.scaleY, exit),
opacity: lerp(from.leftBar.opacity, target.leftBar.opacity, exit),
},
rightBar: {
translateX: lerp(
from.rightBar.translateX,
target.rightBar.translateX,
settle,
),
scaleX: lerp(from.rightBar.scaleX, target.rightBar.scaleX, settle),
opacity: lerp(from.rightBar.opacity, target.rightBar.opacity, exit),
},
triangle: {
scaleX: lerp(from.triangle.scaleX, target.triangle.scaleX, settle),
scaleY: lerp(from.triangle.scaleY, target.triangle.scaleY, settle),
opacity: 1,
},
};
}
/** Returns one full direction's styles, useful for inspection and testing. */
export function slidingPlayPauseIconTransitionStyles(
progress: number,
playing: boolean,
): SlidingPlayPauseIconStyles {
const from = playing ? PLAY_ICON_VALUES : PAUSE_ICON_VALUES;
return iconValuesToStyles(transitionIconValues(progress, playing, from));
}
/** Backward-compatible shorthand for the play → pause direction. */
export function slidingPlayPauseIconStyles(
progress: number,
): SlidingPlayPauseIconStyles {
return slidingPlayPauseIconTransitionStyles(progress, true);
}
function transitionDistance(
values: SlidingPlayPauseIconValues,
target: SlidingPlayPauseIconValues,
) {
const triangleRange = 1 - TRIANGLE_PAUSE_SCALE_X;
return clamp01(
Math.max(
Math.abs(values.leftBar.opacity - target.leftBar.opacity),
Math.abs(values.rightBar.opacity - target.rightBar.opacity),
Math.abs(values.leftBar.scaleY - target.leftBar.scaleY) /
(1 - HIDDEN_BAR_SCALE),
Math.abs(values.rightBar.scaleX - target.rightBar.scaleX) /
(1 - HIDDEN_BAR_SCALE),
Math.abs(values.rightBar.translateX - target.rightBar.translateX) /
SHIFT_DISTANCE,
Math.abs(values.triangle.scaleX - target.triangle.scaleX) /
triangleRange,
),
);
}
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 SlidingPlayButtonProps = 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 `--sliding-play-button-size`. */
size?: number;
/**
* Overrides the full morph duration in seconds. The coordinated rebound
* choreography remains fixed; set `duration` to `0` for an instant change.
*/
transition?: Pick<Transition, "duration">;
/** Accessible name while paused. */
playLabel?: string;
/** Accessible name while playing. */
pauseLabel?: string;
/** `ghost` sits on the page; `frosted` is the over-video glass. */
surface?: "ghost" | "frosted";
};
export const SlidingPlayButton = React.forwardRef<
HTMLButtonElement,
SlidingPlayButtonProps
>(function SlidingPlayButton(
{
playing,
defaultPlaying = false,
onPlayingChange,
size = SURFACE_SIZE,
transition,
playLabel = "Play",
pauseLabel = "Pause",
surface = "ghost",
className,
style,
type = "button",
disabled,
onClick,
...props
},
ref,
) {
const [internalPlaying, setInternalPlaying] = React.useState(defaultPlaying);
const leftBarRef = React.useRef<SVGRectElement | null>(null);
const rightBarRef = React.useRef<SVGRectElement | null>(null);
const triangleRef = React.useRef<SVGPathElement | null>(null);
const shouldReduceMotion = useReducedMotion();
const controlled = playing !== undefined;
const isPlaying = controlled ? playing : internalPlaying;
const progress = useMotionValue(1);
const instantTarget = React.useRef<boolean | null>(null);
const currentValues = React.useRef<SlidingPlayPauseIconValues | undefined>(
undefined,
);
currentValues.current ??= copyIconValues(
isPlaying ? PAUSE_ICON_VALUES : PLAY_ICON_VALUES,
);
const activeTransition = React.useRef<{
from: SlidingPlayPauseIconValues;
playing: boolean;
} | null>(null);
const initialStyles = React.useRef<SlidingPlayPauseIconStyles | undefined>(
undefined,
);
initialStyles.current ??= iconValuesToStyles(currentValues.current);
const requestedDuration = transition?.duration;
const morphDuration =
typeof requestedDuration === "number" && Number.isFinite(requestedDuration)
? Math.max(0, requestedDuration)
: DEFAULT_MORPH_DURATION;
const applyIconValues = React.useCallback(
(values: SlidingPlayPauseIconValues) => {
const layers = iconValuesToStyles(values);
if (leftBarRef.current) {
leftBarRef.current.style.transform = layers.leftBar.transform;
leftBarRef.current.style.opacity = String(layers.leftBar.opacity);
}
if (rightBarRef.current) {
rightBarRef.current.style.transform = layers.rightBar.transform;
rightBarRef.current.style.opacity = String(layers.rightBar.opacity);
}
if (triangleRef.current) {
triangleRef.current.style.transform = layers.triangle.transform;
}
},
[],
);
useIsomorphicLayoutEffect(
() =>
progress.on("change", (value) => {
const transition = activeTransition.current;
if (!transition) return;
const values = transitionIconValues(
value,
transition.playing,
transition.from,
);
currentValues.current = values;
applyIconValues(values);
}),
[applyIconValues, progress],
);
useIsomorphicLayoutEffect(() => {
const target = isPlaying ? PAUSE_ICON_VALUES : PLAY_ICON_VALUES;
const from =
currentValues.current ??
copyIconValues(isPlaying ? PLAY_ICON_VALUES : PAUSE_ICON_VALUES);
const shouldChangeInstantly =
shouldReduceMotion ||
morphDuration === 0 ||
instantTarget.current === isPlaying;
if (instantTarget.current === isPlaying) {
instantTarget.current = null;
}
if (shouldChangeInstantly) {
activeTransition.current = null;
currentValues.current = copyIconValues(target);
applyIconValues(target);
progress.set(1);
return;
}
const remaining = transitionDistance(from, target);
if (remaining <= EPSILON) {
activeTransition.current = null;
currentValues.current = copyIconValues(target);
applyIconValues(target);
return;
}
activeTransition.current = {
from: copyIconValues(from),
playing: isPlaying,
};
progress.set(0);
const controls = animate(progress, 1, {
duration: Math.max(
Math.min(MIN_MORPH_DURATION, morphDuration),
morphDuration * remaining,
),
ease: "linear",
});
return () => controls.stop();
}, [applyIconValues, isPlaying, morphDuration, progress, shouldReduceMotion]);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented || disabled) return;
const nextPlaying = !isPlaying;
// Native keyboard and assistive-technology clicks have no pointer detail.
// Keep those high-frequency interactions immediate instead of making the
// user wait for the decorative transform sequence.
instantTarget.current = event.detail === 0 ? nextPlaying : null;
if (!controlled) {
setInternalPlaying(nextPlaying);
}
onPlayingChange?.(nextPlaying);
},
[controlled, disabled, isPlaying, onClick, onPlayingChange],
);
const frosted = surface === "frosted";
return (
<button
ref={ref}
type={type}
disabled={disabled}
onClick={handleClick}
aria-label={isPlaying ? pauseLabel : playLabel}
data-slot="sliding-play-button"
data-state={isPlaying ? "playing" : "paused"}
style={{
["--sliding-play-button-size" as string]: `${size}px`,
width: "var(--sliding-play-button-size)",
height: "var(--sliding-play-button-size)",
...style,
}}
className={cn(
"relative inline-grid shrink-0 place-items-center rounded-full outline-none",
"transition-[background-color,transform] duration-100 ease-[cubic-bezier(0.23,1,0.32,1)]",
"active:scale-[0.97] disabled:pointer-events-none disabled:opacity-50",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current",
"motion-reduce:transition-colors motion-reduce:active:scale-100",
frosted
? "bg-ericts-media-control/30 text-ericts-media-control-foreground backdrop-blur-[16px] hover:bg-ericts-media-control/45"
: "hover:bg-muted hover:text-foreground",
className,
)}
{...props}
>
<svg
aria-hidden
viewBox={`0 0 ${ICON_VIEW_BOX} ${ICON_VIEW_BOX}`}
data-slot="sliding-play-button-icon"
style={{ width: ICON_RATIO, height: ICON_RATIO }}
className="fill-current"
>
<rect
ref={leftBarRef}
data-slot="sliding-play-button-left-bar"
className="motion-reduce:transition-opacity motion-reduce:duration-100 motion-reduce:ease-[cubic-bezier(0.23,1,0.32,1)]"
x={LEFT_SLOT_X}
y={BAR_TOP}
width={BAR_WIDTH}
height={BAR_HEIGHT}
rx={BAR_RADIUS}
style={{
opacity: initialStyles.current.leftBar.opacity,
transform: initialStyles.current.leftBar.transform,
transformBox: "view-box",
transformOrigin: LEFT_BAR_ORIGIN,
willChange: "transform, opacity",
}}
/>
<rect
ref={rightBarRef}
data-slot="sliding-play-button-right-bar"
className="motion-reduce:transition-opacity motion-reduce:duration-100 motion-reduce:ease-[cubic-bezier(0.23,1,0.32,1)]"
x={RIGHT_SLOT_X}
y={BAR_TOP}
width={BAR_WIDTH}
height={BAR_HEIGHT}
rx={BAR_RADIUS}
style={{
opacity: initialStyles.current.rightBar.opacity,
transform: initialStyles.current.rightBar.transform,
transformBox: "view-box",
transformOrigin: RIGHT_BAR_ORIGIN,
willChange: "transform, opacity",
}}
/>
<path
ref={triangleRef}
data-slot="sliding-play-button-triangle"
d={TRIANGLE_PATH}
style={{
opacity: initialStyles.current.triangle.opacity,
transform: initialStyles.current.triangle.transform,
transformBox: "view-box",
transformOrigin: TRIANGLE_ORIGIN,
willChange: "transform",
}}
/>
</svg>
</button>
);
});