Skip to content

Interactive Bezier Drawer

Two draggable control points on a real curve chart, with the CSS value printed as you move them. Overshoot is allowed rather than clamped, and the preview dot runs on the exported curve itself.

270

What you should hear: Tab reaches the control — and every state change is announced in the same words the page shows.

MIT licensev1.0.02.9 KB gzipzero dependenciesby Motif Studioadded 2026-09-10review due 2026-12-09
Live playground — drag, click, hoversandboxed previewsample copy

Cubic-bezier drawer

cubic-bezier(0.16, 1.00, 0.30, 1.00)

Preview

the dot uses the curve above, not a copy of it
Probe t = 0.00y = 0.000
Easing Lab has the full chart →

Drag a handle, or focus one and use the arrow keys — shift moves ten times as far.

Handles may go outside the box, because overshoot is a property of the curve rather than a mistake to clamp away. The preview dot runs on a real CSS transition with the value above, and the Lab's chart is the same maths with a bigger canvas — the two never disagree because they share these helpers.

Ambience
vibes

Ambience is a live light wash over the preview. Full palette re-theming of every demo arrives with Theme Studio (Pro).

deps: zeroJSX + TailwindMIT · original
bezier-drawer.tsx
// Interactive Bezier Drawer — the demo's own source, extracted verbatim.
// From src/components/demos/scenes-17.tsx, export BezierDrawer.
// Needs React 18+ and Tailwind CSS v4. The classes below include this site's
// token utilities (bg-panel, text-ink-dim, chip …); their values are served as
// CSS custom properties at /api/exports/tokens.json. The reduced-motion branch
// is part of the source, not an afterthought.
// 4 helpers the demo imports are inlined above it, verbatim.

import { useEffect, useRef, useState } from "react";

function useReducedMotion() {
  const [reduced, setReduced] = useState<boolean>(() =>
    typeof window !== "undefined" ? window.matchMedia("(prefers-reduced-motion: reduce)").matches : false
  );
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const on = (e: MediaQueryListEvent) => setReduced(e.matches);
    mq.addEventListener("change", on);
    return () => mq.removeEventListener("change", on);
  }, []);
  return reduced;
}

function curvePoint(x1: number, y1: number, x2: number, y2: number, t: number) {
  const u = 1 - t;
  return {
    x: 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t,
    y: 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t,
  };
}

function curveYAt(x1: number, y1: number, x2: number, y2: number, targetX: number) {
  let best = 0;
  let bestErr = Infinity;
  for (let i = 0; i <= 600; i++) {
    const t = i / 600;
    const { x, y } = curvePoint(x1, y1, x2, y2, t);
    const err = Math.abs(x - targetX);
    if (err < bestErr) {
      bestErr = err;
      best = y;
    }
  }
  return best;
}

const BEZIER_PRESETS = [
  { name: "ease-out-expo", x1: 0.16, y1: 1, x2: 0.3, y2: 1 },
  { name: "back-out", x1: 0.34, y1: 1.56, x2: 0.64, y2: 1 },
  { name: "ease-in-out-quart", x1: 0.76, y1: 0, x2: 0.24, y2: 1 },
];

