I Built a Visual CSS Box Shadow Generator — Multi-Layer, Inset, Presets, 125 Tests
box-shadow is one of those CSS properties where the syntax is perfectly clear yet getting the right values by hand is painful. Is the blur too soft? Does the spread look weird at this opacity? Multi-layer shadows are even worse to iterate on manually.
I built a zero-dependency browser tool to compose box shadows visually — including multi-layer stacking, inset toggling, opacity sliders, and 12 ready-made presets.
Live tool → box-shadow-generator-ias.pages.dev
All tools → devnestio.pages.dev
What the tool does
- Full property control: horizontal offset (X), vertical offset (Y), blur radius, spread radius — all via sliders with -100/+100px ranges
-
Color + opacity:
<input type="color">for the shadow colour, separate opacity slider (0–100%) that computes the correctrgba()value - Inset toggle: one click switches between outer and inset shadow
- Multi-layer shadows: add and remove layers independently; each layer has its own controls; the CSS output stacks them comma-separated
- Live preview: 140×140 card updates on every slider move; choose square, rounded, or circle card shape; three preview background options (dark, light, darker)
- 12 presets: Soft, Sharp, Elevated (2-layer), Glow, Inset, Neumorphic (2-layer), Danger, Deep (3-layer), Flat, Ambient, Neon (2-layer), None
- CSS output with one-click copy
Single index.html, no npm, no build step.
The CSS box-shadow value format
The full syntax for a single shadow layer is:
box-shadow: [inset] <offset-x> <offset-y> <blur-radius> <spread-radius> <color>;
Multi-layer shadows are comma-separated:
box-shadow:
0px 2px 4px -1px rgba(0,0,0,0.20),
0px 8px 24px -4px rgba(0,0,0,0.15);
The tool outputs exactly this format, with the standard box-shadow: property.
Architecture: pure functions for testability
Same pattern I use across all my devnestio tools — extract computation into pure functions, test them without a browser.
function hexToRgba(hex, opacity) {
const clean = hex.replace('#', '');
const full = clean.length === 3
? clean.split('').map(c => c + c).join('')
: clean;
const r = parseInt(full.slice(0, 2), 16);
const g = parseInt(full.slice(2, 4), 16);
const b = parseInt(full.slice(4, 6), 16);
const a = parseFloat((opacity / 100).toFixed(2));
if (a >= 1) return `#${full.toLowerCase()}`;
return `rgba(${r},${g},${b},${a})`;
}
function buildShadowString(layer) {
const inset = layer.inset ? 'inset ' : '';
const color = hexToRgba(layer.color, layer.opacity);
return `${inset}${layer.offsetX}px ${layer.offsetY}px ${layer.blur}px ${layer.spread}px ${color}`;
}
function buildBoxShadowCSS(layers) {
if (layers.length === 0) return 'none';
return layers.map(buildShadowString).join(',\n ');
}
function buildFullCSS(layers) {
return `box-shadow: ${buildBoxShadowCSS(layers)};`;
}
State is a plain array of layer objects. The UI reads sliders, updates the active layer, and re-runs these functions on every change.
125 tests, zero framework
✅ 125 passed, ❌ 0 failed (125 total)
Node.js assert only. The test file mirrors every pure function:
// hexToRgba
test('opacity 100 returns hex', () => {
assert.strictEqual(hexToRgba('#000000', 100), '#000000');
});
test('opacity 50 returns rgba 0.50', () => {
assert.strictEqual(hexToRgba('#000000', 50), 'rgba(0,0,0,0.5)');
});
test('3-char hex expanded', () => {
assert.strictEqual(hexToRgba('#f00', 100), '#ff0000');
});
// buildShadowString
test('inset prefix present', () => {
assert.ok(buildShadowString(defaultLayer({ inset: true })).startsWith('inset '));
});
test('negative offsetX', () => {
assert.ok(buildShadowString(defaultLayer({ offsetX: -10 })).includes('-10px'));
});
// buildBoxShadowCSS
test('empty layers returns none', () => {
assert.strictEqual(buildBoxShadowCSS([]), 'none');
});
test('two layers joined with comma', () => {
assert.ok(buildBoxShadowCSS([defaultLayer(), defaultLayer()]).includes(','));
});
// buildFullCSS
test('starts with box-shadow:', () => {
assert.ok(buildFullCSS([defaultLayer()]).startsWith('box-shadow:'));
});
test('ends with semicolon', () => {
assert.ok(buildFullCSS([defaultLayer()]).endsWith(';'));
});
Tests also cover: clamp, validateHex, opacityToAlpha, alphaToOpacity, defaultLayer, and integration tests for every preset.
The neumorphic preset is interesting
Neumorphism needs two shadows with opposite signs — one dark, one light — to create the embossed look:
box-shadow:
6px 6px 12px 0px rgba(0,0,0,0.15),
-6px -6px 12px 0px rgba(255,255,255,0.70);
The tool makes this easy: add two layers, set one to positive offsets with a dark semi-transparent colour, the other to negative offsets with a near-opaque white. The live preview shows the effect immediately against the card colour you choose.
Opacity is a slider, not a hex alpha
I chose a 0–100% opacity slider rather than an 8-character hex colour (#00000040) for a simple reason: most designers think in opacity percentages, not hex fractions. The function converts internally:
const a = parseFloat((opacity / 100).toFixed(2));
// 25 → 0.25 → rgba(0,0,0,0.25)
// 100 → 1.00 → #000000 (opaque shorthand)
At 100% opacity the output falls back to a plain hex value since rgba(0,0,0,1) and #000000 are equivalent, but the shorter form is cleaner to read.
Running locally
# No install — open directly
open src/index.html
# Tests
npm test
# ✅ 125 passed, ❌ 0 failed
The tool
box-shadow-generator-ias.pages.dev — free, no login, works offline.
Part of devnestio.pages.dev — a growing set of single-file browser tools for developers.
What's your go-to shadow style — soft ambient, sharp offset, or full neumorphic? Drop it in the comments.
Top comments (0)