Background Editor
Ridiculously typed editor for the CSS background shorthand. An index-aware fold permits a <color> token only on the final layer, so a color in any earlier layer is a compile error. A reorderable layer stack embeds the real gradient + color pickers over a live composite preview.
Edit a background shorthand
Controlled value + onChange. The component edits the CSS background shorthand — a comma-stacked list of layers painted back-to-front, each carrying an image, a position / size, and repeat / attachment / origin / clip keywords. The popover trigger shows the layer count and a truncated preview; the panel exposes the reorderable layer stack, a 2D position pad per layer, and a live composite tile. Only the final layer may carry a <color>.
background: linear-gradient(#3b82f6, #8b5cf6) center / cover no-repeat, #0f172aThe layer stack, composited live
The hero: a reorderable stack of layers painted back-to-front. Each card embeds a real GradientEditor (or a url() input) for the image, a 2D crosshair position pad with x/y UnitInputs, a size control, and repeat / attachment / origin / clip selects. Use the ↑ / ↓ buttons to reorder the paint order — and watch the <color> always re-home onto whichever layer is last (the index-aware invariant, kept true under reorder). Only the final card exposes the ColorPicker. The live tile composites every layer in real time, exactly as the browser paints it.
background: linear-gradient(135deg, rgb(59 130 246 / 0.7), rgb(139 92 246 / 0.7)) center / cover no-repeat, radial-gradient(circle at 30% 30%, #fbbf24, transparent 60%) left top / 60% 60% no-repeat, #0f172aThe value lands on the background property. Layers join with , and paint front-to-back (the first layer sits on top); the trailing <color> is emitted only on the final layer. The checkerboard underlay makes transparency visible.
Three usage tiers
From useState-and-go to compile-time last-layer-color-invariant validation.
Pass any string
useState<string>. No compile-time validation; the runtime parser splits the value paren-aware into layers and slots every space-token by kind — image, position, the / size follower, repeat / attachment / origin / clip — exactly as the strict tier would, but lenient about token order and exotic forms.
url(photo.jpg) left top / 50% repeat-x, #fffconst [value, setValue] = useState<string>("url(photo.jpg) left top / 50% repeat-x, #fff")
Background-shaped hints
State typed as BackgroundString — a shorthand-shaped string (and the onChange return type). It stays assignable from any string literal, so you opt into the strict gate only at the call sites that want it (cssBackground()) while the editor still round-trips freely. LayersOf<S> and LayerCountOf<S> read the comma-split layer tuple off any value.
radial-gradient(#000, #fff) center, #0a0a0aconst [value, setValue] = useState<BackgroundString>("radial-gradient(#000, #fff) center, #0a0a0a")
The shorthand typed at compile time
cssBackground() splits the value into comma-stacked layers and folds them with an index-aware head/tail recursion that knows when it is at the last layer — permitting a <color> token ONLY there. A color in any non-final layer, or any unrecognized per-layer token, resolves to never before you run the code. Colors use color-picker forms (#f00 / oklch(…)), not named colors.
linear-gradient(#f00, #00f) center / cover no-repeat, #fffcssBackground("…gradient… center / cover, #fff") // ✓ color on the final layer // @ts-expect-error color in a non-final layer cssBackground("#f00 center, url(x.png)") // @ts-expect-error unknown size token cssBackground("center / wibble")
Note: per-layer token validation is membership-based and order-free — the || ordering / cardinality (at most one image, position-before-/-before- size) is deferred to the runtime parser. Image internals are lenient (any parenthesized function or none; the embedded GradientEditor validates gradients), and calc()/var() defer to runtime.
API
Public surface — component props, runtime helpers, and the type exports.
§ BackgroundEditor / BackgroundEditorPanel
<BackgroundEditor
value: BackgroundString | (string & {})
onChange: (next: BackgroundString) => void
className?: string
aria-label?: string
/>BackgroundEditor is popover-wrapped (a trigger showing the layer count + truncated value); BackgroundEditorPanel renders the same editor inline. Both are controlled. State is the parsed BgLayer[] with a lastEmittedRef resync, and a <color> that re-homes onto the final layer after any reorder.
| Prop | Type | Description |
|---|---|---|
| value | BackgroundString | (string & {}) | Current background shorthand string. Required. An empty / unparseable value seeds a default single-layer editor. |
| onChange | (next: BackgroundString) => void | Fires when the value changes. Emits the canonical re-serialized shorthand (color only on the final layer). |
| className | string | Forwarded to the popover trigger (BackgroundEditor) or the inline fieldset (BackgroundEditorPanel). |
§ Sub-components
<LayerStack layers onChange />
The reorderable vertical stack of layer cards. Renders a LayerCard per layer plus up/down/remove controls (no drag-drop dependency) and an add-layer button. The parent re-derives isFinal from array position so the color always rides the last layer.
<LayerCard index isFinal layer onChange onMoveUp onMoveDown onRemove />
One layer's editors: an embedded GradientEditor (when the image is a gradient) or a url()/none input; a PositionPad + x/y UnitInputs; a size control (cover/contain/auto vs a length); repeat / attachment / origin / clip MiniSelects. The FINAL card additionally renders a ColorPicker for the layer color.
<PositionPad x y onChange />
The 2D crosshair position picker — a role='slider' control with pointer press/drag and Arrow-key nudge (Shift = 10). Locally re-implemented (registry self-containment) — not imported from gradient-editor.
<BackgroundPreview value />
The live composite tile. Renders the produced shorthand as the tile's background style so every layer composites in real time, over a checkerboard underlay that makes transparency visible.
<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 background value rendered in a <code> block.
§ Runtime helpers
cssBackground<S>(value: S & BackgroundLiteral<S>): S
Call-site validator for a background shorthand. Mirrors cssTransform() / cssPositionArea(). The index-aware last-layer-color rule is the namesake.
parseBackground(src): { layers: BgLayer[]; error: string | null }String → editor state. The runtime superset of the strict tier: a paren-aware comma split into layers, then per-layer space-token slotting. A color in any non-final layer is an error (the index-aware invariant).
formatBackground(layers): string
Canonical re-serialization. Layers join with ', '; within a layer, position / size uses a space-separated slash; the <color> is emitted ONLY on the final layer.
classifyToken(token): BgTokenKind
The runtime mirror of the type IsBgToken: image | position | size | repeat | attachment | box | length | color | slash | unknown. A functional color (oklch(…)) is checked before the parenthesized-image rule.
repeatOptions() · attachmentOptions() · boxOptions() · sizeKeywords() · defaultBackground()
The <select> option sources (the strict whitelists) and a sensible single-layer seed value.
§ Types
- BackgroundLiteral<S>
- Strict validator — S if S is a valid background shorthand, else never. Splits into comma-stacked layers and folds them index-aware, permitting a <color> only on the final layer.
- BackgroundString
- The suggestion type (a shorthand-shaped string) and the onChange return type.
- LayersOf<S> / LayerCountOf<S>
- The comma-split layer tuple of a background value, and its length.
- BgLayer / BackgroundValue
- The internal editor state: one layer (image, position, size, repeat, attachment, origin, clip, and an optional color meaningful only on the final layer), and the layer array. Exported for advanced use.
- BgTokenKind
- The classifyToken result union (image | position | size | repeat | attachment | box | length | color | slash | unknown).
§ Strict-tier scope (validated vs deferred)
- Validated: the comma-split layer structure; the last-layer-only-<color> invariant (a color token in any non-final layer →
never); recognized per-layer token membership (image / position / size / repeat / attachment / box /<length-percentage>); and the final color via color-picker'sColorLiteral. - Deferred (lenient → runtime parser): the
||ordering / cardinality (at most one image, position-before-/-before-size) — tokens are validated by membership, in any order; gradient / image internals (any parenthesized function ornone; the embeddedGradientEditorvalidates gradients); multi-value position forms; andcalc()/var()values. - Colors use color-picker functional / hex forms (
#f00/oklch(…)), not named colors; and the/between position and size is space-separated in strict (the runtime parser handles both).
Drop it in
One command via the shadcn CLI.
$ pnpm dlx shadcn@latest add https://ridiculous.turtlesocks.dev/r/background-editor.json