← Mike Higgins Home
Custom Code Experiment •

Gradient

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 gradient 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);
    --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 gradient 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: url(#ringGrad);
    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%, #1E1E24 0%, var(--void) 70%);
    box-shadow: inset 0 0 40px rgba(0,0,0,.8), inset 0 0 0 1px rgba(255,255,255,.18);
  }
  .window canvas { display: block; width: 100%; height: 100%; }
  /* slow hue haze behind the model */
  .window::before {
    content: "";
    position: absolute;
    inset: -20%;
    background: conic-gradient(from 0deg, #ff3d8b, #ffc21a, #3dffa0, #3de0ff, #8a5cff, #ff3d8b);
    opacity: .10;
    filter: blur(28px);
    animation: haze 9s linear infinite;
    pointer-events: none;
  }
  @keyframes haze { to { transform: rotate(360deg); } }
  /* 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; }
    .window::before { animation: none; }
  }
</style>
</head>
<body>
  <div class="orb" role="status" aria-label="Loading">
    <svg class="ring" viewBox="0 0 100 100" aria-hidden="true">
      <defs>
        <linearGradient id="ringGrad" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stop-color="#FFC21A"/>
          <stop offset="50%" stop-color="#FF3D8B"/>
          <stop offset="100%" stop-color="#3DE0FF"/>
        </linearGradient>
      </defs>
      <circle class="track" cx="50" cy="50" r="48"/>
      <circle class="arc" cx="50" cy="50" r="48"/>
    </svg>
    <div class="window">
      <canvas id="solid" aria-hidden="true"></canvas>
    </div>
  </div>

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

  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();

  // ---------- Colour solid (HSL spindle) ----------
  // Every point carries its own colour: hue goes around, lightness goes up,
  // saturation goes outward. Each line is therefore a gradient.
  const TAU = Math.PI * 2;
  const L_MIN = 0.14, L_MAX = 0.94;         // keep the black end visible on a dark background
  const point = (hue, light, sat) => {
    const r = Math.sin(Math.PI * light) * sat;  // spindle: narrow at black and white
    const a = hue * TAU;
    return { p: [r * Math.cos(a), (light - 0.5) * 2, r * Math.sin(a)], h: hue, l: light, s: sat };
  };

  const lines = [];
  // Hue rings at several lightness levels
  for (const l of [0.2, 0.35, 0.5, 0.65, 0.8]) {
    const pts = [];
    for (let k = 0; k <= 72; k++) pts.push(point(k / 72, l, 1));
    lines.push({ pts, w: l === 0.5 ? 2.2 : 1.4 });
  }
  // Meridians: black -> pure hue -> white
  for (let k = 0; k < 12; k++) {
    const pts = [];
    for (let j = 0; j <= 30; j++) pts.push(point(k / 12, j / 30, 1));
    lines.push({ pts, w: 1.3 });
  }
  // Saturation spokes across the equator: grey core -> pure hue
  for (let k = 0; k < 12; k++) {
    const pts = [];
    for (let j = 0; j <= 10; j++) pts.push(point(k / 12, 0.5, j / 10));
    lines.push({ pts, w: 0.9, dim: true });
  }
  // Grey axis
  {
    const pts = [];
    for (let j = 0; j <= 20; j++) pts.push(point(0, j / 20, 0));
    lines.push({ pts, w: 1.1, dim: true });
  }

  const colourOf = (q, hueShift, a) => {
    const light = L_MIN + (L_MAX - L_MIN) * q.l;
    return `hsla(${((q.h + hueShift) % 1) * 360}, ${Math.round(q.s * 100)}%, ${Math.round(light * 100)}%, ${a})`;
  };

  // ---------- Projection ----------
  function project(p, rotY, tilt, jitter, bob) {
    let [x, y, z] = p;
    y += bob;
    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.5, persp = d / (d - z2);
    const scale = W * 0.3;
    return [W/2 + x1 * scale * persp, H/2 - y1 * scale * persp, z2];
  }

  // Draw each segment with its own gradient between its two endpoint colours
  function drawSolid(rotY, tilt, hueShift, ghost, alpha, ox, oy, jitter, bob) {
    for (const line of lines) {
      const P = line.pts.map(q => project(q.p, rotY, tilt, jitter, bob));
      bctx.lineWidth = line.w * dpr;
      for (let i = 0; i < P.length - 1; i++) {
        const z = (P[i][2] + P[i + 1][2]) / 2;
        const depth = 0.3 + 0.7 * Math.min(1, Math.max(0, (z + 1) / 2));
        const a = alpha * depth * (line.dim ? 0.55 : 1);
        const x0 = P[i][0] + ox, y0 = P[i][1] + oy, x1 = P[i+1][0] + ox, y1 = P[i+1][1] + oy;
        if (ghost) {
          bctx.strokeStyle = ghost;
          bctx.globalAlpha = a;
        } else {
          const g = bctx.createLinearGradient(x0, y0, x1, y1);
          g.addColorStop(0, colourOf(line.pts[i], hueShift, a));
          g.addColorStop(1, colourOf(line.pts[i + 1], hueShift, a));
          bctx.strokeStyle = g;
          bctx.globalAlpha = 1;
        }
        bctx.beginPath();
        bctx.moveTo(x0, y0);
        bctx.lineTo(x1, y1);
        bctx.stroke();
      }
    }
    bctx.globalAlpha = 1;
  }

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

  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.35 : 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.4;
      hueJump = Math.random() < 0.5 ? Math.random() : 0;   // colours can jump during a glitch
    }
    const g = glitch > 0;
    if (g) glitch--; else { frozenRot = null; hueJump = 0; }

    const t = now / 1000;
    const bob = Math.sin(t * 2.2) * 0.04;
    const rotY = frozenRot ?? rot;
    const tilt = 0.42 + Math.sin(t * 0.9) * 0.08;
    const hueShift = ((reduced ? 0 : t * 0.06) + hueJump) % 1;  // colours slowly drift around

    bctx.clearRect(0, 0, W, H);
    bctx.lineCap = 'round';
    if (g) {
      bctx.globalCompositeOperation = 'lighter';
      const off = (4 + Math.random() * 8) * dpr * glitchStrength;
      drawSolid(rotY, tilt, hueShift, '#FF2A4A', 0.55, -off, 0, 0.03 * glitchStrength, bob);
      drawSolid(rotY, tilt, hueShift, '#2AE6FF', 0.55, off, (Math.random()-.5)*2*dpr, 0.03 * glitchStrength, bob);
    }
    bctx.globalCompositeOperation = g ? 'lighter' : 'source-over';
    drawSolid(rotY, tilt, hueShift, null, g ? 0.85 : 1, 0, 0, g ? 0.02 : (reduced ? 0 : 0.004), 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 = `hsl(${Math.random() * 360}, 100%, 60%)`;
        ctx.globalAlpha = 0.2 + Math.random() * 0.35;
        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>