← Mike Higgins Home
Custom Code Experiment •

Reader

Source Code View HTML / CSS / JavaScript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Glitch book loader</title>
<style>
  :root {
    box-sizing: border-box;
    padding-top: env(safe-area-inset-top, 0px);
    padding-bottom: env(safe-area-inset-bottom, 0px);
    --page: #EDEDED;
    --ring-track: rgba(0, 0, 0, 0.12);
    --accent: #FFC21A;
    --void: #0A0A0A;
  }
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      --page: #141414;
      --ring-track: rgba(255, 255, 255, 0.12);
    }
  }
  :root[data-theme="dark"] {
    --page: #141414;
    --ring-track: rgba(255, 255, 255, 0.12);
  }
  *, *::before, *::after { box-sizing: inherit; }
  html { height: 100%; scroll-padding-top: env(safe-area-inset-top, 0px); }
  body {
    margin: 0;
    height: 100%;
    background: var(--page);
    display: grid;
    place-items: center;
  }

  .orb {
    position: relative;
    width: min(260px, 70vw);
    aspect-ratio: 1;
  }

  /* Animated yellow ring */
  .ring {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    transform: rotate(-90deg);
  }
  .ring circle { fill: none; stroke-width: 3; }
  .ring .track { stroke: var(--ring-track); }
  .ring .arc {
    stroke: var(--accent);
    stroke-linecap: round;
    stroke-dasharray: 90 400;
    animation: sweep 1.6s cubic-bezier(.6,.1,.3,.9) infinite;
    transform-origin: 50% 50%;
  }
  @keyframes sweep {
    0%   { stroke-dasharray: 20 400; transform: rotate(0deg); }
    50%  { stroke-dasharray: 160 400; }
    100% { stroke-dasharray: 20 400; transform: rotate(360deg); }
  }

  /* Circular viewport */
  .window {
    position: absolute;
    inset: 12px;
    border-radius: 50%;
    overflow: hidden;
    background: radial-gradient(circle at 50% 45%, #262626 0%, var(--void) 70%);
    box-shadow: inset 0 0 40px rgba(0,0,0,.8), inset 0 0 0 1px rgba(255,255,255,.2);
  }
  .window canvas { display: block; width: 100%; height: 100%; }
  /* faint ruled-paper texture */
  .window::before {
    content: "";
    position: absolute;
    inset: 0;
    opacity: .07;
    background:
      linear-gradient(to right, transparent calc(22% - 1px), #fff calc(22% - 1px) 22%, transparent 22%),
      repeating-linear-gradient(to bottom, transparent 0 17px, #fff 17px 18px);
    pointer-events: none;
  }
  /* scanlines */
  .window::after {
    content: "";
    position: absolute;
    inset: 0;
    background: repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0 2px, rgba(0,0,0,.28) 2px 3px);
    pointer-events: none;
  }

  @media (prefers-reduced-motion: reduce) {
    .ring .arc { animation-duration: 4s; }
  }
</style>
</head>
<body>
  <div class="orb" role="status" aria-label="Loading">
    <svg class="ring" viewBox="0 0 100 100" aria-hidden="true">
      <circle class="track" cx="50" cy="50" r="48"/>
      <circle class="arc" cx="50" cy="50" r="48"/>
    </svg>
    <div class="window">
      <canvas id="book" aria-hidden="true"></canvas>
    </div>
  </div>

<script>
(() => {
  const canvas = document.getElementById('book');
  const ctx = canvas.getContext('2d');
  const buf = document.createElement('canvas');
  const bctx = buf.getContext('2d');
  const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

  const WHITE = '#FFFFFF';
  const PAGE  = '#D6D6D6';
  const TEXT  = '#9A9A9A';
  const COVER = '#8C8C8C';
  const GHOST_A = '#8C8C8C', GHOST_B = '#E0E0E0';

  let W = 0, H = 0, dpr = 1;
  function resize() {
    dpr = Math.min(window.devicePixelRatio || 1, 2);
    const r = canvas.getBoundingClientRect();
    W = Math.round(r.width * dpr); H = Math.round(r.height * dpr);
    canvas.width = buf.width = W;
    canvas.height = buf.height = H;
  }
  new ResizeObserver(resize).observe(canvas);
  resize();

  // ---------- Book geometry ----------
  // Spine runs along y. Pages open out along x, fanning toward the viewer (+z).
  const PH = 0.68;        // half page height
  const PW = 0.95;        // page width
  const CH = 0.74;        // half cover height
  const CW = 1.02;        // cover width
  const BETA = 0.22;      // how far the covers tilt toward the viewer
  const STEPS = 16;

  // Walk out along a curved page cross-section; angleAt(s) gives direction at s (0 spine .. 1 edge).
  // side = +1 right, -1 left (mirror in x).
  function crossSection(angleAt, width, side, z0 = 0) {
    const pts = [[0, z0]];
    let x = 0, z = z0;
    for (let i = 1; i <= STEPS; i++) {
      const s = (i - 0.5) / STEPS;
      const a = angleAt(s);
      x += Math.cos(a) * width / STEPS;
      z += Math.sin(a) * width / STEPS;
      pts.push([x, z]);
    }
    return pts.map(([x, z]) => [side * x, z]);
  }

  // Build a page (top edge, bottom edge, outer edge) from a cross-section
  function pagePolys(cs, half, color, w, dim) {
    const top = cs.map(([x, z]) => [x, half, z]);
    const bot = cs.map(([x, z]) => [x, -half, z]);
    const e = cs[cs.length - 1];
    return [
      { pts: top, color, w, dim },
      { pts: bot, color, w, dim },
      { pts: [[e[0], half, e[1]], [e[0], -half, e[1]]], color, w, dim }
    ];
  }

  const statics = [];

  // Covers: board with visible thickness
  for (const side of [-1, 1]) {
    const dx = side * Math.cos(BETA), dz = Math.sin(BETA);
    const nx = side * Math.sin(BETA) * 0.05, nz = -Math.cos(BETA) * 0.05; // backing offset
    for (const off of [[0, 0], [nx, nz]]) {
      const o = [off[0], off[1] - 0.03];
      statics.push({ pts: [
        [o[0], -CH, o[1]], [o[0] + dx * CW, -CH, o[1] + dz * CW],
        [o[0] + dx * CW, CH, o[1] + dz * CW], [o[0], CH, o[1]]
      ], color: COVER, w: 1.6 });
    }
    // connect board faces at the corners
    for (const y of [-CH, CH]) {
      statics.push({ pts: [[dx * CW, y, dz * CW - 0.03], [dx * CW + nx, y, dz * CW + nz - 0.03]], color: COVER, w: 1.2 });
    }
  }
  // Rounded spine behind the fold
  for (const y of [-CH, -CH * 0.33, CH * 0.33, CH]) {
    const pts = [];
    for (let k = 0; k <= 12; k++) {
      const a = Math.PI * k / 12;
      pts.push([Math.cos(a) * 0.07, y, -0.05 - Math.sin(a) * 0.07]);
    }
    statics.push({ pts, color: COVER, w: y === CH || y === -CH ? 1.4 : 0.9, dim: y !== CH && y !== -CH });
  }

  // Page blocks: stacked pages bowing up from the spine
  const LAYERS = 6;
  const restAngle = (lift) => (s) => BETA + lift * Math.pow(1 - s, 2.2);
  for (const side of [-1, 1]) {
    for (let i = 0; i < LAYERS; i++) {
      const lift = 0.25 + 0.75 * (i / (LAYERS - 1));   // top page lifts the most
      const cs = crossSection(restAngle(lift), PW * (0.97 + 0.03 * i / LAYERS), side);
      const topPage = i === LAYERS - 1;
      statics.push(...pagePolys(cs, PH, topPage ? WHITE : PAGE, topPage ? 1.5 : 0.9, !topPage));

      // Lines of "text" on the top page of each side
      if (topPage) {
        let seed = side > 0 ? 11 : 29;
        const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
        const rows = 11;
        for (let r = 0; r < rows; r++) {
          const y = PH * 0.78 - r * (PH * 1.56 / (rows - 1));
          const start = 3, end = STEPS - 2 - (r === rows - 1 || rnd() < 0.25 ? Math.floor(rnd() * 7) : 0);
          if (r === 4) continue; // paragraph break
          const pts = cs.slice(start, end + 1).map(([x, z]) => [x, y, z + 0.004]);
          statics.push({ pts, color: TEXT, w: 1.0, dim: true });
        }
      }
    }
  }

  // Turning pages: sweep from the right stack to the left, lagging tip curls
  function turningPage(progress) {
    const e = progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2; // ease in-out
    const startA = BETA + 0.9, endA = Math.PI - BETA - 0.9;
    const root = startA + (endA - startA) * e;
    const lag = Math.sin(e * Math.PI) * 1.1;     // tip trails behind while mid-turn
    const settle = (1 - Math.sin(e * Math.PI));   // near rest the page drapes like the stack
    const angleAt = (s) => {
      const drape = settle * 0.9 * (Math.pow(1 - s, 2.2) - 1) * (e < 0.5 ? 1 : -1);
      return root - lag * s + drape;
    };
    const cs = crossSection(angleAt, PW, 1, 0.01);
    return pagePolys(cs, PH, WHITE, 1.5, false);
  }

  // ---------- Projection ----------
  const CENTER_Z = 0.3;
  function project(p, rotY, tilt, jitter, bob = 0) {
    // Lay the book open on its back: page faces (+z in the model) point up,
    // the spine runs away from the viewer, so it reads like an open book on a table.
    let x = p[0], y = p[2] - CENTER_Z + bob, z = -p[1];
    if (jitter) { x += (Math.random()-.5)*jitter; y += (Math.random()-.5)*jitter; }
    const cy = Math.cos(rotY), sy = Math.sin(rotY);
    const x1 = x * cy + z * sy, z1 = -x * sy + z * cy;
    const cx = Math.cos(tilt), sx = Math.sin(tilt);
    const y1 = y * cx - z1 * sx, z2 = y * sx + z1 * cx;
    const d = 4.2, persp = d / (d - z2);
    const scale = W * 0.31;
    return [W/2 + x1 * scale * persp, H/2 - y1 * scale * persp, z2];
  }

  function strokeAll(polys, rotY, tilt, color, alpha, ox, oy, jitter, bob) {
    for (const poly of polys) {
      bctx.beginPath();
      let depthSum = 0;
      poly.pts.forEach((p, i) => {
        const [sx, sy, sz] = project(p, rotY, tilt, jitter, bob);
        depthSum += sz;
        if (i === 0) bctx.moveTo(sx + ox, sy + oy); else bctx.lineTo(sx + ox, sy + oy);
      });
      const depth = depthSum / poly.pts.length;
      const fade = 0.5 + 0.5 * Math.min(1, Math.max(0, (depth + 1.2) / 2.4));
      bctx.globalAlpha = alpha * fade * (poly.dim ? 0.55 : 1);
      bctx.strokeStyle = color || poly.color;
      bctx.lineWidth = poly.w * dpr;
      bctx.stroke();
    }
    bctx.globalAlpha = 1;
  }

  // ---------- Loop ----------
  let glitch = 0, glitchStrength = 0;
  let rot = 0, last = performance.now();
  let frozenRot = null;

  function frame(now) {
    if (W === 0 || H === 0) {
      resize();
      if (W === 0 || H === 0) { last = now; requestAnimationFrame(frame); return; }
    }
    const dt = Math.min(50, now - last) / 1000; last = now;
    rot += dt * (reduced ? 0.4 : 1.1);

    if (!reduced && glitch <= 0 && Math.random() < 0.025) {
      glitch = 4 + Math.floor(Math.random() * 10);
      glitchStrength = 0.4 + Math.random() * 0.6;
      if (Math.random() < 0.4) frozenRot = rot + (Math.random() - .5) * 1.2;
    }
    const g = glitch > 0;
    if (g) glitch--; else frozenRot = null;

    const t = now / 1000;
    const bob = Math.sin(t * 2.2) * 0.04;
    const rotY = frozenRot ?? rot;
    const tilt = 0.62 + Math.sin(t * 0.9) * 0.08;

    // Two pages flipping, staggered
    const period = reduced ? 3.2 : 1.4;
    const polys = statics.concat(
      turningPage((t / period) % 1),
      turningPage((t / period + 0.5) % 1)
    );

    bctx.clearRect(0, 0, W, H);
    bctx.lineJoin = 'round'; bctx.lineCap = 'round';
    bctx.globalCompositeOperation = 'lighter';
    const baseJit = reduced ? 0 : 0.005;
    if (g) {
      const off = (4 + Math.random() * 8) * dpr * glitchStrength;
      strokeAll(polys, rotY, tilt, GHOST_A, 0.7, -off, 0, 0.03 * glitchStrength, bob);
      strokeAll(polys, rotY, tilt, GHOST_B, 0.6, off, (Math.random()-.5)*2*dpr, 0.03 * glitchStrength, bob);
    }
    strokeAll(polys, rotY, tilt, null, g ? 0.85 : 1, 0, 0, g ? 0.02 : baseJit, bob);
    bctx.globalCompositeOperation = 'source-over';

    ctx.clearRect(0, 0, W, H);
    if (g) {
      let y = 0;
      while (y < H) {
        const h = Math.min(H - y, Math.max(2, Math.floor((4 + Math.random() * 30) * dpr)));
        const shift = Math.random() < 0.35 ? (Math.random() - .5) * 40 * dpr * glitchStrength : 0;
        ctx.drawImage(buf, 0, y, W, h, shift, y, W, h);
        y += h;
      }
      const blocks = Math.floor(Math.random() * 4);
      for (let i = 0; i < blocks; i++) {
        ctx.fillStyle = [WHITE, GHOST_A, GHOST_B][i % 3];
        ctx.globalAlpha = 0.18 + Math.random() * 0.3;
        ctx.fillRect(Math.random() * W, Math.random() * H, (10 + Math.random() * 60) * dpr, (1 + Math.random() * 3) * dpr);
      }
      ctx.globalAlpha = 1;
    } else {
      ctx.drawImage(buf, 0, 0);
    }

    requestAnimationFrame(frame);
  }
  requestAnimationFrame(frame);
})();
</script>
</body>
</html>