/ component

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.

/ basic-usage

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)
/ live-preview

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.

preview
drag the light
0px 1px 2px rgb(0 0 0 / 0.2), 0px 6px 12px rgb(0 0 0 / 0.18)
preview
drag the light
0px 1px 2px rgb(0 0 0 / 0.2), 0px 6px 12px rgb(0 0 0 / 0.18)
/ types

Three usage tiers

From useState-and-go to per-layer token-typed validation.

01 casual
string

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)")
02 intellisense
BoxShadowString

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 #0003
const [value, setValue] = useState<BoxShadowString>("0px 4px 8px #0003")
03 strict
BoxShadowLiteral<S>

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

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.

PropTypeDescription
valueBoxShadowString | (string & {})Current CSS box-shadow value. Required. `none` is the empty state.
onChange(next: BoxShadowString) => voidFires 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-layer SplitBySpace โ†’ 2-4 IsLength tokens (offset-x, offset-y required; blur, spread optional), with blur non-negative.
  • At most one inset keyword, leading or trailing only. A mid-token or doubled inset resolves to never.
  • 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 in ColorLiteral โ€” they resolve to never at the strict tier (use hex / functional). The runtime parser accepts them.
  • Strict accepts color-last only. A leading-color layer (#000 0px 4px) resolves to never, 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 to never at 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.
/ install

Drop it in

One command via the shadcn CLI.

$ pnpm dlx shadcn@latest add https://turtiesocks.github.io/ridiculous/r/box-shadow-editor.json