Backgrounds
BlueprintGrid
Cyanotype drafting paper. A drafting head traces a gear, its section and true dimensions, then the sheet clears for the next size. The pointer reads coordinates.
Download
- React
- Tailwind CSS v4
components/ui/blueprint-grid.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
type BlueprintGridProps = {
children?: ReactNode;
className?: string;
/** Text in the title block, bottom right. */
title?: string;
/** Paper colour. */
paper?: string;
/** Line colour. */
ink?: string;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
};
type Pt = [number, number];
type Label = { x: number; y: number; text: string };
type Stroke = { pts: Pt[]; len: number; bold?: boolean; dash?: boolean; label?: Label };
const CELL = 12;
const TEETH = [24, 32, 40, 48];
/* Seconds to draft one sheet at speed 1. */
const DRAW_TIME = 16;
const circle = (cx: number, cy: number, r: number): Pt[] =>
Array.from({ length: 49 }, (_, i) => [cx + r * Math.cos((i / 48) * Math.PI * 2), cy + r * Math.sin((i / 48) * Math.PI * 2)]);
const stroke = (pts: Pt[], opts: Omit<Stroke, "pts" | "len"> = {}): Stroke => {
let len = 0;
for (let i = 1; i < pts.length; i++) len += Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
return { pts, len, ...opts };
};
/* 45 degree section lines, cut to a rectangle. */
function hatch(l: number, t: number, r: number, b: number, gap: number) {
const out: Stroke[] = [];
for (let c = l + t + gap; c < r + b; c += gap) {
const x0 = Math.max(l, c - b);
const x1 = Math.min(r, c - t);
if (x1 > x0) out.push(stroke([[x0, c - x0], [x1, c - x1]]));
}
return out;
}
/* One sheet: a spur gear in front view and, when there is room, in section beside it. Sizes come
from the tooth count, so the numbers on the drawing are the true ones for that gear. */
function draft(w: number, h: number, teeth: number) {
const wide = w / h > 1.25;
const u = Math.min(wide ? w * 0.5 : w, h) / 100;
const cx = wide ? w * 0.34 : w / 2;
const cy = h * 0.46;
const R = 33 * u;
const root = R * 0.86;
const bore = R * 0.2;
const size = 2.5;
const tip = (teeth + 2) * size;
const strokes: Stroke[] = [];
const reach = wide ? w * 0.92 : cx + R + 6 * u;
strokes.push(stroke([[cx - R - 6 * u, cy], [reach, cy]], { dash: true }));
strokes.push(stroke([[cx, cy - R - 6 * u], [cx, cy + R + 6 * u]], { dash: true }));
const pitch = (Math.PI * 2) / teeth;
const tooth: Pt[] = [];
for (let i = 0; i < teeth; i++) {
const a = i * pitch;
for (const [da, r] of [[0, root], [0.16, R], [0.38, R], [0.54, root], [1, root]] as const) {
tooth.push([cx + r * Math.cos(a + da * pitch), cy + r * Math.sin(a + da * pitch)]);
}
}
tooth.push(tooth[0]);
strokes.push(stroke(tooth, { bold: true }));
strokes.push(stroke(circle(cx, cy, bore), { bold: true }));
strokes.push(stroke(circle(cx, cy, R * 0.52), { dash: true }));
for (let i = 0; i < 6; i++) {
const a = (i / 6) * Math.PI * 2 + 0.26;
strokes.push(stroke(circle(cx + R * 0.52 * Math.cos(a), cy + R * 0.52 * Math.sin(a), R * 0.08), { bold: true }));
}
const under = cy + R + 9 * u;
strokes.push(stroke([[cx - R, cy + R * 0.4], [cx - R, under + 3]]));
strokes.push(stroke([[cx + R, cy + R * 0.4], [cx + R, under + 3]]));
strokes.push(stroke([[cx - R, under], [cx + R, under]], { label: { x: cx - R + 4, y: under - 5, text: `Ø${tip}` } }));
const hole: Pt = [cx + R * 0.52 * Math.cos(0.26 + (Math.PI * 5) / 3), cy + R * 0.52 * Math.sin(0.26 + (Math.PI * 5) / 3)];
strokes.push(stroke([hole, [cx + R * 0.9, cy - R * 1.02], [cx + R * 1.28, cy - R * 1.02]], { label: { x: cx + R * 0.92, y: cy - R * 1.02 - 5, text: `Z${teeth} M${size}` } }));
if (wide) {
const x = w * 0.66;
const t = 16 * u;
const web = t * 0.3;
const y = (k: number) => cy + k * R;
strokes.push(stroke([[cx, y(-1)], [x, y(-1)]], { dash: true }));
strokes.push(stroke([[cx, y(1)], [x, y(1)]], { dash: true }));
for (const s of [-1, 1]) {
strokes.push(
stroke(
[
[x, y(s)], [x + t, y(s)], [x + t, y(s * 0.72)], [x + t - web, y(s * 0.72)], [x + t - web, y(s * 0.36)],
[x + t, y(s * 0.36)], [x + t, y(s * 0.2)], [x, y(s * 0.2)], [x, y(s * 0.36)], [x + web, y(s * 0.36)],
[x + web, y(s * 0.72)], [x, y(s * 0.72)], [x, y(s)],
],
{ bold: true },
),
);
const band = (a: number, b: number) => [Math.min(y(s * a), y(s * b)), Math.max(y(s * a), y(s * b))];
const [t1, b1] = band(1, 0.72);
const [t2, b2] = band(0.72, 0.36);
const [t3, b3] = band(0.36, 0.2);
strokes.push(...hatch(x, t1, x + t, b1, 2.4 * u), ...hatch(x + web, t2, x + t - web, b2, 2.4 * u), ...hatch(x, t3, x + t, b3, 2.4 * u));
}
const side = x + t + 8 * u;
strokes.push(stroke([[x + t + 2, y(-1)], [side + 3, y(-1)]]));
strokes.push(stroke([[x + t + 2, y(1)], [side + 3, y(1)]]));
strokes.push(stroke([[side, y(-1)], [side, y(1)]], { label: { x: side + 5, y: cy - 5, text: `${teeth * size} PCD` } }));
}
return { strokes, total: strokes.reduce((sum, s) => sum + s.len, 0) };
}
/* BlueprintGrid: cyanotype drafting paper. A drafting head traces a gear, section and dimensions
line by line, then the sheet clears for the next size. The pointer reads coordinates off it. */
export function BlueprintGrid({
children,
className = "",
title = "SPUR GEAR",
paper = "#0d3b78",
ink = "#dcecff",
speed = 1,
}: BlueprintGridProps) {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!root || !canvas || !ctx) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const sheet = document.createElement("canvas");
let w = 0;
let h = 0;
let raf = 0;
let last = 0;
let turn = 0;
let sheetNo = 1;
let plan = draft(1, 1, TEETH[0]);
let done = 0;
let hold = 0;
let fade = 1;
let aim: Pt | null = null;
const paintPaper = () => {
const g = sheet.getContext("2d");
if (!g) return;
g.setTransform(1, 0, 0, 1, 0, 0);
g.fillStyle = paper;
g.fillRect(0, 0, w, h);
g.strokeStyle = ink;
for (const [every, alpha] of [[1, 0.06], [5, 0.13]]) {
g.globalAlpha = alpha;
g.beginPath();
for (let x = 0; x < w; x += CELL * every) g.rect(Math.round(x) + 0.5, -1, 0, h + 2);
for (let y = 0; y < h; y += CELL * every) g.rect(-1, Math.round(y) + 0.5, w + 2, 0);
g.stroke();
}
// Sun-printed paper is never even: a soft bleach in the middle, darker at the edges.
const age = g.createRadialGradient(w / 2, h / 2, 0, w / 2, h / 2, Math.hypot(w, h) / 1.7);
age.addColorStop(0, "rgba(120,180,255,0.1)");
age.addColorStop(1, "rgba(2,14,40,0.42)");
g.globalAlpha = 1;
g.fillStyle = age;
g.fillRect(0, 0, w, h);
g.globalAlpha = 0.5;
g.strokeRect(8.5, 8.5, w - 17, h - 17);
g.globalAlpha = 1;
};
const tell = (l: Label) => {
ctx.fillText(l.text, l.x, l.y);
};
const draw = () => {
ctx.drawImage(sheet, 0, 0, w, h);
ctx.strokeStyle = ink;
ctx.fillStyle = ink;
ctx.lineJoin = "round";
ctx.font = `${Math.max(9, Math.min(w, h) * 0.022)}px ui-monospace, Menlo, Consolas, monospace`;
ctx.globalAlpha = fade;
let left = done;
let pen: Pt = [w / 2, h / 2];
for (const s of plan.strokes) {
if (left <= 0) break;
ctx.lineWidth = s.bold ? 1.5 : 0.8;
ctx.setLineDash(s.dash ? [12, 3, 2, 3] : []);
ctx.beginPath();
ctx.moveTo(s.pts[0][0], s.pts[0][1]);
pen = s.pts[0];
for (let i = 1; i < s.pts.length && left > 0; i++) {
const [ax, ay] = s.pts[i - 1];
const [bx, by] = s.pts[i];
const leg = Math.hypot(bx - ax, by - ay);
const t = Math.min(1, left / (leg || 1));
pen = [ax + (bx - ax) * t, ay + (by - ay) * t];
ctx.lineTo(pen[0], pen[1]);
left -= leg;
}
ctx.stroke();
if (left > 0 && s.label) tell(s.label);
}
ctx.setLineDash([]);
if (w >= 420) {
const bw = 158;
const bh = 48;
const x = w - bw - 16;
const y = h - bh - 16;
ctx.lineWidth = 1;
ctx.strokeRect(x, y, bw, bh);
ctx.beginPath();
ctx.moveTo(x, y + 24);
ctx.lineTo(x + bw, y + 24);
ctx.moveTo(x + 104, y + 24);
ctx.lineTo(x + 104, y + bh);
ctx.stroke();
ctx.font = "10px ui-monospace, Menlo, Consolas, monospace";
ctx.fillText(title, x + 7, y + 16);
ctx.fillText(`SHEET ${String(sheetNo).padStart(2, "0")}`, x + 7, y + 40);
ctx.fillText("1:2", x + 114, y + 40);
}
ctx.globalAlpha = 0.3 * fade;
ctx.lineWidth = 0.8;
if (!still && done < plan.total) {
// The drafting arm: one rule across the sheet and one down it, meeting at the pen.
ctx.beginPath();
ctx.moveTo(0, pen[1]);
ctx.lineTo(w, pen[1]);
ctx.moveTo(pen[0], 0);
ctx.lineTo(pen[0], h);
ctx.stroke();
ctx.globalAlpha = fade;
ctx.fillRect(pen[0] - 2, pen[1] - 2, 4, 4);
}
ctx.globalAlpha = 1;
if (aim) {
const [x, y] = aim;
ctx.globalAlpha = 0.65;
ctx.setLineDash([3, 3]);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(w, y);
ctx.moveTo(x, 0);
ctx.lineTo(x, h);
ctx.stroke();
ctx.setLineDash([]);
ctx.globalAlpha = 1;
const read = `X${((x / CELL) * 5).toFixed(1)} Y${((y / CELL) * 5).toFixed(1)}`;
const across = x + 10 + ctx.measureText(read).width > w - 12;
ctx.textAlign = across ? "right" : "left";
ctx.fillText(read, x + (across ? -8 : 8), y < 28 ? y + 18 : y - 8);
ctx.textAlign = "left";
}
};
const nextSheet = () => {
turn = (turn + 1) % TEETH.length;
sheetNo += 1;
plan = draft(w, h, TEETH[turn]);
done = 0;
hold = 0;
fade = 1;
};
const frame = (now: number) => {
const dt = last ? Math.min((now - last) / 1000, 0.05) * speed : 0;
last = now;
if (done < plan.total) {
done = Math.min(plan.total, done + (plan.total / DRAW_TIME) * dt);
} else if (hold < 5) {
hold += dt;
} else {
fade -= dt / 1.5;
if (fade <= 0) nextSheet();
}
draw();
raf = requestAnimationFrame(frame);
};
const start = () => {
if (still || raf) return;
last = 0;
raf = requestAnimationFrame(frame);
};
const stop = () => {
cancelAnimationFrame(raf);
raf = 0;
};
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
w = canvas.clientWidth;
h = canvas.clientHeight;
if (!w || !h) return;
for (const c of [canvas, sheet]) {
c.width = Math.round(w * dpr);
c.height = Math.round(h * dpr);
c.getContext("2d")?.setTransform(dpr, 0, 0, dpr, 0, 0);
}
paintPaper();
plan = draft(w, h, TEETH[turn]);
// Part done on arrival, so the first look is a drawing, not an empty grid.
done = still ? plan.total : plan.total * 0.45;
draw();
};
const point = (e: PointerEvent) => {
const box = canvas.getBoundingClientRect();
aim = [e.clientX - box.left, e.clientY - box.top];
if (still) draw();
};
const leave = () => {
aim = null;
if (still) draw();
};
// Redraw on size change; only animate while on screen.
const ro = new ResizeObserver(resize);
const io = new IntersectionObserver(([e]) => (e.isIntersecting ? start() : stop()));
ro.observe(canvas);
io.observe(canvas);
root.addEventListener("pointermove", point);
root.addEventListener("pointerleave", leave);
return () => {
stop();
ro.disconnect();
io.disconnect();
root.removeEventListener("pointermove", point);
root.removeEventListener("pointerleave", leave);
};
}, [title, paper, ink, speed]);
return (
<div ref={rootRef} className={`relative overflow-hidden ${className}`} style={{ background: paper }}>
<canvas ref={canvasRef} aria-hidden className="absolute inset-0 h-full w-full" />
<div className="relative h-full">{children}</div>
</div>
);
}
1. Paste the Code tab into
components/ui/blueprint-grid.tsx. The styles are Tailwind CSS v4 classes.2. Use it. This is the preview above:
app/blueprint-grid-demo.tsx
import { BlueprintGrid } from "@/components/ui/blueprint-grid"; export default function BlueprintGridExample() { return ( <BlueprintGrid className="h-full"> <p className="absolute bottom-5 left-6 font-mono text-sm tracking-widest text-[#dcecff]">MOVE POINTER TO MEASURE</p> </BlueprintGrid> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| children | ReactNode | — | Content shown over the background. |
| title | string | "SPUR GEAR" | Text in the title block, bottom right. Shown when 420px wide or more. |
| paper | string | "#0d3b78" | Paper colour. |
| ink | string | "#dcecff" | Line colour. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| className | string | "" | Give it a height here. |