translate(x, y) moves the origin, rotate(angle) turns about it (radians, clockwise) and scale(sx, sy) stretches it by any factor, mirroring when negative. Each call multiplies the current matrix. transform() multiplies by any matrix (transform(1, 0, -0.4, 1, 0, 0) skews), setTransform() replaces it and getTransform() returns it as a DOMMatrix.
<canvas id="c" style="width:600px; height:120px"></canvas><script src="hidpi.js"></script>
<script>
const ctx = hidpi(c);
['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'].forEach((color, i) => {
ctx.save(); ctx.translate(100, 160); ctx.rotate((i - 2.5) * 0.2); // pivot below
ctx.fillStyle = color; ctx.fillRect(-14, -155, 28, 80); ctx.restore();
});
ctx.font = 'bold 34px Georgia'; ctx.fillStyle = '#1f5f8b';
ctx.fillText('BookNest', 220, 60);
ctx.save(); ctx.translate(0, 128); ctx.scale(1, -1); // mirror about y = 64
ctx.globalAlpha = 0.25; ctx.fillText('BookNest', 220, 60); ctx.restore();
</script>
To rotate about a point, translate to it, rotate and translate back.
<!doctype html>
<style>
body { margin: 0; padding: 8px; font: 13px system-ui, sans-serif; background: #fafaf7; }
canvas { width: 100%; max-width: 600px; height: 170px; display: block; background: #fff; border: 1px solid #ddd; }
</style>
<canvas id="c"></canvas>
<label>Fan angle <input type="range" id="fan" min="0" max="40" value="16"></label>
<script>
function hidpi(c, r = devicePixelRatio) {
c.width = c.clientWidth * r; c.height = c.clientHeight * r;
const ctx = c.getContext('2d'); ctx.scale(r, r); return ctx;
}
const ctx = hidpi(document.getElementById('c'));
const colors = ['#1f5f8b', '#5b3f99', '#e09a10', '#3f7d3a', '#b5452f', '#2a9d8f'];
const base = ctx.getTransform(); // the hidpi scale, as a DOMMatrix
function draw(fanDegrees) {
ctx.setTransform(base); // replace, don't multiply
ctx.clearRect(0, 0, 600, 170);
// Rotate each spine about a pivot below the books: translate there, then rotate
colors.forEach((color, i) => {
ctx.save();
ctx.translate(120, 200);
ctx.rotate((i - 2.5) * fanDegrees * Math.PI / 180);
ctx.fillStyle = color; ctx.fillRect(-14, -185, 28, 90);
ctx.restore();
});
ctx.font = 'bold 40px Georgia'; ctx.fillStyle = '#1f5f8b';
ctx.fillText('BookNest', 230, 80);
// scale(1, -1) mirrors about the origin, so translate to y = 2 × 84 first
ctx.save(); ctx.translate(0, 168); ctx.scale(1, -1);
ctx.globalAlpha = 0.2; ctx.fillText('BookNest', 230, 80); ctx.restore();
// transform(a, b, c, d, e, f) multiplies by any matrix: here a horizontal skew
ctx.save(); ctx.transform(1, 0, -0.6, 1, 0, 0);
ctx.fillStyle = 'rgb(0 0 0 / 0.15)'; ctx.fillRect(560, 40, 26, 90); ctx.restore();
ctx.fillStyle = '#b5452f'; ctx.fillRect(500, 40, 26, 90);
const m = ctx.getTransform();
ctx.font = '11px monospace'; ctx.fillStyle = '#263238';
ctx.fillText(`getTransform(): a=${m.a.toFixed(2)} d=${m.d.toFixed(2)}`, 230, 160);
}
const slider = document.getElementById('fan');
slider.addEventListener('input', () => draw(+slider.value));
draw(16);
</script>