DEV Community

SEN LLC
SEN LLC

Posted on

A CSS box-shadow Designer That Keeps Each Layer as a Plain Object

A CSS box-shadow Designer That Keeps Each Layer as a Plain Object

CSS box-shadow accepts a comma-separated list of shadow layers, but most visual editors only show one at a time. This tool shows all layers stacked, with per-layer controls, and the core logic is one pure function that turns an array of plain objects into the final CSS string.

Most shadow generators let you tweak one shadow. But box-shadow is inherently multi-layered — the subtle soft shadows, the neumorphism double-light trick, the layered depth technique all need 2-3 shadows composed together. I wanted a tool where every layer is visible and editable simultaneously.

🔗 Live demo: https://sen.ltd/portfolio/shadow-designer/
📦 GitHub: https://github.com/sen-ltd/shadow-designer

Screenshot

Features:

  • Multiple shadow layers — add, remove, reorder
  • Per-layer: offsetX, offsetY, blur, spread, color, inset toggle
  • 5 presets: Soft, Neumorphism, Glassmorphism, Layered, Brutalist
  • Live preview on a customizable box
  • Copy-ready CSS
  • Japanese / English, dark / light mode
  • Zero dependencies, no build step, 21 tests

The layer model

Each shadow layer is a plain object:

{
  offsetX: 4,   // px
  offsetY: 4,   // px
  blur: 8,      // px (≥ 0)
  spread: 0,    // px
  color: '#000000',
  inset: false,
}
Enter fullscreen mode Exit fullscreen mode

Converting one layer to CSS is a string join:

export function layerToCSS(layer) {
  const { offsetX = 0, offsetY = 0, blur = 0, spread = 0,
          color = '#000000', inset = false } = layer;
  const parts = inset ? ['inset'] : [];
  parts.push(`${offsetX}px`, `${offsetY}px`, `${blur}px`, `${spread}px`, color);
  return parts.join(' ');
}
Enter fullscreen mode Exit fullscreen mode

The full box-shadow value is just layers.map(layerToCSS).join(', '). That's the entire rendering pipeline. Everything else — sliders, color pickers, presets — just manipulates the array of plain objects and calls this function.

Why Neumorphism needs two layers

The classic neumorphism effect uses a light shadow from the top-left and a dark shadow from the bottom-right:

{
  name: 'Neumorphism',
  layers: [
    { offsetX: 6, offsetY: 6, blur: 12, spread: 0,
      color: 'rgba(0,0,0,0.20)', inset: false },
    { offsetX: -6, offsetY: -6, blur: 12, spread: 0,
      color: 'rgba(255,255,255,0.70)', inset: false },
  ],
}
Enter fullscreen mode Exit fullscreen mode

The negative offsets on the second layer simulate light hitting the surface from the upper-left. Without the second layer, you just get a drop shadow. With it, the element looks embossed — raised out of the surface.

This is why a multi-layer editor matters. You can't design neumorphism in a single-shadow tool.

Glassmorphism uses inset

The glass effect combines an outer glow with an inset border highlight:

{
  name: 'Glassmorphism',
  layers: [
    { offsetX: 0, offsetY: 8, blur: 32, spread: 0,
      color: 'rgba(255,255,255,0.15)', inset: false },
    { offsetX: 0, offsetY: 0, blur: 0, spread: 1,
      color: 'rgba(255,255,255,0.30)', inset: true },
  ],
}
Enter fullscreen mode Exit fullscreen mode

The inset layer with blur: 0, spread: 1 creates a 1px inner border. Combined with backdrop-filter: blur() on the element (not part of box-shadow, but often paired with it), this gives the frosted-glass look.

Layered depth: the three-shadow trick

Tobias Ahlin's "layered shadows" technique uses multiple shadows at increasing distances, each with a low opacity:

{
  name: 'Layered',
  layers: [
    { offsetX: 0, offsetY: 1, blur: 2,  spread: 0, color: 'rgba(0,0,0,0.07)' },
    { offsetX: 0, offsetY: 4, blur: 8,  spread: 0, color: 'rgba(0,0,0,0.07)' },
    { offsetX: 0, offsetY: 12, blur: 24, spread: 0, color: 'rgba(0,0,0,0.07)' },
  ],
}
Enter fullscreen mode Exit fullscreen mode

Each layer covers a different distance range. Close shadow → contact feel. Medium → lift. Far → ambient. The cumulative effect is much more natural than a single heavy shadow.

Tests

21 test cases on node --test, covering:

  • layerToCSS with standard, inset, negative offset, and all-zero inputs
  • generateCSS with single, multiple, and empty arrays
  • parsePreset returning deep copies (mutating the result doesn't affect the original)
  • Every preset produces valid CSS
  • Preset-specific structural checks (Neumorphism has 2 layers, Glassmorphism has inset, Brutalist has blur=0)
test('Neumorphism preset has exactly 2 layers', () => {
  const layers = parsePreset('Neumorphism');
  assert.strictEqual(layers.length, 2);
});

test('Glassmorphism preset has an inset layer', () => {
  const layers = parsePreset('Glassmorphism');
  assert.ok(layers.some((l) => l.inset));
});

test('Brutalist preset has blur 0 on all layers', () => {
  const layers = parsePreset('Brutalist');
  assert.ok(layers.every((l) => l.blur === 0));
});
Enter fullscreen mode Exit fullscreen mode

These tests act as documentation. Reading them tells you exactly what makes each preset distinct.

Series

This is entry #34 in my 100+ public portfolio series.

Top comments (0)