export function BezierDrawer() {
  const [[x1, y1], setP1] = useState<[number, number]>([0.16, 1]);
  const [[x2, y2], setP2] = useState<[number, number]>([0.3, 1]);
  const [dragging, setDragging] = useState<1 | 2 | null>(null);
  const [t, setT] = useState(0);
  const svgRef = useRef<SVGSVGElement>(null);
  const reduced = useReducedMotion();

  // y is unclamped on purpose: overshoot is a real property of a curve, and the
  // chart pads to show it rather than cropping the thing you are drawing.
  const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
  const pad = 34;
  const size = 200;
  const toSvg = (clientX: number, clientY: number) => {
    const svg = svgRef.current;
    if (!svg) return null;
    const r = svg.getBoundingClientRect();
    const x = ((clientX - r.left) / r.width) * (size + pad * 2) - pad;
    const y = ((clientY - r.top) / r.height) * (size + pad * 2) - pad;
    return { x: clamp(x / size, -0.4, 1.4), y: clamp(1 - y / size, -0.6, 1.6) };
  };
  const toPx = (x: number, y: number) => ({ px: x * size, py: (1 - y) * size });
  const css = `cubic-bezier(${x1.toFixed(2)}, ${y1.toFixed(2)}, ${x2.toFixed(2)}, ${y2.toFixed(2)})`;

  const setPoint = (which: 1 | 2, x: number, y: number) => {
    if (which === 1) setP1([x, y]);
    else setP2([x, y]);
  };

  const nudge = (e: React.KeyboardEvent, which: 1 | 2) => {
    const step = e.shiftKey ? 0.1 : 0.01;
    const cur = which === 1 ? [x1, y1] : [x2, y2];
    let [nx, ny] = cur;
    if (e.key === "ArrowLeft") nx -= step;
    else if (e.key === "ArrowRight") nx += step;
    else if (e.key === "ArrowUp") ny += step;
    else if (e.key === "ArrowDown") ny -= step;
    else return;
    e.preventDefault();
    setPoint(which, clamp(nx, -0.4, 1.4), clamp(ny, -0.6, 1.6));
  };

  // The preview dot is moved by the browser's own animation of the exported
  // curve — not by a script sampling the same maths a second time. Restarting
  // it is an external side effect on a DOM node, so no state is involved.
  const [runKey, setRunKey] = useState(0);
  const dotRef = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    const dot = dotRef.current;
    if (!dot) return;
    if (reduced) {
      dot.style.transform = "translateX(152px)";
      return;
    }
    dot.style.transform = "translateX(0px)";
    const animation = dot.animate(
      [{ transform: "translateX(0px)" }, { transform: "translateX(152px)" }],
      { duration: 900, easing: css, fill: "forwards" }
    );
    return () => animation.cancel();
  }, [css, runKey, reduced]);

  const path = (() => {
    const steps = 40;
    let d = `M 0 ${size}`;
    for (let i = 1; i <= steps; i++) {
      const p = curvePoint(x1, y1, x2, y2, i / steps);
      d += ` L ${(p.x * size).toFixed(1)} ${(size - p.y * size).toFixed(1)}`;
    }
    return d;
  })();
  const pA = toPx(x1, y1);
  const pB = toPx(x2, y2);
  const y = t >= 0 ? curveYAt(x1, y1, x2, y2, t) : 0;

  return (
    <div className="flex h-full w-full flex-col items-center justify-center bg-[radial-gradient(70%_90%_at_50%_0%,rgba(139,92,246,0.12),transparent_60%),#08090f] px-6 py-6">
      <div className="w-full max-w-md">
        <div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
          <p className="text-[10px] font-bold uppercase tracking-[0.24em] text-violet-300/80">Cubic-bezier drawer</p>
          <p className="font-mono text-[10px] tabular-nums text-ink-faint">{css}</p>
        </div>

        <div className="flex gap-3">
          <svg
            ref={svgRef}
            viewBox={`${-pad} ${-pad} ${size + pad * 2} ${size + pad * 2}`}
            className="h-44 w-44 shrink-0 touch-none rounded-2xl border border-white/10 bg-white/[.02]"
            role="application"
            aria-label="Bezier editor. Drag either handle, or focus one and use the arrow keys."
            onPointerMove={(e) => {
              if (!dragging) return;
              const p = toSvg(e.clientX, e.clientY);
              if (p) setPoint(dragging, p.x, p.y);
            }}
            onPointerUp={() => setDragging(null)}
            onPointerLeave={() => setDragging(null)}
          >
            <rect x={0} y={0} width={size} height={size} fill="rgba(255,255,255,.02)" stroke="rgba(255,255,255,.08)" />
            <line x1={0} y1={size} x2={size} y2={0} stroke="rgba(255,255,255,.12)" strokeDasharray="4 5" />
            <line x1={0} y1={size} x2={pA.px} y2={pA.py} stroke="rgba(167,139,250,.4)" />
            <line x1={size} y1={0} x2={pB.px} y2={pB.py} stroke="rgba(56,189,248,.4)" />
            <path d={path} fill="none" stroke="#a78bfa" strokeWidth="2.5" />
            <circle cx={pA.px} cy={pA.py} r="6" fill="#a78bfa" />
            {[1, 2].map((which) => {
              const p = which === 1 ? pA : pB;
              const active = dragging === which;
              return (
                <circle
                  key={which}
                  cx={p.px}
                  cy={p.py}
                  r={active ? 10 : 8}
                  fill={which === 1 ? "#a78bfa" : "#38bdf8"}
                  fillOpacity={active ? 0.9 : 0.55}
                  stroke="#08090f"
                  strokeWidth="2"
                  tabIndex={0}
                  role="slider"
                  aria-label={`Control point ${which}`}
                  aria-valuetext={`x ${(which === 1 ? x1 : x2).toFixed(2)}, y ${(which === 1 ? y1 : y2).toFixed(2)}`}
                  aria-valuemin={0}
                  aria-valuemax={1}
                  aria-valuenow={which === 1 ? x1 : x2}
                  className="cursor-grab focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/70"
                  onPointerDown={(e) => {
                    (e.target as Element).setPointerCapture?.(e.pointerId);
                    setDragging(which as 1 | 2);
                  }}
                  onKeyDown={(e) => nudge(e, which as 1 | 2)}
                />
              );
            })}
          </svg>

          <div className="min-w-0 flex-1 space-y-2">
            <div className="rounded-2xl border border-white/10 bg-white/[.02] p-3">
              <p className="text-[9px] font-bold uppercase tracking-widest text-ink-faint">Preview</p>
              <div className="relative mt-2 h-8">
                <span ref={dotRef} className="absolute left-0 top-1 h-5 w-5 rounded-full bg-violet-400" />
                <span className="absolute inset-x-0 top-3.5 h-px bg-white/10" />
              </div>
              <div className="mt-2 flex items-center gap-2">
                <button type="button" onClick={() => setRunKey((k) => k + 1)} className="btn btn-ghost !px-2.5 !py-1.5 text-[10px]">
                  Run
                </button>
                <span className="text-[9px] text-ink-faint">the dot uses the curve above, not a copy of it</span>
              </div>
            </div>

            <div className="rounded-2xl border border-white/10 bg-white/[.02] p-3">
              <div className="flex items-center justify-between">
                <span className="text-[9px] font-bold uppercase tracking-widest text-ink-faint">Probe t = {t.toFixed(2)}</span>
                <span className="font-mono text-[9px] tabular-nums text-ink-dim">y = {y.toFixed(3)}</span>
              </div>
              <input
                type="range"
                min={0}
                max={1}
                step={0.01}
                value={t}
                onChange={(e) => setT(Number(e.target.value))}
                aria-label="Probe the curve's progress"
                className="mt-1.5 w-full accent-violet-400"
              />
            </div>
          </div>
        </div>

        <div className="mt-3 flex flex-wrap gap-1.5">
          {BEZIER_PRESETS.map((p) => (
            <button
              key={p.name}
              type="button"
              onClick={() => {
                setP1([p.x1, p.y1]);
                setP2([p.x2, p.y2]);
              }}
              className="rounded-lg border border-white/10 px-2 py-1 font-mono text-[9px] text-ink-dim transition-colors hover:border-violet-400/50 hover:text-ink"
            >
              {p.name}
            </button>
          ))}
          <a href="/lab" className="rounded-lg border border-dashed border-white/15 px-2 py-1 text-[9px] text-ink-faint transition-colors hover:border-violet-400/50 hover:text-ink">
            Easing Lab has the full chart →
          </a>
        </div>

        <p aria-live="polite" className="mt-2 min-h-[1rem] text-[10px] leading-relaxed text-violet-200/80">
          {dragging
            ? `Dragging handle ${dragging}. ${css}`
            : "Drag a handle, or focus one and use the arrow keys — shift moves ten times as far."}
        </p>
        <p className="mt-1 text-[10px] leading-relaxed text-ink-faint">
          Handles may go outside the box, because overshoot is a property of the curve rather than a mistake to clamp away. The
          preview dot runs on a real CSS transition with the value above, and the Lab&apos;s chart is the same maths with a bigger
          canvas — the two never disagree because they share these helpers.
        </p>
      </div>
    </div>
  );
}

