An interactive cubic-bezier editor: drag the two control handles on the SVG plot with arrow-key nudges, watch the preview ball run the curve against a linear ghost, load the house motion tokens from the preset row, and copy the resulting cubic-bezier().
npx shadcn@latest add @paragon/easing-editorAlso installs: copy-button, number-scrubber
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { CopyButton } from "@/registry/paragon/ui/copy-button";
import { NumberScrubber } from "@/registry/paragon/ui/number-scrubber";
import { cn } from "@/lib/utils";
/** A cubic-bezier easing as [x1, y1, x2, y2]. */
export type EasingValue = [number, number, number, number];
/** The house motion tokens, as presets. */
export const EASING_PRESETS: { name: string; token: string; value: EasingValue }[] = [
{ name: "Out", token: "--ease-out", value: [0.22, 1, 0.36, 1] },
{ name: "In-Out", token: "--ease-in-out", value: [0.77, 0, 0.175, 1] },
{ name: "Exit", token: "--ease-exit", value: [0.4, 0, 1, 1] },
{ name: "Drawer", token: "--ease-drawer", value: [0.32, 0.72, 0, 1] },
{ name: "Bounce", token: "--ease-bounce", value: [0.34, 1.36, 0.64, 1] },
];
const fmt = (n: number) => String(Number(n.toFixed(3)));
/** Serialize an easing value as a CSS `cubic-bezier(...)` string. */
export function easingToCss(value: EasingValue): string {
return `cubic-bezier(${value.map(fmt).join(", ")})`;
}
const clamp = (v: number, min: number, max: number) =>
Math.min(max, Math.max(min, v));
/* Plot geometry: a 200×200 viewBox. Time spans x 16→184; progress 0→1
* spans y 150→50, leaving symmetric overshoot room for y ∈ [-0.42, 1.42]
* (the bounce token peaks at 1.36). All coordinates are computed. */
const X0 = 16;
const XW = 168;
const Y0 = 150;
const YH = 100;
const Y_MIN = -0.42;
const Y_MAX = 1.42;
const sx = (t: number) => X0 + t * XW;
const sy = (p: number) => Y0 - p * YH;
/* The ball travels its container's width via cqw units, so the keyframes
* stay static (dedup'd) while duration and timing-function change live —
* editing the curve retargets the running loop instead of restarting it.
* The 0→75% leg is the run; 75→100% holds at the end before looping. */
const easingStyles = `
@keyframes pg-easing-run {
0% { translate: 0 0; }
75%, 100% { translate: calc(100cqw - 100%) 0; }
}
@media (prefers-reduced-motion: reduce) {
[data-pg-easing-dot] { animation: none; }
}
`;
/** One draggable control point with keyboard parity. */
function Handle({
label,
x,
y,
onMove,
onSvgPoint,
disabled,
}: {
label: string;
x: number;
y: number;
onMove: (dx: number, dy: number) => void;
onSvgPoint: (clientX: number, clientY: number) => void;
disabled?: boolean;
}) {
const [dragging, setDragging] = React.useState(false);
return (
<g
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={label}
aria-valuenow={Math.round(y * 100) / 100}
aria-valuetext={`x ${fmt(x)}, y ${fmt(y)}`}
aria-disabled={disabled || undefined}
onPointerDown={(event) => {
if (disabled || event.button !== 0) return;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
(event.currentTarget as SVGGElement).focus();
setDragging(true);
onSvgPoint(event.clientX, event.clientY);
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
onSvgPoint(event.clientX, event.clientY);
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
event.currentTarget.releasePointerCapture(event.pointerId);
setDragging(false);
}}
onPointerCancel={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
event.currentTarget.releasePointerCapture(event.pointerId);
setDragging(false);
}}
onKeyDown={(event) => {
if (disabled) return;
const step = event.shiftKey ? 0.1 : 0.01;
const map: Record<string, [number, number]> = {
ArrowRight: [step, 0],
ArrowLeft: [-step, 0],
ArrowUp: [0, step],
ArrowDown: [0, -step],
};
if (event.key in map) {
event.preventDefault();
onMove(map[event.key][0], map[event.key][1]);
}
}}
className={cn(
"touch-none outline-none select-none",
dragging ? "cursor-grabbing" : "cursor-grab",
)}
>
{/* Invisible hit ring keeps the target comfortably large. */}
<circle cx={sx(x)} cy={sy(y)} r={16} fill="transparent" />
<circle
cx={sx(x)}
cy={sy(y)}
r={dragging ? 7 : 6}
className="fill-primary stroke-background transition-[r] duration-100 ease-out"
strokeWidth={2}
/>
</g>
);
}
export interface EasingEditorProps
extends Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> {
/** Controlled easing as [x1, y1, x2, y2]. */
value?: EasingValue;
/** Initial easing when uncontrolled. Defaults to the house ease-out. */
defaultValue?: EasingValue;
onValueChange?: (value: EasingValue, css: string) => void;
/** Initial preview loop duration in ms. */
defaultDuration?: number;
/** Hide the house-token preset row. */
showPresets?: boolean;
disabled?: boolean;
}
/**
* An interactive cubic-bezier editor: drag the two control handles on the
* SVG plot (pointer-captured, arrow keys nudge ±0.01 and Shift ±0.1), watch
* the preview ball run the curve against a linear ghost, load the house
* motion tokens from the preset row, and copy the resulting cubic-bezier().
*/
export function EasingEditor({
value: valueProp,
defaultValue = [0.22, 1, 0.36, 1],
onValueChange,
defaultDuration = 1000,
showPresets = true,
disabled = false,
className,
...props
}: EasingEditorProps) {
const [uncontrolled, setUncontrolled] = React.useState<EasingValue>(defaultValue);
const value = valueProp ?? uncontrolled;
const valueRef = React.useRef(value);
valueRef.current = value;
const [duration, setDuration] = React.useState(defaultDuration);
const svgRef = React.useRef<SVGSVGElement>(null);
const trackRef = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion() ?? false;
// Loop animations pause offscreen.
const inView = useInView(trackRef, { amount: 0.1 });
const [x1, y1, x2, y2] = value;
const css = easingToCss(value);
const setValue = React.useCallback(
(next: EasingValue) => {
const clamped: EasingValue = [
clamp(Number(next[0].toFixed(3)), 0, 1),
clamp(Number(next[1].toFixed(3)), Y_MIN, Y_MAX),
clamp(Number(next[2].toFixed(3)), 0, 1),
clamp(Number(next[3].toFixed(3)), Y_MIN, Y_MAX),
];
if (clamped.every((n, i) => n === valueRef.current[i])) return;
if (valueProp === undefined) setUncontrolled(clamped);
onValueChange?.(clamped, easingToCss(clamped));
},
[valueProp, onValueChange],
);
/** Client coords → plot units, via the rendered viewBox scale. */
const pointFromClient = (clientX: number, clientY: number) => {
const svg = svgRef.current;
if (!svg) return null;
const rect = svg.getBoundingClientRect();
const px = ((clientX - rect.left) / rect.width) * 200;
const py = ((clientY - rect.top) / rect.height) * 200;
return { x: (px - X0) / XW, y: (Y0 - py) / YH };
};
const dragHandle = (index: 0 | 1) => (clientX: number, clientY: number) => {
const point = pointFromClient(clientX, clientY);
if (!point) return;
const next: EasingValue = [...valueRef.current];
next[index === 0 ? 0 : 2] = point.x;
next[index === 0 ? 1 : 3] = point.y;
setValue(next);
};
const nudgeHandle = (index: 0 | 1) => (dx: number, dy: number) => {
const next: EasingValue = [...valueRef.current];
next[index === 0 ? 0 : 2] += dx;
next[index === 0 ? 1 : 3] += dy;
setValue(next);
};
const curvePath = `M ${X0} ${Y0} C ${sx(x1)} ${sy(y1)}, ${sx(x2)} ${sy(y2)}, ${sx(1)} ${sy(1)}`;
const dotStyle = (timing: string): React.CSSProperties =>
reduced
? { translate: "calc(100cqw - 100%) 0" }
: {
animationName: "pg-easing-run",
// The run occupies 75% of the loop; the rest holds at the end.
animationDuration: `${Math.round((duration * 4) / 3)}ms`,
animationTimingFunction: timing,
animationIterationCount: "infinite",
animationPlayState: inView && !disabled ? "running" : "paused",
};
const activePreset = EASING_PRESETS.find((preset) =>
preset.value.every((n, i) => Math.abs(n - value[i]) < 0.0005),
);
return (
<div
data-slot="easing-editor"
className={cn(
"flex w-72 flex-col gap-2.5",
disabled && "pointer-events-none opacity-50",
className,
)}
{...props}
>
<style href="paragon-easing-editor" precedence="paragon">
{easingStyles}
</style>
{/* Curve plot */}
<svg
ref={svgRef}
viewBox="0 0 200 200"
aria-label="Cubic bezier curve"
className="w-full rounded-lg bg-card shadow-border select-none"
>
{/* Unit square + reference lines — computed, never eyeballed. */}
<rect
x={X0}
y={sy(1)}
width={XW}
height={YH}
className="fill-secondary/40 stroke-border"
strokeWidth={1}
vectorEffect="non-scaling-stroke"
/>
<line
x1={X0}
y1={Y0}
x2={sx(1)}
y2={sy(1)}
strokeDasharray="3 4"
vectorEffect="non-scaling-stroke"
className="stroke-muted-foreground/35"
strokeWidth={1}
/>
{/* Control arms */}
<line
x1={X0}
y1={Y0}
x2={sx(x1)}
y2={sy(y1)}
className="stroke-primary/40"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
<line
x1={sx(1)}
y1={sy(1)}
x2={sx(x2)}
y2={sy(y2)}
className="stroke-primary/40"
strokeWidth={1.5}
vectorEffect="non-scaling-stroke"
/>
{/* The curve */}
<path
d={curvePath}
fill="none"
strokeWidth={2}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
className="stroke-primary"
/>
{/* Anchors */}
<rect x={X0 - 3} y={Y0 - 3} width={6} height={6} rx={1.5} className="fill-foreground/70" />
<rect x={sx(1) - 3} y={sy(1) - 3} width={6} height={6} rx={1.5} className="fill-foreground/70" />
<Handle
label="Start control point"
x={x1}
y={y1}
disabled={disabled}
onSvgPoint={dragHandle(0)}
onMove={nudgeHandle(0)}
/>
<Handle
label="End control point"
x={x2}
y={y2}
disabled={disabled}
onSvgPoint={dragHandle(1)}
onMove={nudgeHandle(1)}
/>
</svg>
{/* House token presets */}
{showPresets && (
<div role="group" aria-label="Easing presets" className="flex gap-1">
{EASING_PRESETS.map((preset) => {
const active = preset === activePreset;
const [px1, py1, px2, py2] = preset.value;
// Thumbnail curve in a 20×14 box: x 2→18, y 11→3.
const tx = (t: number) => 2 + t * 16;
const ty = (p: number) => 11 - p * 8;
return (
<button
key={preset.name}
type="button"
aria-pressed={active}
title={`var(${preset.token})`}
disabled={disabled}
onClick={() => setValue([...preset.value])}
className={cn(
"pressable flex h-8 min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-md",
"transition-[background-color,color,box-shadow] duration-150 ease-out",
"outline-none focus-visible:ring-2 focus-visible:ring-ring",
active
? "bg-secondary text-foreground shadow-border"
: "text-muted-foreground hover:bg-secondary/60 hover:text-foreground",
)}
>
<svg viewBox="0 0 20 14" className="h-3.5 w-5" aria-hidden>
<path
d={`M ${tx(0)} ${ty(0)} C ${tx(px1)} ${ty(py1)}, ${tx(px2)} ${ty(py2)}, ${tx(1)} ${ty(1)}`}
fill="none"
strokeWidth={1.5}
strokeLinecap="round"
vectorEffect="non-scaling-stroke"
className="stroke-current"
/>
</svg>
<span className="w-full truncate px-1 text-center text-[9px] leading-none font-medium">
{preset.name}
</span>
</button>
);
})}
</div>
)}
{/* Live preview: the curve vs a linear ghost */}
<div className="flex items-center gap-2">
<div
className="flex h-9 min-w-0 flex-1 items-center rounded-lg bg-secondary/60 px-2"
aria-hidden
>
<div
ref={trackRef}
className="relative h-full min-w-0 flex-1"
style={{ containerType: "inline-size" }}
>
<span
data-pg-easing-dot
className="absolute top-1/2 left-0 -mt-1.5 size-3 rounded-full bg-primary"
style={dotStyle(css)}
/>
<span
data-pg-easing-dot
className="absolute top-1/2 left-0 -mt-1 size-2 rounded-full bg-muted-foreground/40"
style={dotStyle("linear")}
/>
</div>
</div>
<NumberScrubber
label="Time"
value={duration}
onValueChange={setDuration}
min={100}
max={3000}
step={50}
unit="ms"
disabled={disabled}
className="w-30 shrink-0"
/>
</div>
{/* CSS output */}
<div className="flex h-8 items-center gap-1 rounded-md bg-secondary/60 pl-2.5">
<code
title={css}
className="min-w-0 flex-1 truncate font-mono text-[11px] text-foreground/90 tabular-nums"
>
{css}
</code>
<CopyButton value={css} aria-label="Copy cubic-bezier CSS" />
</div>
</div>
);
}