{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "curve-editor",
  "type": "registry:ui",
  "title": "Curve editor",
  "description": "An easing shaped by hand, as a cubic Bézier: two round caps pull the curve from its start and its end, on a well that shows progress from -0.25 to 1.25. It writes the curve as CSS, says what it feels like in words, and a dot crosses a rail on it when you let go.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/curve-editor.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/curve-editor 2026.10.27. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Curve editor: an easing shaped by hand, as a cubic Bézier. Two handles pull the curve from its start and its end,\n * as in cubic-bezier(x1, y1, x2, y2), and the curve, a line of words and a dot on a rail follow.\n *\n * - Surface: the Pad's well, the cut (R3): --litt-cut inside the --litt-line-control line with --litt-cut-shade, at the surface\n *   radius. Time runs left to right from 0 to 1, progress bottom to top. The floor shows progress from -0.25 to 1.25,\n *   so a curve that pulls back or passes its end still fits: a --litt-line-strong baseline at 0, a dashed --litt-line-strong\n *   target at 1, and a dashed --litt-line-strong diagonal for linear, all static chrome inside the cap's 14px inset.\n * - Curve: --litt-fg at 1.5px from the start (bottom left) to the end (top right), each end a 6px dot in --litt-fg-muted. A\n *   1px --litt-fg-muted arm runs from the start to the first handle and from the end to the second, as every Bézier tool\n *   draws them, so you can see which handle pulls which end. defaultMark draws its curve under it, dashed in\n *   --litt-fg-subtle.\n * - Handles: the Pad's round cap, 20px (--litt-cap, --litt-cap-lift); held, --litt-cap-held and --litt-cap-press and it sinks\n *   --litt-press-depth. Never the accent (R6), never a new size.\n * - It says its value (R5): the curve as CSS under the well, in mono because it is code, and what it feels like in\n *   words beside it (\"Starts quick and eases into place\"). The words are every input's aria-valuetext too.\n * - Preview: the Spring editor's rail, with the dot crossing on this easing for `duration`. It plays by itself when a\n *   change is committed and crosses back the next time. It is the one place this component moves, on the person's\n *   own curve, because the preview is the point. Reduced motion lands it at once.\n * - Pointer: a press on a handle takes it where it was taken, so it never jumps under the finger; a press anywhere\n *   else sends the nearer handle there. The handle follows one to one; values snap to 0.01. Double-click goes back to\n *   defaultMark. data-no-drag and touch-action none: the whole well is the target.\n * - Keyboard: each handle is one tab stop with two visually hidden range inputs (time in the tab order, progress\n *   tabindex -1), as on the Pad. ← → move the handle in time, ↑ ↓ in progress, Shift makes it 10 steps, Page Up and\n *   Page Down move progress by 10 steps, Home and End take time to its ends.\n * - Disabled: everything lies flat, as the Pad's, with the curve in --litt-fg-disabled. The CSS and the words step down to\n *   --litt-fg-subtle, as the Slider's value does, because they are still read.\n * - Motion: the handles, the arms and the curve follow the pointer and the keys at once, with no easing (R4).\n */\nimport { useId, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type PointerEvent, type ReactNode } from \"react\";\nimport { motion, type Transition } from \"motion/react\";\nimport { useMotionDial, useTransition } from \"../../lib/litt/motion\";\nimport { LIMITS } from \"../../lib/litt/motion/rules\";\nimport { Button } from \"./button\";\nimport { PlayIcon } from \"./icons\";\n\n/** An easing as the four numbers of cubic-bezier(x1, y1, x2, y2). x stays between 0 and 1. */\nexport type CubicBezier = [x1: number, y1: number, x2: number, y2: number];\n\nexport type CurveEditorProps = {\n  /** The label above the editor. Required: it names the group and describes every input for screen readers. */\n  label: ReactNode;\n  /** Help text under the editor, linked to every input with aria-describedby. */\n  help?: ReactNode;\n  /** The curve, when you control it, as [x1, y1, x2, y2]. Pair it with onValueChange. */\n  value?: CubicBezier;\n  /** The curve it starts at when you do not control it. Defaults to [0.25, 0.1, 0.25, 1]. */\n  defaultValue?: CubicBezier;\n  /** Called with the new curve on every move of a handle, by pointer or key. */\n  onValueChange?: (value: CubicBezier) => void;\n  /** Called with the final curve when a drag is released, or right after a key moves a handle. Use it to save. */\n  onValueCommit?: (value: CubicBezier) => void;\n  /** How long the preview's dot takes to cross, in ms. Defaults to 600, the longest a litt motion may take to settle. */\n  duration?: number;\n  /** Draws this curve dashed under the one being edited, so people can see where Reset goes. Double-click the well to go back to it. */\n  defaultMark?: CubicBezier;\n  /** Shows the rail under the well where a dot crosses on the curve, with a Play button. On by default. */\n  preview?: boolean;\n  /** Height of the well in px. The width is the container's. */\n  height?: number;\n  /** Makes the editor unavailable: muted, skipped by Tab, and ignoring the pointer. */\n  disabled?: boolean;\n  /** Extra classes for the wrapper, such as a width. */\n  className?: string;\n};\n\n/* ----------------------------------------------------------------- geometry */\n\nconst INSET = 14;\nconst GRAB = 20; // a press this close to a handle's centre takes the handle where it is\nconst STEP = 0.01;\nconst Y_MIN = -0.25;\nconst Y_MAX = 1.25;\nconst VIEW = 1000;\nconst HANDLES = [\"Start handle\", \"End handle\"] as const;\n\nconst clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));\nconst snap = (raw: number, lo: number, hi: number) => Number(clamp(Math.round(raw / STEP) * STEP, lo, hi).toFixed(2));\nconst along = (f: number) => `calc(${INSET}px + (100% - ${2 * INSET}px) * ${f})`;\n/** Where a progress value sits from the top, as a fraction of the inset box. */\nconst fromTop = (y: number) => (Y_MAX - y) / (Y_MAX - Y_MIN);\nconst num = (v: number) => String(+v.toFixed(2));\n\n/** The curve as CSS: cubic-bezier(0.25, 0.1, 0.25, 1). */\nexport const toCubicBezier = ([x1, y1, x2, y2]: CubicBezier) => `cubic-bezier(${num(x1)}, ${num(y1)}, ${num(x2)}, ${num(y2)})`;\n\n/** What a curve feels like, in words, from where its handles pull. */\nexport function describeCurve([x1, y1, x2, y2]: CubicBezier): string {\n  if (y1 < 0 && y2 > 1) return \"Pulls back, then passes the end and settles\";\n  if (y1 < 0) return \"Pulls back, then goes\";\n  if (y2 > 1 || y1 > 1) return \"Passes the end and settles back\";\n  const start = x1 === 0 ? Infinity : y1 / x1;\n  const end = x2 === 1 ? Infinity : (1 - y2) / (1 - x2);\n  const quick = (s: number) => s > 1.5;\n  const slow = (s: number) => s < 0.67;\n  if (quick(start) && slow(end)) return \"Starts quick and eases into place\";\n  if (slow(start) && quick(end)) return \"Starts slow and speeds up\";\n  if (slow(start) && slow(end)) return \"Slow at both ends, quick in the middle\";\n  if (quick(start) && quick(end)) return \"Quick at both ends, slow in the middle\";\n  if (Math.abs(start - end) < 0.25) return \"Even, close to linear\";\n  return start > end ? \"Eases out gently\" : \"Eases in gently\";\n}\n\nconst point = (x: number, y: number) => `${(x * VIEW).toFixed(1)} ${(fromTop(y) * VIEW).toFixed(1)}`;\nconst pathOf = ([x1, y1, x2, y2]: CubicBezier) => `M${point(0, 0)}C${point(x1, y1)} ${point(x2, y2)} ${point(1, 1)}`;\n\n/* ----------------------------------------------------------------- styles */\n\nconst well = \"relative w-full touch-none select-none overflow-hidden rounded-litt-surface forced-colors:border \";\nconst wellOn = \"bg-litt-cut shadow-[inset_0_0_0_1px_var(--litt-line-control),var(--litt-cut-shade)]\";\nconst wellOff = \"bg-litt-surface-2 shadow-[inset_0_0_0_1px_var(--litt-line)]\";\nconst thumb = \"pointer-events-none absolute block size-5 -translate-x-1/2 rounded-litt-pill forced-colors:border \";\nconst thumbUp = \"-translate-y-1/2 bg-litt-cap shadow-(--litt-cap-lift) forced-colors:bg-[Highlight]\";\nconst thumbHeld = \"translate-y-[calc(var(--litt-press-depth)-50%)] bg-litt-cap-held shadow-(--litt-cap-press) forced-colors:bg-[Highlight]\";\nconst thumbOff = \"-translate-y-1/2 bg-litt-surface-2 shadow-[inset_0_0_0_1px_var(--litt-line)]\";\nconst end = \"pointer-events-none absolute size-1.5 -translate-x-1/2 -translate-y-1/2 rounded-litt-pill \";\n\n/* ----------------------------------------------------------------- the preview */\n\nfunction Preview({ curve, duration, run, disabled }: { curve: CubicBezier; duration: number; run: number; disabled?: boolean }) {\n  const { reduced } = useMotionDial();\n  const still = useTransition(\"instant\");\n  const own: Transition = { type: \"tween\", duration: duration / 1000, ease: curve }; // litt-allow: the preview runs on the curve being edited\n  return (\n    <div aria-hidden=\"true\" className=\"relative h-3 min-w-0 flex-1\">\n      <span className=\"absolute inset-x-0 top-1/2 h-px -translate-y-1/2 bg-litt-line-strong\" />\n      <motion.span\n        className=\"absolute inset-y-0 left-0 w-[calc(100%-12px)]\"\n        initial={false}\n        animate={{ x: run % 2 ? \"100%\" : \"0%\" }}\n        transition={reduced ? still : own}\n      >\n        <span className={\"absolute top-0 left-0 size-3 rounded-litt-pill \" + (disabled ? \"bg-litt-fg-disabled\" : \"bg-litt-fg\")} />\n      </motion.span>\n    </div>\n  );\n}\n\n/* ----------------------------------------------------------------- CurveEditor */\n\n/** An easing drawn by hand: drag the two handles to shape a cubic Bézier, and read it back as CSS and in words. */\nexport function CurveEditor({\n  label,\n  help,\n  value: valueProp,\n  defaultValue,\n  onValueChange,\n  onValueCommit,\n  duration = LIMITS.settleMs,\n  defaultMark,\n  preview = true,\n  height = 220,\n  disabled = false,\n  className = \"\",\n}: CurveEditorProps) {\n  const id = useId().replace(/[^\\w-]/g, \"\");\n  const labelId = `${id}-label`;\n  const helpId = help ? `${id}-help` : undefined;\n  const cssId = `${id}-css`;\n\n  const [inner, setInner] = useState<CubicBezier>(() => defaultValue ?? [0.25, 0.1, 0.25, 1]);\n  const value = valueProp ?? inner;\n  const feel = describeCurve(value);\n  const css = toCubicBezier(value);\n\n  const inputs0 = useRef<HTMLInputElement>(null);\n  const inputs1 = useRef<HTMLInputElement>(null);\n  const [held, setHeld] = useState<0 | 1 | null>(null);\n  const [keys, setKeys] = useState<0 | 1 | null>(null);\n  const [run, setRun] = useState(0);\n  const dragRef = useRef<{ handle: 0 | 1; dx: number; dy: number } | null>(null);\n  const lastRef = useRef<CubicBezier>(value);\n\n  const move = (next: CubicBezier) => {\n    if (next.every((n, i) => n === value[i])) return false;\n    if (valueProp === undefined) setInner(next);\n    onValueChange?.(next);\n    return true;\n  };\n  const commit = (next: CubicBezier) => {\n    onValueCommit?.(next);\n    if (preview) setRun((r) => r + 1);\n  };\n  const place = (curve: CubicBezier, handle: 0 | 1, x: number, y: number): CubicBezier => {\n    const next = [...curve] as CubicBezier;\n    next[handle * 2] = snap(x, 0, 1);\n    next[handle * 2 + 1] = snap(y, Y_MIN, Y_MAX);\n    return next;\n  };\n\n  /* ---- pointer */\n\n  /** The pointer in the curve's units, and each handle's distance from it in px. */\n  const read = (e: PointerEvent<HTMLDivElement>) => {\n    const box = e.currentTarget.getBoundingClientRect();\n    const w = Math.max(1, box.width - 2 * INSET);\n    const h = Math.max(1, box.height - 2 * INSET);\n    const px = e.clientX - box.left - INSET;\n    const py = e.clientY - box.top - INSET;\n    const at = (handle: 0 | 1) => ({ x: value[handle * 2] * w, y: fromTop(value[handle * 2 + 1]) * h });\n    const dist = (handle: 0 | 1) => Math.hypot(at(handle).x - px, at(handle).y - py);\n    return { w, h, px, py, at, dist };\n  };\n\n  const drag = (e: PointerEvent<HTMLDivElement>) => {\n    const grip = dragRef.current;\n    if (!grip) return;\n    const { w, h, px, py } = read(e);\n    const x = (px - grip.dx) / w;\n    const y = Y_MAX - ((py - grip.dy) / h) * (Y_MAX - Y_MIN);\n    const next = place(value, grip.handle, x, y);\n    lastRef.current = next;\n    move(next);\n  };\n\n  const release = () => {\n    if (!dragRef.current) return;\n    dragRef.current = null;\n    setHeld(null);\n    commit(lastRef.current);\n  };\n\n  /* ---- keyboard */\n\n  const onKeyDown = (handle: 0 | 1) => (e: KeyboardEvent<HTMLInputElement>) => {\n    if (disabled || e.altKey || e.ctrlKey || e.metaKey) return;\n    const n = e.shiftKey ? 10 : 1;\n    const x = value[handle * 2];\n    const y = value[handle * 2 + 1];\n    let next: CubicBezier | null = null;\n    switch (e.key) {\n      case \"ArrowLeft\":\n        next = place(value, handle, x - n * STEP, y);\n        break;\n      case \"ArrowRight\":\n        next = place(value, handle, x + n * STEP, y);\n        break;\n      case \"ArrowDown\":\n        next = place(value, handle, x, y - n * STEP);\n        break;\n      case \"ArrowUp\":\n        next = place(value, handle, x, y + n * STEP);\n        break;\n      case \"PageDown\":\n        next = place(value, handle, x, y - 10 * STEP);\n        break;\n      case \"PageUp\":\n        next = place(value, handle, x, y + 10 * STEP);\n        break;\n      case \"Home\":\n        next = place(value, handle, 0, y);\n        break;\n      case \"End\":\n        next = place(value, handle, 1, y);\n        break;\n    }\n    if (!next) return;\n    e.preventDefault();\n    setKeys(handle);\n    if (move(next)) commit(next);\n  };\n\n  const adjust = (handle: 0 | 1, axis: 0 | 1, raw: string) => {\n    const x = axis === 0 ? Number(raw) : value[handle * 2];\n    const y = axis === 1 ? Number(raw) : value[handle * 2 + 1];\n    const next = place(value, handle, x, y);\n    if (move(next)) commit(next);\n  };\n\n  /* ---- what is drawn */\n\n  const describedBy = [labelId, helpId, cssId].filter(Boolean).join(\" \");\n  const ink = disabled ? \"stroke-litt-fg-disabled\" : \"stroke-litt-fg\";\n  const chrome = disabled ? \"stroke-litt-line\" : \"stroke-litt-line-strong\";\n  const arm = disabled ? \"stroke-litt-line\" : \"stroke-litt-fg-muted\";\n\n  const inputsFor = (handle: 0 | 1) => {\n    const name = HANDLES[handle];\n    const x = value[handle * 2];\n    const y = value[handle * 2 + 1];\n    const text = `${name} at time ${num(x)}, progress ${num(y)}. ${feel}.`;\n    const shared = {\n      type: \"range\" as const,\n      step: STEP,\n      disabled,\n      \"aria-describedby\": describedBy,\n      \"aria-valuetext\": text,\n      onKeyDown: onKeyDown(handle),\n      onFocus: (e: FocusEvent<HTMLInputElement>) => {\n        if (!dragRef.current) setKeys(e.currentTarget.matches(\":focus-visible\") ? handle : null);\n      },\n      onBlur: () => setKeys(null),\n    };\n    return (\n      <>\n        <input\n          ref={handle === 0 ? inputs0 : inputs1}\n          className=\"sr-only\"\n          {...shared}\n          min={0}\n          max={1}\n          value={x}\n          aria-label={`${name}, time`}\n          onChange={(e: ChangeEvent<HTMLInputElement>) => adjust(handle, 0, e.target.value)}\n        />\n        <input\n          className=\"sr-only\"\n          tabIndex={-1}\n          aria-orientation=\"vertical\"\n          {...shared}\n          min={Y_MIN}\n          max={Y_MAX}\n          value={y}\n          aria-label={`${name}, progress`}\n          onChange={(e: ChangeEvent<HTMLInputElement>) => adjust(handle, 1, e.target.value)}\n        />\n      </>\n    );\n  };\n\n  const [x1, y1, x2, y2] = value;\n\n  return (\n    <div className={[\"grid gap-2\", className].join(\" \")}>\n      <span id={labelId} className={\"text-litt-small font-litt-title \" + (disabled ? \"text-litt-fg-disabled\" : \"text-litt-fg\")}>\n        {label}\n      </span>\n      <div role=\"group\" aria-labelledby={labelId} aria-disabled={disabled || undefined}>\n        <div\n          data-no-drag=\"\"\n          className={well + (disabled ? wellOff + \" cursor-not-allowed\" : wellOn + \" cursor-pointer\")}\n          style={{ height }}\n          onMouseDown={(e) => {\n            if (!disabled) e.preventDefault();\n          }}\n          onPointerDown={(e) => {\n            if (disabled || (e.pointerType === \"mouse\" && e.button !== 0)) return;\n            const { px, py, at, dist } = read(e);\n            const handle: 0 | 1 = dist(0) <= dist(1) ? 0 : 1;\n            const near = dist(handle) <= GRAB;\n            const c = at(handle);\n            e.currentTarget.setPointerCapture(e.pointerId);\n            dragRef.current = { handle, dx: near ? px - c.x : 0, dy: near ? py - c.y : 0 };\n            setHeld(handle);\n            setKeys(null);\n            drag(e);\n            (handle === 0 ? inputs0 : inputs1).current?.focus({ preventScroll: true });\n          }}\n          onPointerMove={drag}\n          onPointerUp={release}\n          onPointerCancel={release}\n          onLostPointerCapture={release}\n          onDoubleClick={() => {\n            if (disabled || !defaultMark) return;\n            if (move(defaultMark)) commit(defaultMark);\n          }}\n        >\n          <svg\n            aria-hidden=\"true\"\n            focusable=\"false\"\n            viewBox={`0 0 ${VIEW} ${VIEW}`}\n            preserveAspectRatio=\"none\"\n            className=\"pointer-events-none absolute overflow-visible\"\n            style={{ left: INSET, top: INSET, width: `calc(100% - ${2 * INSET}px)`, height: `calc(100% - ${2 * INSET}px)` }}\n          >\n            <path d={`M0 ${point(0, 0).split(\" \")[1]}H${VIEW}`} className={chrome} strokeWidth={1} vectorEffect=\"non-scaling-stroke\" fill=\"none\" />\n            <path d={`M0 ${point(0, 1).split(\" \")[1]}H${VIEW}`} className={chrome} strokeWidth={1} strokeDasharray=\"2 3\" vectorEffect=\"non-scaling-stroke\" fill=\"none\" />\n            <path d={`M${point(0, 0)}L${point(1, 1)}`} className={chrome} strokeWidth={1} strokeDasharray=\"2 3\" vectorEffect=\"non-scaling-stroke\" fill=\"none\" />\n            {defaultMark && (\n              <path d={pathOf(defaultMark)} className={disabled ? \"stroke-litt-fg-disabled\" : \"stroke-litt-fg-subtle\"} strokeWidth={1} strokeDasharray=\"4 3\" vectorEffect=\"non-scaling-stroke\" fill=\"none\" />\n            )}\n            <path d={`M${point(0, 0)}L${point(x1, y1)}M${point(1, 1)}L${point(x2, y2)}`} className={arm} strokeWidth={1} vectorEffect=\"non-scaling-stroke\" fill=\"none\" />\n            <path d={pathOf(value)} className={ink} strokeWidth={1.5} strokeLinecap=\"round\" vectorEffect=\"non-scaling-stroke\" fill=\"none\" />\n          </svg>\n\n          <span aria-hidden=\"true\" className={end + (disabled ? \"bg-litt-fg-disabled\" : \"bg-litt-fg-muted\")} style={{ left: along(0), top: along(fromTop(0)) }} />\n          <span aria-hidden=\"true\" className={end + (disabled ? \"bg-litt-fg-disabled\" : \"bg-litt-fg-muted\")} style={{ left: along(1), top: along(fromTop(1)) }} />\n\n          {inputsFor(0)}\n          {inputsFor(1)}\n\n          {([0, 1] as const).map((handle) => (\n            <span\n              key={handle}\n              aria-hidden=\"true\"\n              className={thumb + (disabled ? thumbOff : held === handle ? thumbHeld : thumbUp) + (keys === handle ? \" outline-2 outline-offset-2 outline-litt-focus\" : \"\")}\n              style={{ left: along(clamp(value[handle * 2], 0, 1)), top: along(fromTop(clamp(value[handle * 2 + 1], Y_MIN, Y_MAX))) }}\n            />\n          ))}\n        </div>\n      </div>\n\n      <div className=\"flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1\">\n        <code id={cssId} className={\"font-litt-mono text-litt-caption font-normal tabular-nums \" + (disabled ? \"text-litt-fg-subtle\" : \"text-litt-fg\")}>\n          {css}\n        </code>\n        <span className={\"text-litt-small \" + (disabled ? \"text-litt-fg-subtle\" : \"text-litt-fg-muted\")}>{feel}</span>\n      </div>\n\n      {preview && (\n        <div className=\"flex items-center gap-3\">\n          <Button size=\"sm\" variant=\"ghost\" icon={<PlayIcon />} disabled={disabled} onClick={() => setRun((r) => r + 1)} className=\"-ml-3\">\n            Play\n          </Button>\n          <Preview curve={value} duration={duration} run={run} disabled={disabled} />\n        </div>\n      )}\n\n      {help && (\n        <p id={helpId} className=\"text-litt-small text-litt-fg-subtle\">\n          {help}\n        </p>\n      )}\n    </div>\n  );\n}\n",
      "target": "components/litt/curve-editor.tsx"
    }
  ],
  "docs": "Import CurveEditor from components/litt/curve-editor. Give it a label and hold the curve ([x1, y1, x2, y2]) in state with value and onValueChange; toCubicBezier writes it as CSS. Pass defaultMark to show the default and duration for the preview. Each handle is one tab stop with two range inputs.",
  "meta": {
    "version": "2026.10.27",
    "released": "2026-10-27",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
