A CSS box-shadow Designer That Keeps Each Layer as a Plain Object
CSS
box-shadowaccepts 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
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,
}
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(' ');
}
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 },
],
}
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 },
],
}
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)' },
],
}
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:
-
layerToCSSwith standard, inset, negative offset, and all-zero inputs -
generateCSSwith single, multiple, and empty arrays -
parsePresetreturning 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));
});
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.
- 📦 Repo: https://github.com/sen-ltd/shadow-designer
- 🌐 Live: https://sen.ltd/portfolio/shadow-designer/
- 🏢 Company: https://sen.ltd/

Top comments (0)