First published on Homingo Learn.
Flat isometric boxes look like paper cut-outs. Hand-picking three tones for the top, left and right face of every part works for one box, and falls apart at twenty boxes in five colours: you end up with faces that don't agree about where the light is.
The fix is small. Choose one light direction, measure how directly each of the three visible faces points at it, and derive every tone from that. This tutorial does it in about 30 lines of JavaScript with no libraries.
1. The three faces and their normals
The projection in the listing puts +x running down-right on screen, +y down-left and +z up, so the viewer sits on the high side of all three axes and exactly three faces of a box are ever visible. Each face is described by its normal, the direction it points:
const NORMALS = { top: [0, 0, 1], left: [0, 1, 0], right: [1, 0, 0] }; The left face looks toward +y, the right face toward +x.
2. One light, and Lambert's rule
Pick the light as a vector pointing from the scene toward the lamp, and normalise it to length 1 with a small helper, unit(), so that the dot product below is a plain number between -1 and 1 (negative means the face turns away from the light). Mine comes from above and a little toward the left face:
const LIGHT = unit([-0.3, 1, 1.6]); A face is brightest when its normal points at the light, and dark when it points away. The dot product measures exactly that, so lambert(n) is max(0, n . LIGHT). Add an ambient floor so a face turned away from the light is dark rather than black: brightness is AMBIENT + (1 - AMBIENT) * lambert(n), and each colour channel is multiplied by it.
I divide by the top face's brightness, so the top keeps the exact colour you chose and the sides are darker versions of it. That only holds while the top is the brightest face; with a low light a side can beat the top, and Math.min(255, ...) then clips channels, which can shift the hue. The colour in your design file is the colour of the top.
3. The full listing
// shade.mjs: three face tones for an isometric box from one light direction. No libraries.
// Run: node shade.mjs prints the tones for a few colours and writes shade.svg
import { writeFileSync } from 'node:fs';
const COS30 = Math.cos(Math.PI / 6), S = 40;
const project = ([x, y, z], ox, oy) => [ox + (x - y) * COS30 * S, oy + (x + y) * 0.5 * S - z * S];
// Normals of the three faces the viewer sees (viewer at +x +y +z), plus the light as a unit vector.
const NORMALS = { top: [0, 0, 1], left: [0, 1, 0], right: [1, 0, 0] }; // left face looks toward +y, right toward +x
const unit = v => { const n = Math.hypot(...v); return v.map(c => c / n); };
const LIGHT = unit([-0.3, 1, 1.6]); // from above, a little toward the left face
const AMBIENT = 0.35;
const lambert = n => Math.max(0, n[0] * LIGHT[0] + n[1] * LIGHT[1] + n[2] * LIGHT[2]);
const hex = c => '#' + c.map(v => Math.round(v).toString(16).padStart(2, '0')).join('');
const rgb = h => [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16));
// brightness = ambient + (1 - ambient) * lambert, then scale the base colour, normalised so the lit top keeps the base colour.
const raw = Object.fromEntries(Object.entries(NORMALS).map(([k, n]) => [k, AMBIENT + (1 - AMBIENT) * lambert(n)]));
const top = raw.top;
export function tones(base) {
const c = rgb(base);
return Object.fromEntries(Object.keys(raw).map(k => [k, hex(c.map(v => Math.min(255, v * raw[k] / top)))]));
}
const colors = ['#2563eb', '#16a34a', '#b45309'];
console.log('brightness top/left/right:', ['top', 'left', 'right'].map(k => (raw[k] / top).toFixed(2)).join(' '));
for (const c of colors) console.log(c, JSON.stringify(tones(c)));
let svg = '
colors.forEach((base, i) => {
const t = tones(base), ox = 90 + i * 120, oy = 90, w = 1.5, d = 1.5, h = 1.5;
const p = pt => project(pt, ox, oy).map(v => v.toFixed(1)).join(',');
svg += <polygon points="${p([0,0,h])} ${p([w,0,h])} ${p([w,d,h])} ${p([0,d,h])}" fill="${t.top}"/>;
svg += <polygon points="${p([0,d,h])} ${p([w,d,h])} ${p([w,d,0])} ${p([0,d,0])}" fill="${t.left}"/>;
svg += <polygon points="${p([w,0,h])} ${p([w,d,h])} ${p([w,d,0])} ${p([w,0,0])}" fill="${t.right}"/>;
});
writeFileSync('shade.svg', svg + '');
Run node shade.mjs. It printed this for me:
brightness top/left/right: 1.00 0.77 0.39 #2563eb {"top":"#2563eb","left":"#1d4cb5","right":"#0e275c"} #16a34a {"top":"#16a34a","left":"#117e39","right":"#09401d"} #b45309 {"top":"#b45309","left":"#8b4007","right":"#462004"} It also writes shade.svg, with one box in each colour. The left face is 0.77 of the top's brightness and the right face is 0.39. With this light the right face's Lambert term is exactly 0, so its tone is pure ambient: 0.35 divided by the top's raw brightness. That is why AMBIENT is the control to adjust.
4. Moving the light
Change LIGHT and run it again. With unit([1, -0.3, 1.6]) the result mirrors: the left face drops to 0.39 and the right face rises to 0.77, because the light now comes from the right. Raising AMBIENT flattens the whole box, so if the darkest face looks too black, raise AMBIENT first.
Where this stops being enough
This is flat shading per face. It doesn't do cast shadows, and it treats every face as matte, so metal or glass needs more. The code also multiplies sRGB values directly instead of working in linear light, so this is an approximation of Lambert shading, not the physical thing. It also assumes the light is the same everywhere, which suits technical drawings but isn't realistic for a scene with a lamp inside it.
Disclosure: I'm the founder of Homingo; Homingo sells the product below, and this article was written with AI assistance, with the code run before publishing.
If you want finished machine figures without hand-writing the geometry: Isometric Explainers is our Claude Code skill. Claude Code draws how-it-works machines for your docs, blogs and landing pages, and one command turns the same figure into a 1080x1920 explainer reel with camera moves, cutaways and callouts timed to narration. Skill Rs 1,499 (about US$17), Complete Kit Rs 2,499 (about US$28; adds 3 more machine reel projects, the finished demo reel, 4 video lessons and a written tutorial), one-time, prices in INR, international cards accepted; it needs Claude access with Claude Code and Node.js 20.10+, Claude plans not included: Isometric Explainers.