Snippet provenance

lines 1–256 · v1.0.0
v1.0.0Motif Studio2026-09-10the only version the catalog records for this asset

No changelog entry mentions “Interactive Bezier Drawer”, so the catalog records a single version for it.

Every line above is attributed to the single version this catalog records — we keep no per-line history, so no per-line attribution is invented. See the provenance page for the rule and the gaps.

Review notes · anchored to code lines

Threads attach to a line number in this asset's snippet, so a remark about the focus trap stays next to it.

demo · stored in this browser

Loading notes…

The snippet on this page is 256 lines long, so anchors run 1–256. Notes are per-asset and per-browser; a shared review thread is a server feature, and this panel is the local stand-in that proves the anchoring works.

Dependency-aware copy: zero packages to install — paste and run. All markup is original Motif UI content (MIT).

Course rail — understand it before you ship it

Theme Studio preview

Theming example — two token themes

Assets are token-driven (100% design-token driven). Here the same scene runs under two theme presets from the token sheet.

Midnight / violet

Cubic-bezier drawer

cubic-bezier(0.16, 1.00, 0.30, 1.00)

Preview

the dot uses the curve above, not a copy of it
Probe t = 0.00y = 0.000
Easing Lab has the full chart →

Drag a handle, or focus one and use the arrow keys — shift moves ten times as far.

