About this snippet

A loading state for AI image generation that looks like the picture coming into focus. The frame starts as colored grain, then sharpens over 30 steps while the progress bar, the step counter and the percentage keep pace. Cancel stops it at the current step, Save image downloads the finished PNG, and Generate again paints a new picture from a new seed.

The picture is painted on a canvas that is never added to the page, with a seeded random number generator (a lake, a sun and three ridges), so one seed always gives the same scene and no image file is loaded. Until step 25, each frame draws that scene into a smaller canvas and scales it back up, which blurs it, then adds random grain with getImageData and putImageData. Both effects shrink as the steps go on. The blur is gone by step 25 and the grain by step 29, so the last frame is the sharp scene.

The 2D context is requested with willReadFrequently, which tells the browser its pixels will be read back often. The canvas has the img role and an aria-label that changes from generating to a description of the result, and the progress bar reports its value to screen readers. For visitors who prefer reduced motion the grain is skipped and only the blur clears. If your API streams preview frames, draw those in place of the painted scene.

Size
11.1 KB
Uses
HTML, CSS, JavaScript
Loads
Nothing external
Views
75
<main class="studio">
  <div class="frame">
    <canvas id="canvas" width="512" height="512" role="img" aria-label="Image not generated yet"></canvas>
  </div>
  <section class="panel" aria-labelledby="prompt-label">
    <h1 class="panel-label" id="prompt-label">Prompt</h1>
    <p class="prompt">A calm lake at dusk below three mountain ridges</p>
    <div class="progress">
      <div class="progress-bar" id="progress" role="progressbar" aria-label="Generation progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
        <span class="progress-fill" id="progress-fill"></span>
      </div>
      <p class="progress-text">
        <span id="step-text">Ready</span>
        <span id="percent">0%</span>
      </p>
    </div>
    <p class="seed">Seed <span id="seed">none yet</span></p>
    <div class="buttons">
      <button type="button" class="btn btn-primary" id="generate">Generate</button>
      <button type="button" class="btn" id="cancel" disabled>Cancel</button>
      <button type="button" class="btn" id="save" disabled>Save image</button>
    </div>
    <p class="sr-only" id="status" role="status"></p>
  </section>
</main>
*, *::before, *::after {
  box-sizing: border-box;
}

body {
  font-family: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  margin: 0;
  background: #0b0f19;
  color: #e2e8f0;
}

.studio {
  display: grid;
  grid-template-columns: minmax(0, 460px) minmax(0, 340px);
  justify-content: center;
  align-items: center;
  gap: 40px;
  min-height: 100vh;
  padding: 32px 20px;
}

.frame {
  aspect-ratio: 1;
  overflow: hidden;
  border-radius: 18px;
  background: #151b2b;
  box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.18), 0 24px 60px -20px rgba(0, 0, 0, 0.7);
}

canvas {
  display: block;
  width: 100%;
  height: 100%;
}

.panel-label {
  margin: 0 0 6px;
  font-size: 0.8125rem;
  font-weight: 600;
  color: #94a3b8;
}

.prompt {
  margin: 0;
  font-size: 1.25rem;
  font-weight: 600;
  line-height: 1.4;
  color: #f8fafc;
}

.progress {
  margin: 28px 0 12px;
}

.progress-bar {
  height: 6px;
  overflow: hidden;
  border-radius: 3px;
  background: #1e293b;
}

.progress-fill {
  display: block;
  width: 0;
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, #3d7bfa, #9a5cf6, #ef4f98);
}

.progress-text {
  display: flex;
  justify-content: space-between;
  margin: 10px 0 0;
  font-size: 0.875rem;
  font-variant-numeric: tabular-nums;
  color: #cbd5e1;
}

.seed {
  margin: 0 0 24px;
  font-size: 0.875rem;
  color: #94a3b8;
}

.seed span {
  font-variant-numeric: tabular-nums;
  color: #cbd5e1;
}

