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.

raw

// ── 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);
}