Handles may go outside the box, because overshoot is a property of the curve rather than a mistake to clamp away. The preview dot runs on a real CSS transition with the value above, and the Lab's chart is the same maths with a bigger canvas — the two never disagree because they share these helpers.

--accent 262 · dark surfaces

Bone / amber

Cubic-bezier drawer

cubic-bezier(0.16, 1.00, 0.30, 1.00)

Preview

the dot uses the curve above, not a copy of it
Probe t = 0.00y = 0.000
Easing Lab has the full chart →

Drag a handle, or focus one and use the arrow keys — shift moves ten times as far.

Handles may go outside the box, because overshoot is a property of the curve rather than a mistake to clamp away. The preview dot runs on a real CSS transition with the value above, and the Lab's chart is the same maths with a bigger canvas — the two never disagree because they share these helpers.

--accent 40 · paper surfaces

90-second read

Usage recipe — where this fits

Put it where a micro-decision happens: a toggle, a loader, a hover state — the small moments that make an interface feel considered.

Drop it into the exact spot it belongs, keep the default props until the copy is final, then tune the one knob that matters for your context.

belongs in elementsdrag behaviourkeyboard behaviour

Cost honesty

Performance note

2.9 KB gzipnegligible — a single painted surface; nothing moves unless hovered.

Cheaper alternative — if you need even lighter, the plain HTML version skips React entirely — same result, ~0 KB.

What it needs

Browser support strip

HTML semanticsfocus-visiblecustom propertiesprefers-reduced-motionall evergreen browsers

No polyfills ship with the asset — if a listed feature is missing in a target browser, the graceful fallback is the static layout.

prefers-reduced-motion

Reduced-motion fallback

With prefers-reduced-motion: reduce, the animation loop and travel are removed; state changes keep working through opacity and colour (≤ 300ms). The demo you see above honours the switch — try it in your OS settings and the loop will quiet down.

Design rule: the reduced branch is a second design, not a stripped page — every state change stays visible.

Pressable step-through

Keyboard walk demo

Step through what a keyboard user experiences with this asset.

1

Tab to the element — the focus ring is visible before interaction

Auto-suggested

Composition map — what pairs well

One original micro-case

Inspiration context

The settings row that got fewer support tickets

A settings screen had seventeen identical switches with no press state. Users could not tell whether a tap had registered, so they tapped twice — and support logs filled with 'it toggles back'. The fix was one press-state scale plus a 250ms state fade per row. Ticket volume on that screen dropped by half in a month.

Written in-house — no screenshots, no borrowed imagery. The pattern, not the pixels.

Every version, why

Changelog — this asset

v1.0.02026-09-10

