Seek Button
A circular media skip control whose ring spins one full turn per press while the seconds it jumps stay upright at the centre.
actionrotation
"use client";
import * as React from "react";
import {
animate,
motion,
useMotionValue,
type Transition,
} from "motion/react";
import { useReducedMotion } from "@/hooks/use-reduced-motion";
import { cn } from "@/lib/utils";
// The glyph is drawn in the same 36-unit box `play-button` uses, so a seek
// button and a play button line up stroke-for-stroke in one transport bar.
const ICON_VIEW_BOX = 36;
const CENTER = ICON_VIEW_BOX / 2;
const RING_RADIUS = 13;
/**
* The ring stops short of a full turn; the gap is where the arrowhead leads.
* Wide enough that the head and the ring's tail read as two ends of one arrow
* rather than a stroke that merely thickens.
*/
const RING_GAP_DEGREES = 40;
/** How far the head reaches back over the stroke, so the two read as one arrow. */
const ARROW_OVERLAP = 2.4;
const ARROW_LENGTH = 6.6;
const ARROW_HALF_HEIGHT = 3.7;
function round(value: number) {
return Math.round(value * 100) / 100;
}
/** Degrees run clockwise from the top of the ring, which is where it ends. */
function ringPoint(degrees: number) {
const radians = (degrees * Math.PI) / 180;
return [
round(CENTER + RING_RADIUS * Math.sin(radians)),
round(CENTER - RING_RADIUS * Math.cos(radians)),
] as const;
}
const [RING_START_X, RING_START_Y] = ringPoint(RING_GAP_DEGREES);
const RING_END_Y = CENTER - RING_RADIUS;
// Sweeping clockwise past 180° means both arc flags are set.
const RING_PATH = `M ${RING_START_X} ${RING_START_Y} A ${RING_RADIUS} ${RING_RADIUS} 0 1 1 ${CENTER} ${RING_END_Y}`;
const ARROW_BASE_X = CENTER - ARROW_OVERLAP;
const ARROW_PATH = [
`M ${ARROW_BASE_X} ${round(RING_END_Y - ARROW_HALF_HEIGHT)}`,
`L ${round(ARROW_BASE_X + ARROW_LENGTH)} ${RING_END_Y}`,
`L ${ARROW_BASE_X} ${round(RING_END_Y + ARROW_HALF_HEIGHT)}`,
"Z",
].join(" ");
// A press must read as exactly one turn, and the turn is the whole point of
// the control, so it is paced to be watched rather than to get out of the way.
// 400ms lands as the finger lifts: quick enough that a burst of taps stays one
// continuous spin, slow enough to see the arrow come back round.
//
// The curve carries more of that budget than the duration does. It leaves
// promptly, spends the middle of the ramp on the middle of the rotation, and
// settles; a steep ease-out at the same duration would put two thirds of the
// turn in the first few frames, which reads as a flicker rather than a turn.
const SPIN_TRANSITION: Transition = {
duration: 0.4,
ease: [0.45, 0.25, 0.2, 1],
};
const TURN_DEGREES = 360;
// Matches `play-button`: a 24px glyph inside a 40px surface, expressed as a
// ratio so overriding `--seek-button-size` scales both.
const SURFACE_SIZE = 40;
const ICON_SIZE = 24;
const ICON_RATIO = `${(ICON_SIZE / SURFACE_SIZE) * 100}%`;
const AMOUNT_FONT_SIZE = 12;
/** Three digits or more have to shrink to clear the ring's inner edge. */
const AMOUNT_FONT_SIZE_WIDE = 9;
export type SeekDirection = "forward" | "backward";
export type SeekSurface = "ghost" | "glass" | "frosted";
/**
* `glass` is the default because a skip control is read at a glance, usually
* over artwork or video: it needs a hit target you can see before you reach for
* it. All three are built from `currentColor` and theme tokens, so the glyph
* stays readable on whatever sits behind them.
*
* The glass is a stack of layers rather than one translucent fill, because that
* is what makes a pane read as a *solid* with thickness instead of a tinted
* hole. From the back: the backdrop filter blurs and re-saturates the page
* behind the disc; a scrim the colour of the page thins it out; a lens ring
* blurs the rim harder than the middle, the way a thick edge bends what passes
* through it; a fixed sheen lays a specular across the surface; and the shadow
* stack lights the top edge, catches the bottom one, and drops the disc onto
* the page.
*
* The scrim follows the environment the way an iOS material does, but it is not
* the same colour in both themes, because a material lets light *through*: on a
* light page it thins the backdrop towards the page (`background`), and on a
* dark one it has to lift it instead (`foreground`). Scrimming a dark page with
* more dark is what makes glass read as a smudge — nothing separates the disc
* from the page, the black inner shade and drop shadow land invisible, and the
* specular is left carrying the whole shape as a grey fog. Lifting instead puts
* the pane in front of the page again, which is also why the sheen can come
* down in the dark: it no longer has to do the separating on its own.
*
* The speculars stay light in both themes, since a highlight is a reflection of
* the room rather than a colour of the surface. `frosted` pins the scrim to the
* player's fixed on-video palette and keeps the rest.
*/
/**
* Five layers of light, written out in order so the stack can be read as one
* thing: specular on the top edge, a catch-light on the bottom one, the pane's
* own thickness shading its underside, then a contact shadow and a wide ambient
* one. The ambient is deliberately broad and weak — a tight, strong drop shadow
* rings the disc like a dark halo instead of lifting it off the page.
*
* These are achromatic on purpose and therefore literal: a highlight is white
* and a shadow is black in every theme, so routing them through theme variables
* would only add globals a consumer must not touch, and lose the whole stack
* for anyone who copies this file by hand instead of installing it.
*/
const GLASS_SHADOW =
"shadow-[inset_0_1px_1px_-0.5px_oklch(1_0_0_/_55%),inset_0_-1.5px_1px_-1px_oklch(1_0_0_/_30%),inset_0_-6px_12px_-8px_oklch(0_0_0_/_12%),0_2px_10px_-6px_oklch(0_0_0_/_7%),0_10px_40px_-16px_oklch(0_0_0_/_16%)]";
const SURFACE_CLASSES: Record<SeekSurface, string> = {
ghost: "hover:bg-muted hover:text-foreground dark:hover:bg-muted/50",
glass: `bg-background/25 backdrop-blur-md backdrop-saturate-150 inset-ring-1 inset-ring-foreground/12 hover:bg-background/40 dark:bg-foreground/10 dark:inset-ring-foreground/20 dark:hover:bg-foreground/16 ${GLASS_SHADOW}`,
frosted: `bg-ericts-media-control/30 text-ericts-media-control-foreground backdrop-blur-[16px] backdrop-saturate-150 inset-ring-1 inset-ring-ericts-media-control-foreground/20 hover:bg-ericts-media-control/45 ${GLASS_SHADOW}`,
};
/**
* Masks the lens ring to the rim: the middle of the disc keeps the surface's
* own blur, and only the outer band gets the second pass.
*/
const LENS_MASK =
"radial-gradient(closest-side, transparent 58%, currentColor 96%)";
/**
* Two specular arcs, a bright one and a faint one, on opposite sides of the
* disc. Off-axis (`from 140deg`) so neither lands square on the glyph.
*
* It does not move. The pane is bolted to the page, so the light on it is too —
* only the glyph behind the glass turns. An earlier version drifted the sheen
* with the spin at a fraction of its speed, for parallax; what it actually read
* as was the whole button rotating, which is the one thing a fixed surface must
* never look like it is doing.
*/
const SHEEN_GRADIENT =
"conic-gradient(from 140deg, transparent 0deg, oklch(1 0 0 / 55%) 55deg, transparent 140deg, transparent 215deg, oklch(1 0 0 / 30%) 285deg, transparent 350deg)";
/**
* Rotation the glyph should animate to for the next press. Each press adds a
* whole turn to the previous *target* rather than restarting from zero, so a
* burst of taps keeps spinning from wherever the glyph currently is.
*/
export function nextSeekRotation(rotation: number, direction: SeekDirection) {
return rotation + (direction === "backward" ? -TURN_DEGREES : TURN_DEGREES);
}
export type SeekIconProps = Omit<React.ComponentProps<"svg">, "children"> & {
direction?: SeekDirection;
};
/** The default glyph: an open ring with an arrowhead at its leading end. */
export function SeekIcon({
direction = "forward",
className,
...props
}: SeekIconProps) {
return (
<svg
viewBox={`0 0 ${ICON_VIEW_BOX} ${ICON_VIEW_BOX}`}
fill="none"
className={cn("stroke-current", className)}
{...props}
>
{/* Backward is the same geometry mirrored, so the two directions cannot
drift apart as the ring is tuned. */}
<g
transform={
direction === "backward"
? `translate(${ICON_VIEW_BOX} 0) scale(-1 1)`
: undefined
}
>
<path d={RING_PATH} strokeWidth={2.5} strokeLinecap="round" />
<path d={ARROW_PATH} className="fill-current" strokeWidth={0} />
</g>
</svg>
);
}
export type SeekButtonProps = Omit<
React.ComponentProps<"button">,
"children"
> & {
/** Which way the seek runs: the glyph, its spin, and `onSeek`'s sign all follow it. */
direction?: SeekDirection;
/** Seek amount in seconds, printed inside the ring and signed into `onSeek`. */
seconds?: number;
/** Print `seconds` inside the ring. Turn off when a custom icon carries its own. */
showSeconds?: boolean;
/** Replaces the default ring glyph. Whatever is passed spins on press. */
icon?: React.ReactNode;
/** Called with the signed offset in seconds: `+seconds` forward, `-seconds` back. */
onSeek?: (seconds: number) => void;
/** Diameter in px, also published as `--seek-button-size` for CSS overrides. */
size?: number;
/** `glass` is a translucent disc, `ghost` is bare, `frosted` is over-video glass. */
surface?: SeekSurface;
/** Transition for the one-turn spin. */
transition?: Transition;
};
export function SeekButton({
direction = "forward",
seconds = 10,
showSeconds = true,
icon,
onSeek,
size = SURFACE_SIZE,
surface = "glass",
transition = SPIN_TRANSITION,
className,
style,
type = "button",
disabled,
onClick,
"aria-label": ariaLabel,
...props
}: SeekButtonProps) {
const shouldReduceMotion = useReducedMotion();
const rotation = useMotionValue(0);
// The target is tracked apart from the motion value: mid-spin the value sits
// between turns, and stacking the next press on it would cut the turn short.
const targetRotation = React.useRef(0);
const spinRef = React.useRef<ReturnType<typeof animate> | null>(null);
React.useEffect(() => () => spinRef.current?.stop(), []);
React.useEffect(() => {
if (!shouldReduceMotion) return;
spinRef.current?.stop();
spinRef.current = null;
targetRotation.current = 0;
rotation.set(0);
}, [shouldReduceMotion, rotation]);
const handleClick = React.useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
if (event.defaultPrevented || disabled) return;
if (!shouldReduceMotion) {
targetRotation.current = nextSeekRotation(
targetRotation.current,
direction,
);
spinRef.current?.stop();
spinRef.current = animate(rotation, targetRotation.current, transition);
}
onSeek?.(direction === "backward" ? -seconds : seconds);
},
[
direction,
disabled,
onClick,
onSeek,
rotation,
seconds,
shouldReduceMotion,
transition,
],
);
const amount = String(seconds);
const glass = surface !== "ghost";
return (
<button
type={type}
disabled={disabled}
onClick={handleClick}
aria-label={
ariaLabel ??
`${direction === "backward" ? "Back" : "Forward"} ${amount} seconds`
}
data-slot="seek-button"
data-direction={direction}
style={{
["--seek-button-size" as string]: `${size}px`,
width: "var(--seek-button-size)",
height: "var(--seek-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 spin has visibly started.
"transition-[background-color,transform] duration-100 ease-out",
"active:scale-[0.94] disabled:pointer-events-none disabled:opacity-50",
"focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current",
"motion-reduce:transition-none motion-reduce:active:scale-100",
SURFACE_CLASSES[surface],
className,
)}
{...props}
>
{glass ? (
<>
<span
aria-hidden
data-slot="seek-button-lens"
className="pointer-events-none absolute inset-0 rounded-full backdrop-blur-[5px]"
style={{ maskImage: LENS_MASK, WebkitMaskImage: LENS_MASK }}
/>
<span
aria-hidden
data-slot="seek-button-sheen"
className="pointer-events-none absolute inset-0 rounded-full opacity-55 dark:opacity-40"
style={{ backgroundImage: SHEEN_GRADIENT }}
/>
</>
) : null}
<motion.span
aria-hidden
data-slot="seek-button-icon"
className="col-start-1 row-start-1 grid place-items-center [&>svg]:size-full"
style={{
width: ICON_RATIO,
height: ICON_RATIO,
rotate: rotation,
}}
>
{icon ?? <SeekIcon direction={direction} />}
</motion.span>
{showSeconds ? (
// Outside the spinning element on purpose: the ring turns, the number
// it is turning by stays upright and readable the whole way round.
<svg
aria-hidden
data-slot="seek-button-seconds"
viewBox={`0 0 ${ICON_VIEW_BOX} ${ICON_VIEW_BOX}`}
className="pointer-events-none col-start-1 row-start-1 fill-current font-semibold tabular-nums"
style={{ width: ICON_RATIO, height: ICON_RATIO }}
>
<text
x={CENTER}
y={CENTER}
textAnchor="middle"
dominantBaseline="central"
fontSize={
amount.length > 2 ? AMOUNT_FONT_SIZE_WIDE : AMOUNT_FONT_SIZE
}
>
{amount}
</text>
</svg>
) : null}
</button>
);
}