Easing Editor
Inputs & Forms

Easing Editor

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().

Install

npx shadcn@latest add @paragon/easing-editor

Also installs: copy-button, number-scrubber

easing-editor.tsx

"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>
  );
}