Components & interactionsBezier curve editor
Vector handles
Two generous control points reshape a cubic path without hiding its construction.

Drag a handle or move it with arrow keys
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 { useRef, useState } from "react"
export default function VectorHandles({ active = true }: { active?: boolean }) {
const [points, setPoints] = useState([
{ x: 25, y: 15 },
{ x: 73, y: 85 },
])
const plane = useRef<HTMLDivElement>(null),
drag = useRef<number | null>(null)
const update = (i: number, x: number, y: number) =>
setPoints((p) =>
p.map((v, j) => (j === i ? { x: Math.max(7, Math.min(93, x)), y: Math.max(8, Math.min(92, y)) } : v)),
)
return (
<div className="sf-vector" data-active={active}>
<style>{`
.sf-vector{height:100%;min-height:300px;display:grid;place-items:center;background:#eef0ef;font-family:Arial,sans-serif;overflow:hidden}.sf-vector *{box-sizing:border-box}.sf-vector-plane{position:relative;width:min(360px,88%);height:245px;border:1px solid #d9dfdc;background:white;background-image:radial-gradient(#cbd2ce .8px,transparent .8px);background-size:16px 16px;border-radius:8px;box-shadow:0 5px 16px #243b3310}.sf-vector-plane>svg{width:100%;height:100%;position:absolute;inset:0;overflow:visible}.sf-vector-handle{position:absolute;width:44px;height:44px;transform:translate(-50%,-50%);background:none;border:0;padding:15px;cursor:move;touch-action:none}.sf-vector-handle i{display:block;width:14px;height:14px;border-radius:50%;background:#fff;border:2px solid #458272;box-shadow:0 2px 4px #12352a25;transition:transform 100ms ease}.sf-vector-handle:active i{transform:scale(1.15)}.sf-vector-handle:focus-visible{outline:2px solid #23765f;border-radius:50%}.sf-vector-coordinates{position:absolute;bottom:12px;left:14px;font:10px monospace;color:#72877e;background:#fff9;padding:3px}.sf-vector-reset{position:absolute;right:5px;bottom:3px;border:0;background:none;font-size:18px;width:40px;height:40px;color:#5b7469;cursor:pointer}.sf-vector[data-active=false] *{transition:none}@media(prefers-reduced-motion:reduce){.sf-vector *{transition:none}}
`}</style>
<div className="sf-vector-plane" ref={plane}>
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
aria-label="Cubic path with two editable control points"
role="img"
>
<path
d={`M10 75 C${points[0].x} ${points[0].y} ${points[1].x} ${points[1].y} 90 25`}
fill="none"
stroke="#22725c"
strokeWidth="1.15"
/>
<path
d={`M10 75 L${points[0].x} ${points[0].y} M90 25 L${points[1].x} ${points[1].y}`}
stroke="#8eaea2"
strokeWidth=".4"
strokeDasharray="1 1"
fill="none"
/>
<rect x="8.5" y="73.5" width="3" height="3" fill="#22725c" />
<rect x="88.5" y="23.5" width="3" height="3" fill="#22725c" />
</svg>
{points.map((p, i) => (
<button
type="button"
key={i}
className="sf-vector-handle"
style={{ left: `${p.x}%`, top: `${p.y}%` }}
aria-label={`Control point ${i + 1}. X ${Math.round(p.x)}, Y ${Math.round(p.y)}. Use arrow keys.`}
onPointerDown={(e) => {
drag.current = i
e.currentTarget.setPointerCapture(e.pointerId)
}}
onPointerMove={(e) => {
if (drag.current !== i) return
const r = plane.current!.getBoundingClientRect()
update(i, ((e.clientX - r.left) / r.width) * 100, ((e.clientY - r.top) / r.height) * 100)
}}
onPointerUp={() => {
drag.current = null
}}
onPointerCancel={() => {
drag.current = null
}}
onLostPointerCapture={() => {
drag.current = null
}}
onKeyDown={(e) => {
if (e.key.startsWith("Arrow")) {
e.preventDefault()
const d = e.shiftKey ? 10 : 2
update(
i,
p.x + (e.key === "ArrowRight" ? d : e.key === "ArrowLeft" ? -d : 0),
p.y + (e.key === "ArrowDown" ? d : e.key === "ArrowUp" ? -d : 0),
)
}
}}
>
<i />
</button>
))}
<output className="sf-vector-coordinates">
{points.map((p) => `${Math.round(p.x)}, ${Math.round(p.y)}`).join(" / ")}
</output>
<button
type="button"
className="sf-vector-reset"
aria-label="Reset path"
onClick={() =>
setPoints([
{ x: 25, y: 15 },
{ x: 73, y: 85 },
])
}
>
↺
</button>
</div>
</div>
)
}
Where it works well
Path shaping, motion trajectory editing, and teaching tangent relationships.
Reduced motion
Input follows immediately; press decoration has no transition.
Things to know
Single cubic segment with fixed endpoints, not a complete path editor or easing-time solver.