.buttons {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.btn {
  min-height: 40px;
  padding: 8px 14px;
  border: 1px solid #334155;
  border-radius: 10px;
  background: transparent;
  color: #e2e8f0;
  font: inherit;
  font-size: 0.9375rem;
  font-weight: 600;
  cursor: pointer;
}

.btn:hover:not(:disabled) {
  background: #1e293b;
}

.btn-primary {
  border-color: #f8fafc;
  background: #f8fafc;
  color: #0b0f19;
}

.btn-primary:hover:not(:disabled) {
  border-color: #cbd5e1;
  background: #cbd5e1;
}

.btn:focus-visible {
  outline: 2px solid #93c5fd;
  outline-offset: 2px;
}

.btn:disabled {
  opacity: 0.4;
  cursor: default;
}

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

@media (max-width: 760px) {
  .studio {
    grid-template-columns: minmax(0, 420px);
    align-content: center;
    gap: 18px;
    padding: 20px 16px;
  }

  .frame {
    width: min(100%, 58vh);
    justify-self: center;
  }

  .prompt {
    font-size: 1.0625rem;
  }

  .progress {
    margin: 14px 0 8px;
  }

  .seed {
    margin-bottom: 16px;
  }
}

@media (forced-colors: active) {
  .progress-bar {
    border: 1px solid CanvasText;
  }

  .progress-fill {
    forced-color-adjust: none;
    background: Highlight;
  }
}

@media (pointer: coarse) {
  .btn {
    min-height: 44px;
  }
}
const SIZE = 512;
const STEPS = 30;
const STEP_MS = 140;

const palettes = [
  { sky: ["#1b1740", "#6b3a78", "#f39a6b"], sun: "255, 211, 154", ridges: ["#8a5580", "#553463", "#2a1c40"], lake: ["#3a2552", "#140f26"] },
  { sky: ["#0f2438", "#3f5f7f", "#f2b36b"], sun: "255, 226, 168", ridges: ["#6b7f96", "#3d5068", "#1c2738"], lake: ["#26384d", "#0b1420"] },
  { sky: ["#2a1433", "#9c4466", "#ffb38a"], sun: "255, 240, 201", ridges: ["#9a5672", "#633152", "#2f172c"], lake: ["#4a2440", "#170a18"] },
  { sky: ["#07252a", "#1f6b6b", "#e8c07a"], sun: "255, 243, 196", ridges: ["#4a8a84", "#285a58", "#11302f"], lake: ["#154040", "#061617"] }
];

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
const scene = document.createElement("canvas");
const sceneCtx = scene.getContext("2d");
const tiny = document.createElement("canvas");
const tinyCtx = tiny.getContext("2d");
const progress = document.getElementById("progress");
const fill = document.getElementById("progress-fill");
const stepText = document.getElementById("step-text");
const percent = document.getElementById("percent");
const seedText = document.getElementById("seed");
const generateBtn = document.getElementById("generate");
const cancelBtn = document.getElementById("cancel");
const saveBtn = document.getElementById("save");
const status = document.getElementById("status");
const calm = window.matchMedia("(prefers-reduced-motion: reduce)");

scene.width = SIZE;
scene.height = SIZE;

let run = 0;
let seed = 0;
let step = 0;

// Seeded random numbers, so one seed always paints one picture
function random(a) {
  return function() {
    a = (a + 0x6d2b79f5) | 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function paintScene(g, n) {
  const rand = random(n);
  const p = palettes[Math.floor(rand() * palettes.length)];
  const horizon = 330;

  const sky = g.createLinearGradient(0, 0, 0, horizon);
  sky.addColorStop(0, p.sky[0]);
  sky.addColorStop(0.62, p.sky[1]);
  sky.addColorStop(1, p.sky[2]);
  g.fillStyle = sky;
  g.fillRect(0, 0, SIZE, horizon);

  for (let i = 0; i < 70; i++) {
    g.fillStyle = "rgba(255, 255, 255, " + (0.15 + rand() * 0.6).toFixed(2) + ")";
    g.fillRect(rand() * SIZE, rand() * horizon * 0.45, 1.5, 1.5);
  }

  const sx = 140 + rand() * 232;
  const sy = 240 + rand() * 40;
  const sr = 30 + rand() * 16;
  const glow = g.createRadialGradient(sx, sy, sr * 0.5, sx, sy, sr * 5);
  glow.addColorStop(0, "rgba(" + p.sun + ", 0.5)");
  glow.addColorStop(1, "rgba(" + p.sun + ", 0)");
  g.fillStyle = glow;
  g.fillRect(0, 0, SIZE, horizon);
  g.fillStyle = "rgb(" + p.sun + ")";
  g.beginPath();
  g.arc(sx, sy, sr, 0, Math.PI * 2);
  g.fill();

  // Three ridges, far to near, each a sum of two sine waves
  p.ridges.forEach(function(color, i) {
    const base = 300 + i * 14;
    const height = 120 - i * 36;
    const f1 = 1 + rand() * 1.5;
    const f2 = 3 + rand() * 4;
    const p1 = rand() * Math.PI * 2;
    const p2 = rand() * Math.PI * 2;
    g.beginPath();
    g.moveTo(0, horizon);
    for (let x = 0; x <= SIZE; x += 4) {
      const u = (x / SIZE) * Math.PI;
      const h = 0.5 + 0.35 * Math.sin(u * f1 + p1) + 0.15 * Math.sin(u * f2 + p2);
      g.lineTo(x, Math.min(horizon, base - height * h + rand() * 3));
    }
    g.lineTo(SIZE, horizon);
    g.closePath();
    g.fillStyle = color;
    g.fill();
  });

  const lake = g.createLinearGradient(0, horizon, 0, SIZE);
  lake.addColorStop(0, p.lake[0]);
  lake.addColorStop(1, p.lake[1]);
  g.fillStyle = lake;
  g.fillRect(0, horizon, SIZE, SIZE - horizon);

  g.save();
  g.globalAlpha = 0.3;
  g.translate(0, horizon * 2);
  g.scale(1, -1);
  g.drawImage(g.canvas, 0, 0, SIZE, horizon, 0, 0, SIZE, horizon);
  g.restore();

  for (let y = horizon + 3; y < SIZE; y += 3 + rand() * 5) {
    const fade = 1 - (y - horizon) / (SIZE - horizon);
    const width = sr * (0.6 + rand() * 1.8) * (0.3 + fade);
    const shift = (rand() - 0.5) * sr * 0.8;
    g.fillStyle = "rgba(" + p.sun + ", " + (0.2 + rand() * 0.45 * fade).toFixed(2) + ")";
    g.fillRect(sx + shift - width / 2, y, width, 1.5);
  }
}

// Blur the scene and add grain, a little less at every step
function renderStep(t) {
  const block = Math.max(1, Math.round(48 * Math.pow(1 - t, 2)));
  ctx.imageSmoothingEnabled = true;
  if (block > 1) {
    const small = Math.ceil(SIZE / block);
    tiny.width = small;
    tiny.height = small;
    tinyCtx.drawImage(scene, 0, 0, small, small);
    ctx.drawImage(tiny, 0, 0, SIZE, SIZE);
  } else {
    ctx.drawImage(scene, 0, 0);
  }
  const amount = calm.matches ? 0 : Math.pow(1 - t, 1.5) * 0.85;
  if (amount < 0.01) return;
  const image = ctx.getImageData(0, 0, SIZE, SIZE);
  const d = image.data;
  const spread = 400 * amount;
  for (let i = 0; i < d.length; i += 4) {
    d[i] += (Math.random() - 0.5) * spread;
    d[i + 1] += (Math.random() - 0.5) * spread;
    d[i + 2] += (Math.random() - 0.5) * spread;
  }
  ctx.putImageData(image, 0, 0);
}

function setProgress(value, label) {
  const pct = Math.round(value * 100);
  fill.style.width = pct + "%";
  percent.textContent = pct + "%";
  progress.setAttribute("aria-valuenow", String(pct));
  stepText.textContent = label;
}

function setBusy(busy) {
  const focused = document.activeElement;
  generateBtn.disabled = busy;
  cancelBtn.disabled = !busy;
  saveBtn.disabled = busy || step < STEPS;
  // Move focus off a button that was just disabled
  if (focused && focused.disabled) (busy ? cancelBtn : generateBtn).focus();
}

function generate() {
  const id = ++run;
  seed = 10000 + Math.floor(Math.random() * 90000);
  step = 0;
  seedText.textContent = String(seed);
  paintScene(sceneCtx, seed);
  canvas.setAttribute("aria-label", "Image generating");
  setBusy(true);
  status.textContent = "Generating image";
  function next() {
    if (id !== run) return;
    step++;
    renderStep(step / STEPS);
    setProgress(step / STEPS, "Step " + step + " of " + STEPS);
    if (step < STEPS) {
      setTimeout(next, STEP_MS);
      return;
    }
    stepText.textContent = "Done";
    generateBtn.textContent = "Generate again";
    canvas.setAttribute("aria-label", "Generated image of a calm lake at dusk below three mountain ridges");
    setBusy(false);
    status.textContent = "Image ready";
  }
  renderStep(0);
  setProgress(0, "Starting");
  setTimeout(next, STEP_MS);
}

function cancel() {
  run++;
  stepText.textContent = "Canceled at step " + step;
  canvas.setAttribute("aria-label", "Canceled image, partly generated");
  setBusy(false);
  status.textContent = "Canceled at step " + step + " of " + STEPS;
}

function save() {
  const name = "generated-" + seed + ".png";
  canvas.toBlob(function(blob) {
    const url = URL.createObjectURL(blob);
    const link = document.createElement("a");
    link.href = url;
    link.download = name;
    document.body.append(link);
    link.click();
    link.remove();
    setTimeout(function() {
      URL.revokeObjectURL(url);
    }, 1000);
  });
}

generateBtn.addEventListener("click", generate);
cancelBtn.addEventListener("click", cancel);
saveBtn.addEventListener("click", save);

ctx.fillStyle = "#151b2b";
ctx.fillRect(0, 0, SIZE, SIZE);
setTimeout(function() {
  if (!run) generate();
}, 300);
Esc
↑↓ moveEnter select

Start from a template

Open an .html file, or HTML, CSS, JS, Markdown, Less or TypeScript files. You can also drop them here. Styles and scripts go into their panes.

Share

Source

Keyboard shortcuts

Save