Box Shadow Editor
Edit the CSS box-shadow property โ a comma-separated list of shadow layers โ with compile-time PER-LAYER TOKEN VALIDATION. The list splits by comma into layers, each layer splits by space into tokens, and every layer is checked for 2-4 lengths (offset-x, offset-y required; blur non-negative; spread optional), at most one inset keyword (leading or trailing), and at most one trailing color validated against the color-picker ColorLiteral. 0px 4px red โ never (bare keyword color); 0px 4px -8px โ never (negative blur); #000 0px 4px โ never (leading color). The strict tier resolves any violation to never.
Stack shadow layers
Controlled value + onChange. Add layers, toggle inset, edit each offset / blur / spread with the right units, pick a per-layer color, and the editor emits a valid comma-separated box-shadow string.
0px 1px 2px rgb(0 0 0 / 0.2), 0px 4px 8px rgb(0 0 0 / 0.15)Drag the light, stack the shadow
The preview card carries the whole shadow stack. Drag the glowing light source and every layer's offset-x / offset-y re-casts away from it; the elevation scrubber scales the blur. Add and remove layers and pick each layer's color in the panel โ every change writes back the typed string.
0px 1px 2px rgb(0 0 0 / 0.2), 0px 6px 12px rgb(0 0 0 / 0.18)Three usage tiers
From useState-and-go to per-layer token-typed validation.
Pass any string
useState<string>. No compile-time validation; the runtime parser handles whatever you type โ including bare keyword colors like red, calc(), and a leading color.
0px 4px 8px rgb(0 0 0 / 0.2)const [value, setValue] = useState<string>("0px 4px 8px rgb(0 0 0 / 0.2)")
Literal-shaped hints
State typed as BoxShadowString โ a single space-separated layer, a comma-joined multi-layer list, or none. It is also the onChange return type.
0px 4px 8px #0003const [value, setValue] = useState<BoxShadowString>("0px 4px 8px #0003")
Per-layer token typing at compile time
cssBoxShadow() splits the list by comma, then each layer by space, and resolves a bad arity, a negative blur, a misplaced inset, or a non-ColorLiteral color to never โ a type error before you run the code.
inset 0px 0px 10px 2px #000, 0px 4px 8px rgb(0 0 0 / 0.2)cssBoxShadow("inset 0px 0px 10px 2px #000, 0px 4px 8px #0003") // โ // @ts-expect-error bare keyword color cssBoxShadow("0px 4px red") // @ts-expect-error negative blur cssBoxShadow("0px 4px -8px")
Note: bare keyword colors (red) and calc() / var() are rejected at the strict tier โ use the casual or IntelliSense tier; the runtime parser accepts them.
API
Public surface โ component props, runtime helpers, and the type exports.
ยง BoxShadowEditor / BoxShadowEditorPanel
<BoxShadowEditor
value: BoxShadowString | (string & {})
onChange: (next: BoxShadowString) => void
className?: string
aria-label?: string
/>BoxShadowEditor is popover-wrapped; BoxShadowEditorPanel renders the same editor inline. Both are controlled.
| Prop | Type | Description |
|---|---|---|
| value | BoxShadowString | (string & {}) | Current CSS box-shadow value. Required. `none` is the empty state. |
| onChange | (next: BoxShadowString) => void | Fires when the edited stack changes. Emits the normalized comma-separated string (inset leading, color last) or `none`. |
ยง Sub-components
<ShadowLayerRow layer onChange onRemove index? />
One editable layer โ an inset toggle, offset-x / offset-y / blur / spread length editors, a per-layer color control (a ColorPicker with an add / remove affordance), and a remove button.
<ShadowLengthEditor label value onChange allowNegative? />
A single length editor: a numeric field plus a unit select (px/rem/em/%/vw/vh); raw text passthrough for opaque calc()/var(). `allowNegative` gates the leading minus (offsets + spread allow it; blur does not).
<AddLayerButton onAdd />
Appends a fresh layer (a soft drop shadow) via onAdd.
<BoxShadowPreview value onChange? />
The showcase: a card carrying the stacked shadow, a draggable light source that re-casts every layer's offsets away from it, and a UnitInput elevation scrubber that scales blur across the stack.
ยง Runtime helpers
cssBoxShadow<S extends string>(value: S & BoxShadowLiteral<S>): S
Call-site validator. Mirrors cssFilter() / cssTransform() / color() / easing().
parseBoxShadow(src: string): ShadowLayer[] | null
String โ typed layers, or null on arity / duplicate / syntax error. `none` and empty โ []. Tolerates calc()/var(), bare keyword colors, and a leading color (normalized to color-last).
formatBoxShadow(layers: ShadowLayer[]): string
Canonical re-serialization (inset leading, color last; layers joined by `, `). Empty list โ `none`.
layerToCss(layer: ShadowLayer): string
One layer โ its CSS string.
boxShadowLayerCount(src: string): number
Runtime mirror of LayerCountOf โ the number of layers (invalid โ 0).
defaultLayer(): ShadowLayer
Seed a fresh layer โ a soft drop shadow (0px 4px 8px rgb(0 0 0 / 0.25)).
ยง Types
- BoxShadowLiteral<S>
- Strict validator โ S if every layer validates (inset placement + 2-4 lengths + optional trailing ColorLiteral; blur non-negative), else never.
- ShadowLayerLiteral<S>
- Strict single-layer validator (a comma-list is not a single layer).
- BoxShadowString
- Suggestion union: a single space-separated layer, a comma-joined list, or `none`.
- BoxShadowStringMap / BoxShadowKind
- Layer-kind โ output-string map (inset / outset) and its key union.
- LayersOf<S> / LayerCountOf<S>
- Tuple of the raw per-layer strings; and its length.
- HasInset<S> / IsInsetLayer<S>
- Whether any layer is inset; whether a single layer is inset.
- ShadowLayer
- Per-layer record state โ { inset, offsetX, offsetY, blur?, spread?, color? }. Exported for advanced use.
ยง Strict-tier scope
- Full per-layer token validation:
SplitByCommaโ per-layerSplitBySpaceโ 2-4IsLengthtokens (offset-x, offset-y required; blur, spread optional), withblurnon-negative. - At most one
insetkeyword, leading or trailing only. A mid-token or doubledinsetresolves tonever. - At most one trailing color, validated against the color-picker's
ColorLiteral. A functional color whose own body has spaces and a slash โrgb(0 0 0 / 0.2)โ stays one token thanks to the paren-aware split. - Bare keyword colors (
red) are not inColorLiteralโ they resolve toneverat the strict tier (use hex / functional). The runtime parser accepts them. - Strict accepts color-last only. A leading-color layer (
#000 0px 4px) resolves tonever, but the runtime parser accepts it and normalizes to color-last. - Dimension + arity + placement only โ numeric magnitudes are not range-checked (beyond the blur-non-negative rule).
calc()/var()inside a token resolve toneverat the strict tier (undecidable at compile time). The runtime parser accepts them.- The comma layer-list recursion is capped at 32 layers (the tail is weak-validated past the cap); the runtime parser validates fully.
Drop it in
One command via the shadcn CLI.
$ pnpm dlx shadcn@latest add https://turtiesocks.github.io/ridiculous/r/box-shadow-editor.json