Easing workbench
Two editable curve handles connect time to a moving white marker on a cobalt timing instrument.

Drag the handles, scrub time, play the motion, or copy the CSS curve
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import * as React from "react"
type Point = { x: number; y: number }
const START: [Point, Point] = [
{ x: 0.22, y: 1 },
{ x: 0.6, y: 0.15 },
]
const clamp = (value: number) => Math.max(0, Math.min(1, value))
const coordinate = (t: number, a: number, b: number) =>
3 * (1 - t) * (1 - t) * t * a + 3 * (1 - t) * t * t * b + t * t * t
function valueAtTime(time: number, points: [Point, Point]) {
if (time <= 0) return 0
if (time >= 1) return 1
// Time is the curve's x coordinate, not its polynomial parameter.
let lower = 0,
upper = 1
for (let index = 0; index < 22; index++) {
const middle = (lower + upper) / 2
if (coordinate(middle, points[0].x, points[1].x) < time) lower = middle
else upper = middle
}
return coordinate((lower + upper) / 2, points[0].y, points[1].y)
}
const screenPoint = (point: Point) => ({ x: 48 + point.x * 272, y: 238 - point.y * 190 })
export default function Example({ active = true }: { active?: boolean }) {
const [points, setPoints] = React.useState<[Point, Point]>(START)
const [progress, setProgress] = React.useState(0.38)
const [playing, setPlaying] = React.useState(false)
const [duration, setDuration] = React.useState(1200)
const [durationDraft, setDurationDraft] = React.useState("1200")
const [reduced, setReduced] = React.useState(true)
const [visible, setVisible] = React.useState(true)
const [copied, setCopied] = React.useState(false)
const progressRef = React.useRef(0.38)
const graph = React.useRef<SVGSVGElement>(null)
const drag = React.useRef<{ id: number; index: number; before: [Point, Point] } | null>(null)
const copyTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const mounted = React.useRef(false)
const hintId = React.useId()
React.useEffect(() => {
mounted.current = true
const mq = matchMedia("(prefers-reduced-motion: reduce)")
const motion = () => {
setReduced(mq.matches)
if (mq.matches) setPlaying(false)
}
const visibility = () => setVisible(!document.hidden)
motion()
visibility()
mq.addEventListener("change", motion)
document.addEventListener("visibilitychange", visibility)
return () => {
mounted.current = false
mq.removeEventListener("change", motion)
document.removeEventListener("visibilitychange", visibility)
if (copyTimer.current) clearTimeout(copyTimer.current)
}
}, [])
React.useEffect(() => {
if (active || !drag.current) return
const current = drag.current
drag.current = null
setPoints(current.before)
if (graph.current?.hasPointerCapture(current.id)) graph.current.releasePointerCapture(current.id)
}, [active])
React.useEffect(() => {
if (!active || !visible || !playing || reduced) return
let frame = 0,
previous = 0
const tick = (now: number) => {
if (previous) progressRef.current = Math.min(1, progressRef.current + Math.min(now - previous, 80) / duration)
previous = now
setProgress(progressRef.current)
if (progressRef.current >= 1) {
setPlaying(false)
return
}
frame = requestAnimationFrame(tick)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [active, visible, playing, reduced, duration])
function seek(time: number) {
progressRef.current = clamp(time)
setProgress(progressRef.current)
}
function movePoint(index: number, point: Point) {
setPoints(
(current) =>
current.map((value, i) =>
i === index
? { x: Math.round(clamp(point.x) * 100) / 100, y: Math.round(clamp(point.y) * 100) / 100 }
: value,
) as [Point, Point],
)
}
function pointer(event: React.PointerEvent<SVGSVGElement>) {
if (drag.current?.id !== event.pointerId || !graph.current) return
const matrix = graph.current.getScreenCTM()
if (!matrix) return
const point = graph.current.createSVGPoint()
point.x = event.clientX
point.y = event.clientY
const local = point.matrixTransform(matrix.inverse())
movePoint(drag.current.index, { x: (local.x - 48) / 272, y: (238 - local.y) / 190 })
}
const curve = `cubic-bezier(${points.map((point) => `${point.x.toFixed(2)}, ${point.y.toFixed(2)}`).join(", ")})`
const first = screenPoint(points[0]),
second = screenPoint(points[1]),
eased = valueAtTime(progress, points)
const bead = screenPoint({ x: progress, y: eased })
return (
<div className="sf-easing-workbench">
<div className="easing-instrument">
<svg
ref={graph}
viewBox="0 0 520 288"
className="easing-graph"
aria-label="Cubic easing editor"
onPointerMove={pointer}
onPointerUp={() => {
drag.current = null
}}
onPointerCancel={() => {
if (drag.current) setPoints(drag.current.before)
drag.current = null
}}
onLostPointerCapture={() => {
if (drag.current) setPoints(drag.current.before)
drag.current = null
}}
>
{[0, 0.25, 0.5, 0.75, 1].map((tick) => (
<g key={tick} stroke="currentColor" opacity={tick === 0 || tick === 1 ? 0.32 : 0.12} strokeWidth=".8">
<line x1={48 + tick * 272} x2={48 + tick * 272} y1="48" y2="238" />
<line x1="48" x2="320" y1={238 - tick * 190} y2={238 - tick * 190} />
</g>
))}
<text x="48" y="267" fontSize="10" fill="currentColor" opacity=".65">
0
</text>
<text x="316" y="267" fontSize="10" fill="currentColor" opacity=".65">
1
</text>
<text x="27" y="52" fontSize="10" fill="currentColor" opacity=".65">
1
</text>
<line x1="48" y1="238" x2={first.x} y2={first.y} stroke="currentColor" opacity=".45" strokeWidth="1" />
<line x1="320" y1="48" x2={second.x} y2={second.y} stroke="currentColor" opacity=".45" strokeWidth="1" />
<path
d={`M48 238 C${first.x} ${first.y} ${second.x} ${second.y} 320 48`}
fill="none"
stroke="currentColor"
strokeWidth="2.4"
/>
<line x1={bead.x} x2={bead.x} y1={bead.y} y2="238" stroke="currentColor" opacity=".5" strokeDasharray="2 4" />
<line x1={bead.x} x2="441" y1={bead.y} y2={bead.y} stroke="currentColor" opacity=".3" strokeDasharray="2 4" />
{[first, second].map((point, index) => (
<g
key={index}
role="button"
tabIndex={0}
className="easing-handle"
aria-label={`Control point ${index + 1}: ${points[index].x.toFixed(2)}, ${points[index].y.toFixed(2)}`}
aria-describedby={hintId}
onPointerDown={(event) => {
if (event.button !== 0) return
event.preventDefault()
event.currentTarget.focus()
setPlaying(false)
drag.current = {
id: event.pointerId,
index,
before: points.map((point) => ({ ...point })) as [Point, Point],
}
graph.current?.setPointerCapture(event.pointerId)
}}
onKeyDown={(event) => {
if (event.key === "Escape" && drag.current) {
const current = drag.current
setPoints(current.before)
drag.current = null
if (graph.current?.hasPointerCapture(current.id)) graph.current.releasePointerCapture(current.id)
return
}
if (!["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home"].includes(event.key)) return
event.preventDefault()
setPlaying(false)
const step = event.shiftKey ? 0.01 : 0.05
movePoint(
index,
event.key === "Home"
? START[index]
: {
x:
points[index].x + (event.key === "ArrowRight" ? step : event.key === "ArrowLeft" ? -step : 0),
y: points[index].y + (event.key === "ArrowUp" ? step : event.key === "ArrowDown" ? -step : 0),
},
)
}}
>
<circle cx={point.x} cy={point.y} r="22" fill="transparent" />
<circle
className="easing-handle-dot"
cx={point.x}
cy={point.y}
r="6"
fill="#183cd8"
stroke="currentColor"
strokeWidth="2"
/>
</g>
))}
<circle cx={bead.x} cy={bead.y} r="4" fill="currentColor" pointerEvents="none" />
<line x1="441" x2="441" y1="48" y2="238" stroke="currentColor" opacity=".2" />
<circle cx="441" cy="48" r="23" fill="none" stroke="currentColor" strokeWidth=".7" opacity=".3" />
<circle cx="441" cy="238" r="23" fill="none" stroke="currentColor" strokeWidth=".7" opacity=".3" />
<circle cx="441" cy={bead.y} r="23" fill="#f9fbff" />
<path d={`M432 ${bead.y}h18M441 ${bead.y - 9}v18`} stroke="#183cd8" strokeWidth="1" />
</svg>
<div className="easing-transport">
<button
className="easing-play"
aria-label={playing ? "Pause preview" : reduced ? "Advance preview" : "Play preview"}
onClick={() => {
if (reduced) {
seek(progress >= 1 ? 0 : progress + 0.25)
return
}
if (playing) setPlaying(false)
else {
seek(0)
setPlaying(true)
}
}}
>
<svg viewBox="0 0 20 20" aria-hidden="true">
{playing ? <path d="M5 4h3v12H5zM12 4h3v12h-3z" /> : <path d="m6 3 11 7-11 7z" />}
</svg>
</button>
<input
type="range"
min="0"
max="1"
step=".001"
value={progress}
aria-label="Preview time"
aria-valuetext={`${Math.round(progress * 100)} percent`}
onChange={(event) => {
setPlaying(false)
seek(Number(event.target.value))
}}
/>
<label className="easing-duration">
<input
type="number"
min="200"
max="4000"
step="100"
value={durationDraft}
aria-label="Duration in milliseconds"
onChange={(event) => {
setDurationDraft(event.target.value)
const next = Number(event.target.value)
if (next >= 200 && next <= 4000) setDuration(next)
}}
onBlur={() => {
const next = Math.max(200, Math.min(4000, Number(durationDraft) || duration))
setDuration(next)
setDurationDraft(String(next))
}}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur()
}}
/>
<span>ms</span>
</label>
</div>
<button
className="easing-code"
aria-label="Copy cubic-bezier CSS value"
onClick={async () => {
try {
await navigator.clipboard.writeText(curve)
if (!mounted.current) return
setCopied(true)
if (copyTimer.current) clearTimeout(copyTimer.current)
copyTimer.current = setTimeout(() => setCopied(false), 1600)
} catch {
setCopied(false)
}
}}
>
<span>{curve}</span>
<svg viewBox="0 0 20 20" aria-hidden="true">
{copied ? (
<path d="m4 10 4 4 8-8" />
) : (
<>
<rect x="7" y="6" width="9" height="10" rx="1.5" />
<path d="M12 6V3H3v10h4" />
</>
)}
</svg>
</button>
<span className="easing-sr" id={hintId}>
Drag either handle, or use arrow keys to change horizontal time and vertical progress. Shift makes smaller
changes. Home resets that handle.
</span>
<span className="easing-sr" aria-live="polite">
{copied ? "CSS timing curve copied" : ""}
</span>
</div>
<style>{`
.sf-easing-workbench{height:100%;width:100%;min-height:0;display:grid;place-items:center;overflow:hidden;background:#183cd8;color:#f9fbff;font-family:monospace;container-type:size}.sf-easing-workbench *{box-sizing:border-box}.sf-easing-workbench .easing-instrument{width:min(86cqw,138cqh)}.sf-easing-workbench .easing-graph{display:block;width:100%;overflow:visible;touch-action:none;user-select:none}.sf-easing-workbench .easing-handle{cursor:grab;outline:none}.sf-easing-workbench .easing-handle:active{cursor:grabbing}.sf-easing-workbench .easing-handle:hover .easing-handle-dot,.sf-easing-workbench .easing-handle:focus-visible .easing-handle-dot{stroke-width:4}.sf-easing-workbench .easing-transport{display:flex;align-items:center;gap:16px;padding:0 8%}.sf-easing-workbench .easing-play{background:transparent;color:inherit;border:1px solid #ffffff50;border-radius:50%;width:44px;height:44px;display:grid;place-items:center;flex-shrink:0;cursor:pointer}.sf-easing-workbench .easing-play:hover{background:#ffffff16}.sf-easing-workbench .easing-play svg{width:14px;fill:currentColor}.sf-easing-workbench input[type=range]{flex:1;min-width:0;appearance:none;background:transparent;height:36px;cursor:ew-resize}.sf-easing-workbench input[type=range]::-webkit-slider-runnable-track{height:2px;background:#ffffff50}.sf-easing-workbench input[type=range]::-webkit-slider-thumb{appearance:none;width:14px;height:14px;margin-top:-6px;border-radius:50%;background:#f9fbff;border:0}.sf-easing-workbench input[type=range]::-moz-range-track{height:2px;background:#ffffff50}.sf-easing-workbench input[type=range]::-moz-range-thumb{width:14px;height:14px;border-radius:50%;background:#f9fbff;border:0}.sf-easing-workbench .easing-duration{display:flex;gap:4px;align-items:center;font-size:11px;color:#ffffffa0}.sf-easing-workbench .easing-duration input{background:none;border:0;color:#f9fbff;width:38px;text-align:right;font:inherit;appearance:textfield;padding:5px 0}.sf-easing-workbench input::-webkit-inner-spin-button{appearance:none}.sf-easing-workbench .easing-code{display:flex;align-items:center;justify-content:center;gap:9px;width:100%;background:none;border:0;color:#ffffffa0;font:clamp(8px,1.5cqw,11px) monospace;padding:12px 0 0;cursor:pointer}.sf-easing-workbench .easing-code:hover{color:#fff}.sf-easing-workbench .easing-code svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round;stroke-width:1.2}.sf-easing-workbench button:focus-visible,.sf-easing-workbench input:focus-visible{outline:2px solid white;outline-offset:4px}.sf-easing-workbench .easing-sr{position:absolute;width:1px;height:1px;clip-path:inset(50%);overflow:hidden;white-space:nowrap}
`}</style>
</div>
)
}
Where it works well
Authoring and inspecting CSS timing curves with a synchronized, seekable motion preview.
Reduced motion
Playback becomes explicit quarter-step advancement; manual scrubbing and curve editing remain available.
Things to know
Both control-point coordinates are bounded to 0–1. It covers CSS cubic timing curves without overshoot; not a spring simulator.