take it
Drop the script next to your page and add
<canvas id="field"></canvas> with
position: fixed; inset: 0. No dependencies, respects
prefers-reduced-motion.
// ── background: live UV-Vis spectrophotometer trace ──
// A plausible absorption spectrum for laser-ablation-synthesised gold nanoparticles:
// rising UV baseline (interband transitions / scattering below ~300 nm) plus the
// characteristic localised surface plasmon resonance peak around 520-530 nm. Not real
// data — you lost the actual scan — but the shape and peak position are physically
// correct for citrate-free gold colloids made this way. Redrawn fresh each sweep with
// slightly different noise and peak position, like a real repeat scan would look.
const canvas = document.getElementById('field');
const ctx = canvas.getContext('2d');
let W, H;
function resize() { W = canvas.width = innerWidth; H = canvas.height = innerHeight; }
resize();
addEventListener('resize', resize);
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
function rand(a, b) { return a + Math.random() * (b - a); }
const LAMBDA_MIN = 200, LAMBDA_MAX = 800;
const N = 220;
const TRACE = '130,190,255';
let traces = []; // { data, born } — each a full sweep, oldest fade out and get removed
function makeSeriesData() {
const peakCenter = rand(518, 532);
const peakSigma = rand(22, 28);
const peakAmp = rand(0.82, 0.98);
const uvAmp = rand(0.12, 0.2);
const s = [];
for (let i = 0; i <= N; i++) {
const lambda = LAMBDA_MIN + (i / N) * (LAMBDA_MAX - LAMBDA_MIN);
const uv = uvAmp * Math.exp(-(lambda - LAMBDA_MIN) / 110) + 0.04;
const peak = peakAmp * Math.exp(-Math.pow(lambda - peakCenter, 2) / (2 * peakSigma * peakSigma));
const noise = rand(-0.012, 0.012);
s.push(Math.max(0, uv + peak + noise));
}
return s;
}
function layout() {
const plotW = Math.min(W * 0.72, 640);
const plotH = Math.min(H * 0.4, 260);
const x0 = (W - plotW) / 2;
const y0 = H * 0.5 - plotH / 2;
return { x0, y0, plotW, plotH };
}
function drawAxes(L) {
ctx.strokeStyle = 'rgba(120,126,138,0.22)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(L.x0, L.y0); ctx.lineTo(L.x0, L.y0 + L.plotH); ctx.lineTo(L.x0 + L.plotW, L.y0 + L.plotH);
ctx.stroke();
ctx.font = '10px "JetBrains Mono", monospace';
ctx.fillStyle = 'rgba(110,116,128,0.4)';
ctx.textAlign = 'center';
for (let lambda = 200; lambda <= 800; lambda += 100) {
const x = L.x0 + ((lambda - LAMBDA_MIN) / (LAMBDA_MAX - LAMBDA_MIN)) * L.plotW;
ctx.strokeStyle = 'rgba(120,126,138,0.1)';
ctx.beginPath(); ctx.moveTo(x, L.y0); ctx.lineTo(x, L.y0 + L.plotH); ctx.stroke();
ctx.fillText(String(lambda), x, L.y0 + L.plotH + 16);
}
ctx.textAlign = 'right';
[0, 0.5, 1.0].forEach(v => {
const y = L.y0 + L.plotH - v * L.plotH * 0.85;
ctx.strokeStyle = 'rgba(120,126,138,0.1)';
ctx.beginPath(); ctx.moveTo(L.x0, y); ctx.lineTo(L.x0 + L.plotW, y); ctx.stroke();
ctx.fillStyle = 'rgba(110,116,128,0.4)';
ctx.fillText(v.toFixed(1), L.x0 - 10, y + 3);
});
ctx.textAlign = 'center';
ctx.fillStyle = 'rgba(110,116,128,0.35)';
ctx.fillText('wavelength, nm', L.x0 + L.plotW / 2, L.y0 + L.plotH + 32);
ctx.save();
ctx.translate(L.x0 - 34, L.y0 + L.plotH / 2);
ctx.rotate(-Math.PI / 2);
ctx.fillText('A, a.u.', 0, 0);
ctx.restore();
}
const SWEEP = 4.5; // seconds to draw one full trace
const HOLD = 0.6; // stays fully bright this long after finishing
const FADE = 5.5; // then fades out over this long
const SPAWN_INTERVAL = SWEEP + HOLD; // a new trace starts as the previous one begins to fade
const MAX_TRACES = 4;
let spawnTimer = 0;
let start = null;
function spawnTrace(now) {
traces.push({ data: makeSeriesData(), born: now });
if (traces.length > MAX_TRACES) traces.shift();
}
function drawTrace(L, trace, now, alphaScale) {
const age = now - trace.born;
const sweeping = age < SWEEP;
const progress = sweeping ? age / SWEEP : 1;
const count = Math.max(2, Math.floor(progress * N));
const data = trace.data;
let alpha = 0.5;
if (age >= SWEEP + HOLD) {
const fadeAge = age - SWEEP - HOLD;
alpha = 0.5 * Math.max(0, 1 - fadeAge / FADE);
}
alpha *= alphaScale;
if (alpha <= 0.01) return false;
ctx.beginPath();
for (let i = 0; i <= count; i++) {
const x = L.x0 + (i / N) * L.plotW;
const y = L.y0 + L.plotH - data[i] * L.plotH * 0.85;
if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
}
ctx.strokeStyle = `rgba(${TRACE},${alpha})`;
ctx.lineWidth = 1.4;
ctx.lineJoin = 'round';
ctx.stroke();
if (sweeping) {
const tipX = L.x0 + (count / N) * L.plotW;
const tipY = L.y0 + L.plotH - data[count] * L.plotH * 0.85;
const grad = ctx.createRadialGradient(tipX, tipY, 0, tipX, tipY, 8);
grad.addColorStop(0, `rgba(${TRACE},${alpha * 1.8})`);
grad.addColorStop(1, `rgba(${TRACE},0)`);
ctx.fillStyle = grad;
ctx.beginPath(); ctx.arc(tipX, tipY, 8, 0, Math.PI * 2); ctx.fill();
}
return true;
}
function tick(now) {
if (start === null) { start = now; spawnTrace(0); }
const t = (now - start) / 1000;
spawnTimer -= reduceMotion ? 0 : (t - (tick.prevT || 0));
tick.prevT = t;
if (spawnTimer <= 0) { spawnTrace(t); spawnTimer = SPAWN_INTERVAL; }
ctx.fillStyle = '#07080b';
ctx.fillRect(0, 0, W, H);
const L = layout();
drawAxes(L);
for (let i = traces.length - 1; i >= 0; i--) {
const alive = drawTrace(L, traces[i], t, i === traces.length - 1 ? 1 : 0.7);
if (!alive && i !== traces.length - 1) traces.splice(i, 1);
}
ctx.font = '10px "JetBrains Mono", monospace';
ctx.fillStyle = `rgba(${TRACE},0.4)`;
ctx.textAlign = 'left';
ctx.fillText('Au NP colloid · UV-Vis scan', L.x0, L.y0 - 12);
if (!reduceMotion) requestAnimationFrame(tick);
}
if (!reduceMotion) requestAnimationFrame(tick);
else {
traces = [{ data: makeSeriesData(), born: 0 }];
tick.prevT = 0; start = 0;
const L0 = layout();
ctx.fillStyle = '#07080b'; ctx.fillRect(0, 0, W, H);
drawAxes(L0);
drawTrace(L0, traces[0], SWEEP + 1, 1);
}