Shape Path Editor
Ridiculously typed editor for the CSS shape() function (clip-path / offset-path). The strict tier dispatches each command on its name (move/line/curve/arc/…), checking arity, direction, and every coordinate. Ships the registry's only Bézier-control-handle canvas with a live preview.
Edit a shape() path
Controlled value + onChange. The popover opens a draggable SVG canvas plus a per-command row editor: add / remove / reorder move · line · curve · arc commands, and the editor emits a canonical shape() string. Coordinates are <length-percentage> with units (0px / 0%) — never a bare 0.
clip-path: shape(from 0px 0px, line to 100px 0px, curve to 100px 100px with 60px 20px, close)Drag the Bézier handles, snapped live
The hero: a draggable SVG canvas editing a curve with its Bézier control handles (each tethered to its endpoint by a connector line) and a smooth segment. Drag a node or a handle — or focus one and arrow-nudge it — and the editor re-serializes through updatePoint and emits a canonical shape() string. The clip-path toggle re-targets the live preview: clip a box, or animate a dot along the path. Coordinates stay <length-percentage> with units.
clip-path: shape(from 20px 100px, curve to 180px 100px with 60px 0px / 120px 200px, smooth to 100px 40px, close)The value lands on the clip-path property. The preview is gated on CSS.supports("clip-path: shape(…)") and degrades to a raw SVG path render with a support note where shape() is unavailable (Chrome 137 / Safari 18.4).
Three usage tiers
From useState-and-go to compile-time per-command-grammar validation.
Pass any string
useState<string>. No compile-time validation; the runtime parser tokenizes the shape() body, classifies every command by name, and drives the canvas — including the optional fill-rule and exotic hline / vline forms the strict tier treats structurally.
shape(evenodd from 0px 0px, hline by 50px, vline by 50px, close)const [value, setValue] = useState<string>("shape(evenodd from 0px 0px, hline by 50px, …)")
shape()-shaped hints
State typed as ShapeString — `shape(${string})` | (string & {}) — a wrapper-shaped string (and the onChange return type). The editor hint surfaces the shape( prefix while still accepting any string, so you get the autocomplete nudge without the strict tier's per-command rejection.
shape(from 10px 10px, smooth to 90px 90px, close)const [value, setValue] = useState<ShapeString>("shape(from 10px 10px, smooth to 90px 90px, close)")
The shape() grammar typed at compile time
cssShape() peels the shape() wrapper, validates the optional fill-rule and the from <coordinate-pair> seed, then dispatches every comma-separated command on its name — per-command arity, the by/to direction, the with/of slot keywords, and each coordinate's dimension — resolving any violation to never before you run the code.
shape(from 0px 0px, curve to 100px 100px with 50px 0px, close)cssShape("shape(from 0px 0px, curve to 100px 100px with 50px 0px, close)") // ✓ // @ts-expect-error unknown command cssShape("shape(from 0px 0px, wiggle to 10px 10px)") // @ts-expect-error line needs a coordinate PAIR cssShape("shape(from 0px 0px, line to 100px, close)") // @ts-expect-error curve needs a `with` control cssShape("shape(from 0px 0px, curve to 10px 10px, close)")
Note: coordinates are <length-percentage> with units — a bare 0 is rejected (use 0px / 0%). The hline/vline keyword positions, the order-free arc flags (cw/large/rotate), and calc()/var() coordinates are deferred to the lenient runtime parser.
API
Public surface — component props, runtime helpers, and the type exports.
§ ShapePathEditor / ShapePathEditorPanel
<ShapePathEditor
value: ShapeString | (string & {})
onChange: (next: ShapeString) => void
mode?: "clip-path" | "offset-path" // default "clip-path" (preview only)
className?: string
aria-label?: string
/>ShapePathEditor is popover-wrapped (a trigger showing the command count + truncated value); ShapePathEditorPanel renders the same editor inline. Both are controlled. The mode prop drives the live preview only — both modes share the identical shape() grammar and validator.
| Prop | Type | Description |
|---|---|---|
| value | ShapeString | (string & {}) | Current shape() value string. Required. An empty / unparseable value seeds the default shape (a triangle). |
| onChange | (next: ShapeString) => void | Fires on every edit (canvas drag/nudge, row change, add/remove/reorder). Emits the canonical re-serialized shape() string. |
| mode | "clip-path" | "offset-path" | Which property the live preview targets. Default "clip-path" (clips a box). "offset-path" animates a dot along the path. Does NOT change validation or the onChange output. |
§ Sub-components
<ShapeCanvas value onChange className />
The draggable SVG path canvas. One node per command endpoint, a draggable Bézier control handle (with a connector line) per curve/smooth control point, and an arc endpoint node — each a labelled handle with arrow-key nudge. Dragging re-serializes via updatePoint → onChange. Omit onChange for a read-only canvas.
<CommandRow index command onChange onRemove onMoveUp onMoveDown canMoveUp canMoveDown />
One command's editor: a command-kind MiniSelect, a by/to toggle, coordinate unit-inputs, and the with/of/flag fields shown per kind. Includes remove + up/down reorder controls.
<ShapePreview value mode />
The live preview. clip-path mode clips a gradient box; offset-path mode animates a dot. Gated on CSS.supports('clip-path: shape(...)'); degrades to a raw SVG path render + a support note (Chrome 137 / Safari 18.4) where shape() is unavailable.
<MiniSelect value options onChange />
The local compact <select> chrome. Each component owns its copy (registry self-containment) — it is not imported from query-builder.
<LiveString value />
The produced value rendered in a <code> block.
§ Runtime helpers
cssShape<S>(value: S & ShapeLiteral<S>): S
Call-site validator for a shape() value. Mirrors cssClipPath() / cssTransform().
parseShape(src): { fillRule; from; commands; error }String → editor state. The runtime superset of the strict tier: tokenizes the shape() body into a fillRule, a from seed, and a discriminated ShapeCommand[], surfacing a parse error string for malformed input.
formatShape(shape): string
Canonical re-serialization of the parsed { fillRule, from, commands } back to a shape() string.
shapeToPoints(shape): CanvasPoint[] · updatePoint(shape, id, x, y)
Canvas geometry: project endpoints + Bézier control handles into a flat list of draggable { id, role, cmdIndex, x, y } points in a normalized 0..200 px space, and write one back. hline/vline/close contribute no points.
commandNames() · isCommandName(name) · commandArity(kind) · defaultShape()
The eight command names (the strict whitelist), a name guard, the coordinate-count per command kind, and a sensible default shape() seed.
§ Types
- ShapeLiteral<S>
- Strict validator — S if S is a valid shape() value, else never. Per-command dispatch is the namesake.
- ShapeString
- Suggestion union (a shape(…)-prefixed template-literal string | (string & {})). The onChange return type.
- CommandsOf<S> / CommandCountOf<S>
- The command segment tuple of a shape() value (everything after the from seed), and its length.
- ShapeCommandName
- The eight <shape-command> names (move | line | hline | vline | curve | smooth | arc | close).
- ShapeCommand / Point / ShapeValue
- The internal editor state: a discriminated command union (by kind), an { x, y } coordinate pair, and the full { fillRule, from, commands }. Exported for advanced use.
- CanvasPoint / PointRole
- A draggable canvas point ({ id, role, cmdIndex, x, y }) and its role tag (endpoint | control | control2).
§ Strict-tier scope (validated vs deferred)
- Validated: the
shape()wrapper + optional fill-rule; thefromseed; each command name; per-command arity; theby/to,with, andofkeyword slots; and every coordinate's dimension as a<length-percentage>(wiggle to 10px 10px→never;line to 100px→never;curve to 10px 10pxwithoutwith→never). - Deferred (lenient → runtime parser): the
hline/vlinekeyword positions (left/center/…, accepted only as<length-percentage>in strict); the order-freearcflags (cw/ccw/large/small/rotate <angle>, a lenient trailing tail); acurve's optional second control point validated only if present; andcalc()/var()coordinates. - Path closure, self-intersection, and geometric validity are never checked — out of strict scope.
Drop it in
One command via the shadcn CLI.
$ pnpm dlx shadcn@latest add https://ridiculous.turtlesocks.dev/r/shape-path-editor.json