Current release — this page documents the asset as shipped.

v1.0.02026-08-18

Token pass: colours and radii moved to the design-token sheet; a11y score raised to the current bar.

v0.1.02026-07-02

Initial release to the library — original markup, MIT licensed.

30-day sparkline

Copy history

270copies this month

Sample series from this asset's usage fingerprint · last 12 days shown.

Future queue

Community remixes

Queue opens after launch. Remixes arrive as alternate versions of this asset — same job, different voice — submitted by users and audited by the studio before they appear beside the original.

0 remixes yetaudited by studiocredit + link back to the remixer

Star / favourite

Save this asset

Your collection is stored in this browser (localStorage) — no account needed.

Build a page around it

Pairs well with a prompt

This asset belongs inside a bigger build — here is the prompt that would generate a page containing it:

A11y-strict page — WCAG AA as a hard requirement

AA requirements · Contrast audit · Keyboard map

featured

Exact tree

Bundled dependencies — disclosed

bezier-drawer.tsx10.8 KB raw · ~1 KB gzip
(no runtime dependencies)the CSS ships with the snippet

Full snippet ≈ 10.8 KB raw, ~1 KB gzip — before any framework you already load.

Download

Export as single file

Grab the asset as one file, ready to paste into your project.

Files are generated in your browser from the snippets on this page — nothing leaves the tab.

Scale, not clutter

Sizing system — three densities

Density is a token decision, not a per-page override — pick one density per product surface and hold it. Active: Default.

Runnable copy

Open in a sandbox

Opens this asset in a new tab with its stylesheet injected — a real, editable HTML document in your browser. No account, no upload.

If the tab is blocked, use the single-file export instead — same content, saved locally.

Written version first

Tutorial transcript

This asset does not have a video yet — so the written version stands in: the code line walk below narrates the snippet line by line, and the design rationale on this page explains the default decisions. When a video ships, this exact panel hosts its full transcript.

video: not yettranscript: line walk belowformat: markdown, searchable

Annotated snippet

Code line walk

The lines that matter, with the reason they matter. Click one:

Trade-off, visible

Size vs. quality slider

~2 KB~6 KB~12 KB

Standard · CSS + hover states

~6 KB

adds transitions, focus-visible and reduced-motion rules

The slider is honest: the slim tier has no JS, the pro tier pays for behaviour. Start slim, upgrade only the surfaces that need it.

HTML → React, priced

Stack-switch cost note

Taking the HTML/CSS version into a React component costs real work — and the cost is predictable:

Event wiring

Hover, focus and click become handlers — the CSS stays, the state appears.

Props for the knobs

Every inline value that should vary becomes a prop with a default — the defaults must match the CSS exactly.

The estimate

A clean port of this asset is 20–45 minutes for a React developer — the 0-character stylesheet is the easy half.

Find it the way you say it

Tag synonyms

Search on this site resolves synonyms globally — "dropdown", "select" and "picker" land on the same asset, so you never need to know the catalog's internal vocabulary.

When to pick which

Sibling comparison

Data table — data table
CompareInteractive Bezier DrawerPrism SwitchHalo Button
KindElementsElementsElements
Bundle2.9 KB gzip1.2 KB gzip2.1 KB gzip
a11y95/10098/10096/100
Dependenciesnonenonenone
Copies / mo2704,8203,910
Interactionsdrag, keyboardclick, hoverhover, click, drag

The honest verdict — Prism Switch is lighter, but Interactive Bezier Drawer carries richer behaviour — pick by the interaction your screen actually needs, then swap tokens, not pages.

Read next — two guides and a prompt

picked by tag overlap, not by hand

How these three were chosen: essays scored on shared tags (1 and 1 matches), then prompt scored on block/vibe overlap (0 matches). The rule lives in src/lib/spine.ts, so the same asset always links to the same three pages and a second reader can reproduce the choice.

–remixes · this browser

The counter is a note to yourself — it adds one each time you tap, in motif:remix-count:v1, and goes nowhere. A real, public remix count would need a server and accounts; this site has neither, and it would rather say that than print a number it cannot own.

Was this helpful?