A CSS shadow feels like a designer's dark art — you nudge numbers until it looks right and never quite know why. But a box-shadow is not a picture; it is a tiny recipe of four lengths and a colour, and once you see it that way, building a generator for it has almost no algorithm at all. The cleverness is entirely in the data model. I built one in vanilla JavaScript — no library — and it reminded me that the hard part of a visual tool is usually the data, not the drawing.
Model one shadow as a plain object
The CSS syntax [inset] <x> <y> <blur> <spread> <color> maps one-to-one onto an object. I keep the four lengths as plain pixel numbers, the colour as an {r,g,b} triple, and the transparency as a separate opacity percentage:
const layer = {
x: 0, y: 10, // offset-x, offset-y (px)
blur: 25, spread: -5, // blur-radius, spread-radius (px)
color: { r:15, g:23, b:42 },
opacity: 18, // % → the colour's alpha
inset: false
};
Splitting colour from opacity is deliberate: it lets one slider drive the alpha without touching the hue — and the alpha is where believable shadows are won or lost.
Serialise one object to a string
Turning an object into a valid box-shadow fragment is just pushing parts into an array in CSS order and joining with spaces. Building an array (rather than string-concatenating) keeps the optional inset keyword clean and the output free of stray spaces:
function rgba(layer){
const { r, g, b } = layer.color;
return `rgba(${r}, ${g}, ${b}, ${layer.opacity / 100})`;
}
function shadowToString(s){
const parts = [];
if (s.inset) parts.push("inset");
parts.push(s.x + "px", s.y + "px", s.blur + "px", s.spread + "px", rgba(s));
return parts.join(" ");
}
// { x:0, y:10, blur:25, spread:-5, opacity:18 }
// → "0px 10px 25px -5px rgba(15, 23, 42, 0.18)"
Stacked shadows fall out for free
Here is the part that turns box-shadow from a toy into a design tool. A stacked shadow is nothing more than several fragments in one comma-separated list, so the whole design is one array joined with commas:
function joinShadows(list){
return list.map(shadowToString).join(", ");
}
That single string is the complete value — the same text you would type by hand. So the entire state of the tool is just an array plus a selected index:
let shadows = [
{ x:0, y:10, blur:25, spread:-5, color:{r:15,g:23,b:42}, opacity:15, inset:false },
{ x:0, y:4, blur:6, spread:-2, color:{r:15,g:23,b:42}, opacity:10, inset:false }
];
let selected = 0; // which layer the editor edits
render() hands the string to the browser
The render is the payoff, and it is anticlimactic in the best way. Build the value once, assign it to the element's style.boxShadow, and mirror it into the read-only output. There is no canvas and no drawing loop — the browser already knows how to paint shadows:
function render(){
const css = joinShadows(shadows);
box.style.boxShadow = css; // the browser paints it
cssOut.textContent = "box-shadow: " + css + ";";
renderLayerList();
syncEditor();
}
Sliders mutate shadows[selected] and call render(); add/remove push and splice the array. Because the model is plain data, wiring a control is a one-liner and the preview updates on every pixel of the drag.
Five things that surprised me
Building it forced me to actually understand shadows, and a few things stuck:
-
The colour's alpha is the whole trick. A solid black shadow is a photocopier smudge; real ones are
rgba(0,0,0,0.1)-ish. Almost every "why does my shadow look cheap" problem is an opacity that is too high. - Blur and spread are different. Blur softens the edge outward; spread grows or shrinks the shadow's size before it is blurred. A negative spread is the secret to a tight, believable drop.
- The list is a stacking order — and the reverse of what people expect. The first shadow paints on top of the later ones. So list the sharp contact shadow first, the wide ambient one after.
-
insetflips the light inward, carving the shadow into the box — that is how you get pressed buttons and inner wells. -
Shadows cost no layout space and follow the box's
border-radiusautomatically, which is why they beat faking depth with a border or an extra element.
Believable elevation comes from stacking a tight, darker contact shadow over a wide, fainter ambient one — mimicking how a direct light and the sky both cast shadows. Model the shadow well and the CSS engine does the painting for you.
Drag the sliders, stack a few layers, and copy the exact value out:
https://dev48v.infy.uk/solve/day35-box-shadow-generator.html
Top comments (0)