Components
Figma's vector-editing chrome rebuilt for the web, in two parts. FigmaFrame draws the 'selected layer' look around any element: a thin accent border that animates open, four corner handles that pop in one by one, and a live W×H dimension badge tracked with a ResizeObserver. VectorEditor is a live SVG bezier point editor: every anchor is a draggable dot and every bezier handle a draggable diamond joined by a thin tangent arm — drag anchors to move, drag handles to reshape, hold Alt to break a tangent into a corner, with Figma-style mirroring modes (none / angle / angle-length). Includes a lossless SVG path parser/serializer (M/L/H/V/C/S/Q/T/Z, multi-subpath with holes) so you can drop in any path, edit it, and read the new d string back. Zero dependencies, pure pointer math through the live screen CTM.
npx @21st-dev/cli@beta add arlanoska/vector-editorLoading preview...
"use client"
import { useMemo, useRef, useState } from "react"
import {
FigmaFrame,
VectorEditor,
parsePath,
DEFAULT_EDITOR,
DEFAULT_FRAME,
type Mirror,
type VectorPath,
} from "@/components/ui/vector-editor"
/* Shapes from the source playground (arlan.me/vault/vector-editor), verbatim:
the default circle plus the Remix pool (heart, the word "Arlan", star, drop, bolt). */
const SHAPES: { d: string; viewBox: [number, number, number, number] }[] = [
{ d: "M 100 20 C 150 20 180 60 180 100 C 180 150 140 180 100 180 C 50 180 20 140 20 100 C 20 50 60 20 100 20 Z", viewBox: [0, 0, 200, 200] },
{ d: "M 100 50 C 100 20 60 10 40 35 C 15 65 40 110 100 160 C 160 110 185 65 160 35 C 140 10 100 20 100 50 Z", viewBox: [0, 0, 200, 180] },
{ d: "M 19.8 150 L 51.8 150 L 62.2 118.2 L 115.8 118.2 L 126 150 L 158.8 150 L 104.6 7 L 73.6 7 L 19.8 150 Z M 69.6 94.6 L 88.8 34.6 L 89.2 34.6 L 108.6 94.6 L 69.6 94.6 Z M 195.8 100.4 C 195.8 81.8 208.8 71 224.6 71 C 226.8 71 228.6 71.2 231.2 71.6 L 231.2 47 C 227.8 46.4 225.2 46.4 222.2 46.4 C 210 46.4 200.4 51.8 195.4 61.4 L 195 61.4 L 195 48 L 169.8 48 L 169.8 150 L 195.8 150 L 195.8 100.4 Z M 302.4 46.2 C 286.2 46.2 272.6 51.4 263.2 60.2 L 276.4 76.6 C 282.6 71 291.2 67.2 300.2 67.2 C 312.4 67.2 319.4 73.2 319.4 82.8 L 319.4 87.2 C 314.6 85.6 307.6 84.4 300.8 84.4 C 278.4 84.4 262.2 95.6 262.2 114.6 C 262.2 137 278.6 151.8 297.8 151.8 C 307.6 151.8 315.4 148.4 319.8 141.6 L 320.2 141.6 L 320.2 150 L 345.4 150 L 345.4 84 C 345.4 60.4 328.6 46.2 302.4 46.2 Z M 303.8 132 C 295.4 132 288.4 127.2 288.4 118.4 C 288.4 109.4 295.8 104.2 305.4 104.2 C 310.6 104.2 315.6 105 319.4 106.2 L 319.4 113.4 C 319.4 124.6 312.6 132 303.8 132 Z M 388.4 150 L 388.4 96.6 C 388.4 79.8 397 68.4 410.6 68.4 C 422.8 68.4 429.2 76.8 429.2 92.2 L 429.2 150 L 455.2 150 L 455.2 88.4 C 455.2 62.8 441.6 46.4 419.6 46.4 C 405.6 46.4 394.8 52.6 388.8 63.6 L 388.4 63.6 L 388.4 48 L 362.4 48 L 362.4 150 L 388.4 150 Z M 491.2 150 L 491.2 4 L 465.2 4 L 465.2 150 L 491.2 150 Z", viewBox: [0, 0, 511, 169] },
{ d: "M 48 178 C 48 177.99 62 118.01 62 118 C 62 118 16.01 78.01 16.01 78.01 C 16.01 78 76 72 76 72 C 76 72 100 16.01 100 16 C 100 16 124 72 124 72 C 124 72 184 78 184 78 C 184 78 138 118 138 118 C 138 118 152 178 152 178 C 152 178 100 146 100 146 C 100 146 48 178 48 178 Z", viewBox: [0, 0, 200, 200] },
{ d: "M 80 16 C 90 29.5 99.38 42.13 107.82 53.94 C 116.25 65.75 123.76 76.75 130.01 87 C 142.51 107.49 150 125 150 140 C 150 178 119 200 80 200 C 41 200 10 178 10 140 C 10 125 17.5 107.5 30 87 C 42.5 66.5 60 43 80 16 Z", viewBox: [0, 0, 160.0005, 200] },
{ d: "M 84 8 C 84 8.01 16.02 111.99 16.03 111.99 C 16.03 112 64.02 112 64.02 112.01 C 64.02 112.02 48.02 192 48.01 192 C 48.02 192 124.02 80 124.03 80 C 124.02 80 76.02 80 76.02 80 C 76.02 80 84 8 84 8 Z", viewBox: [0, 0, 140.0002, 200] },
]
const FIT = 300 // fit each shape into a 300px box, like the source playground
const STYLE = `
.vep { --page:#f4f4f5; --surface:#fff; --line:rgba(0,0,0,0.09); --ring:rgba(0,0,0,0.22);
--t1:#1b1b1b; --t2:#6b6b6b; --sel:#fcfcfc; --bg-weak:#f5f5f3;
display:flex; min-height:100vh; width:100%; align-items:center; justify-content:center;
background:var(--page); color:var(--t1); padding:32px 24px;
font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text",Inter,"Segoe UI",system-ui,sans-serif; }
.vep-wrap { width:100%; max-width:680px; }
.vep-head { display:flex; justify-content:flex-end; gap:8px; padding:0 2px 10px; }
.vep-btn { height:30px; padding:0 12px; border:1px solid var(--line); border-radius:9px; background:var(--surface);
color:var(--t1); font:inherit; font-size:13px; font-weight:500; cursor:pointer; transition:border-color .15s ease; }
.vep-btn:hover { border-color:var(--ring); }
.vep-preview { border:1px solid var(--line); border-radius:16px 16px 0 0; background:var(--surface);
height:400px; display:flex; align-items:center; justify-content:center; overflow:hidden; }
.vep-panel { border:1px solid var(--line); border-top:0; border-radius:0 0 16px 16px; background:var(--surface);
padding:14px 16px 16px; display:flex; flex-direction:column; gap:10px; }
.vep-grid2 { display:grid; grid-template-columns:1fr 1fr; gap:10px 14px; }
.vep-row { display:flex; align-items:center; gap:12px; }
.vep-row > .lab { color:var(--t2); font-size:13px; flex:0 0 auto; }
.vep-slider { position:relative; margin-left:auto; flex:1; height:32px; border:1px solid var(--line);
border-radius:9px; background:var(--surface); display:flex; align-items:center; }
.vep-slider .cap { position:absolute; left:11px; font-size:12px; color:var(--t2); pointer-events:none; }
.vep-slider .val { position:absolute; right:11px; font-size:12px; color:var(--t2); font-variant-numeric:tabular-nums; pointer-events:none; }
.vep-slider input { -webkit-appearance:none; appearance:none; width:100%; height:32px; margin:0; background:transparent; cursor:pointer; }
.vep-slider input::-webkit-slider-thumb { -webkit-appearance:none; appearance:none; width:3px; height:15px; border-radius:2px; background:var(--t1); opacity:.5; }
.vep-slider input::-moz-range-thumb { width:3px; height:15px; border:0; border-radius:2px; background:var(--t1); opacity:.5; }
.vep-seg { margin-left:auto; display:inline-flex; gap:2px; padding:3px; border-radius:10px; background:var(--bg-weak); }
.vep-seg button { padding:5px 14px; border-radius:8px; border:0; cursor:pointer; font:inherit; font-size:12px;
font-weight:500; background:transparent; color:var(--t2); transition:all .18s ease; }
.vep-seg button[data-active="true"] { background:var(--surface); color:var(--t1); box-shadow:0 1px 2px rgba(0,0,0,.06); }
.vep-swatch { margin-left:auto; width:26px; height:26px; border-radius:7px; border:1px solid var(--line); padding:0;
cursor:pointer; -webkit-appearance:none; appearance:none; background:none; }
.vep-swatch::-webkit-color-swatch-wrapper { padding:0; }
.vep-swatch::-webkit-color-swatch { border:0; border-radius:6px; }
.vep-toggles { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; padding-top:2px; }
.vep-tog { display:flex; flex-direction:column; gap:6px; }
.vep-tog .lab { color:var(--t2); font-size:12px; }
`
function Seg({ options, value, onChange, ariaPrefix }: {
options: string[]; value: string; onChange: (v: string) => void; ariaPrefix: string
}) {
return (
<div className="vep-seg" role="tablist">
{options.map((o) => (
<button key={o} type="button" role="tab" aria-selected={o === value}
aria-label={`${ariaPrefix}: ${o}`} data-active={o === value ? "true" : "false"}
onClick={() => onChange(o)}>
{o}
</button>
))}
</div>
)
}
function Slider({ label, value, min, max, step, format, onChange }: {
label: string; value: number; min: number; max: number; step: number
format: (v: number) => string; onChange: (v: number) => void
}) {
return (
<div className="vep-slider">
<span className="cap">{label}</span>
<input type="range" min={min} max={max} step={step} value={value} aria-label={label}
onChange={(e) => onChange(Number(e.target.value))} />
<span className="val">{format(value)}</span>
</div>
)
}
export default function Default() {
const [shapeIdx, setShapeIdx] = useState(0)
const [path, setPath] = useState<VectorPath>(() => parsePath(SHAPES[0].d))
const [viewBox, setViewBox] = useState<[number, number, number, number]>(SHAPES[0].viewBox)
const [accent, setAccent] = useState("#0d99ff")
const [mirrorMode, setMirrorMode] = useState<"Free" | "Angle">("Free")
const [border, setBorder] = useState(1)
const [handle, setHandle] = useState(8)
const [stroke, setStroke] = useState(1.5)
const [fill, setFill] = useState(0.08)
const [showHandles, setShowHandles] = useState(true)
const [showBadge, setShowBadge] = useState(true)
const [showPoints, setShowPoints] = useState(true)
const fileRef = useRef<HTMLInputElement>(null)
const remix = () => {
const next = (shapeIdx + 1) % SHAPES.length
setShapeIdx(next)
setPath(parsePath(SHAPES[next].d))
setViewBox(SHAPES[next].viewBox)
}
const uploadSvg = (file: File) => {
file.text().then((text) => {
try {
const doc = new DOMParser().parseFromString(text, "image/svg+xml")
const d = doc.querySelector("path")?.getAttribute("d")
if (!d) throw new Error("No <path> found in that SVG.")
const parsed = parsePath(d)
const svg = doc.querySelector("svg")
const vb = svg?.getAttribute("viewBox")?.split(/[\s,]+/).map(Number)
setPath(parsed)
if (vb && vb.length === 4) setViewBox(vb as [number, number, number, number])
} catch (err) {
alert(err instanceof Error ? err.message : "Could not read that SVG.")
}
})
}
// fit the shape into a FIT×FIT box, like the source playground
const scale = Math.min(FIT / viewBox[2], FIT / viewBox[3])
const w = Math.round(viewBox[2] * scale)
const h = Math.round(viewBox[3] * scale)
const mirror: Mirror = mirrorMode === "Free" ? "none" : "angle"
const editorStyle = useMemo(() => ({
...DEFAULT_EDITOR,
accent,
stroke: accent,
fill: accent,
fillOpacity: fill,
strokeWidth: stroke,
showRig: showPoints,
}), [accent, fill, stroke, showPoints])
const frameStyle = useMemo(() => ({
...DEFAULT_FRAME,
accent,
badgeBg: accent,
borderWidth: border,
handleSize: handle,
showHandles,
showBadge,
}), [accent, border, handle, showHandles, showBadge])
return (
<>
<style>{STYLE}</style>
<div className="vep">
<div className="vep-wrap">
<div className="vep-head">
<button type="button" className="vep-btn" onClick={remix}>Remix</button>
<button type="button" className="vep-btn" onClick={() => fileRef.current?.click()}>Upload SVG</button>
<input ref={fileRef} type="file" accept=".svg,image/svg+xml" style={{ display: "none" }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) uploadSvg(f); e.target.value = "" }} />
</div>
<div className="vep-preview">
<FigmaFrame style={frameStyle} width={w} height={h}>
<VectorEditor
path={path}
onChange={setPath}
style={editorStyle}
mirror={mirror}
viewBox={viewBox}
width={w}
height={h}
/>
</FigmaFrame>
</div>
<div className="vep-panel">
<div className="vep-grid2">
<div className="vep-row">
<span className="lab">Accent</span>
<input className="vep-swatch" type="color" value={accent} aria-label={`Accent: ${accent}`}
onChange={(e) => setAccent(e.target.value)} />
</div>
<div className="vep-row">
<Seg options={["Free", "Angle"]} value={mirrorMode}
onChange={(v) => setMirrorMode(v as "Free" | "Angle")} ariaPrefix="Mirroring" />
</div>
<Slider label="Border" value={border} min={0} max={4} step={0.1} format={(v) => v.toFixed(1)} onChange={setBorder} />
<Slider label="Handle" value={handle} min={4} max={14} step={1} format={(v) => String(v)} onChange={setHandle} />
<Slider label="Stroke" value={stroke} min={0.5} max={5} step={0.1} format={(v) => v.toFixed(1)} onChange={setStroke} />
<Slider label="Fill" value={fill} min={0} max={0.4} step={0.01} format={(v) => Math.round(v * 100) + "%"} onChange={setFill} />
</div>
<div className="vep-toggles">
<div className="vep-tog">
<span className="lab">Handles</span>
<Seg options={["On", "Off"]} value={showHandles ? "On" : "Off"}
onChange={(v) => setShowHandles(v === "On")} ariaPrefix="Handles" />
</div>
<div className="vep-tog">
<span className="lab">Badge</span>
<Seg options={["On", "Off"]} value={showBadge ? "On" : "Off"}
onChange={(v) => setShowBadge(v === "On")} ariaPrefix="Badge" />
</div>
<div className="vep-tog">
<span className="lab">Points</span>
<Seg options={["On", "Off"]} value={showPoints ? "On" : "Off"}
onChange={(v) => setShowPoints(v === "On")} ariaPrefix="Points" />
</div>
</div>
</div>
</div>
</div>
</>
)
}