AI Image Generation Grid Resolving from Noise

A prompt bar and a grid of four tiles that behave like an image generator. Each tile starts as flickering colour noise drawn on a small canvas, then the artwork underneath fades up, loses its blur and settles into place while a step counter climbs. Tiles finish one after another with a glare sweep. The artwork is inline SVG gradients, so there are no image files. The grid is two by two on phones and four across on large screens, and the ratio switch reshapes every tile.

Code

import { useEffect, useRef, useState, type ReactNode } from "react";
import { useInView, useReducedMotion } from "framer-motion";

const PROMPT = "Abstract desert at dusk, paper cut layers, soft studio light";

/** Shapes the grid can switch between. `box` is the Tailwind aspect class for each tile. */
const ratios = [
  { label: "1:1", box: "aspect-square" },
  { label: "4:5", box: "aspect-[4/5]" },
  { label: "3:2", box: "aspect-[3/2]" },
];

/** The four finished pieces, drawn with SVG gradients. Swap any of them for your own artwork. */
const art: { title: string; seed: string; svg: ReactNode }[] = [
  {
    title: "Dune line",
    seed: "48213",
    svg: (
      <>
        <defs>
          <linearGradient id="aiig-a-sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stopColor="#1e1b4b" /><stop offset="0.55" stopColor="#be185d" /><stop offset="1" stopColor="#fb923c" /></linearGradient>
          <radialGradient id="aiig-a-sun"><stop offset="0" stopColor="#fff7ed" /><stop offset="1" stopColor="#fdba74" /></radialGradient>
          <linearGradient id="aiig-a-d1" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#db2777" /><stop offset="1" stopColor="#4c0519" /></linearGradient>
          <linearGradient id="aiig-a-d2" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#7e22ce" /><stop offset="1" stopColor="#1e1b4b" /></linearGradient>
        </defs>
        <rect width="200" height="200" fill="url(#aiig-a-sky)" />
        <circle cx="138" cy="100" r="34" fill="url(#aiig-a-sun)" />
        <path d="M0 130C40 105 80 150 120 125S180 110 200 128V200H0Z" fill="url(#aiig-a-d1)" />
        <path d="M0 155C50 130 90 175 140 150S190 150 200 160V200H0Z" fill="url(#aiig-a-d2)" />
        <path d="M0 180C60 162 120 196 200 174V200H0Z" fill="#0f0a2a" />
      </>
    ),
  },
  {
    title: "Glass orbs",
    seed: "70931",
    svg: (
      <>
        <defs>
          <radialGradient id="aiig-b-bg" cx="0.3" cy="0.2" r="1"><stop offset="0" stopColor="#14b8a6" /><stop offset="1" stopColor="#022c22" /></radialGradient>
          <radialGradient id="aiig-b-o1" cx="0.35" cy="0.3" r="0.8"><stop offset="0" stopColor="#ecfeff" /><stop offset="0.45" stopColor="#22d3ee" /><stop offset="1" stopColor="#155e75" /></radialGradient>
          <radialGradient id="aiig-b-o2" cx="0.35" cy="0.3" r="0.8"><stop offset="0" stopColor="#fefce8" /><stop offset="0.45" stopColor="#facc15" /><stop offset="1" stopColor="#854d0e" /></radialGradient>
          <radialGradient id="aiig-b-o3" cx="0.35" cy="0.3" r="0.8"><stop offset="0" stopColor="#fdf2f8" /><stop offset="0.45" stopColor="#f472b6" /><stop offset="1" stopColor="#831843" /></radialGradient>
        </defs>
        <rect width="200" height="200" fill="url(#aiig-b-bg)" />
        <ellipse cx="86" cy="170" rx="56" ry="9" fill="#021a15" opacity="0.5" />
        <circle cx="82" cy="112" r="54" fill="url(#aiig-b-o1)" />
        <circle cx="148" cy="66" r="30" fill="url(#aiig-b-o2)" />
        <circle cx="152" cy="146" r="21" fill="url(#aiig-b-o3)" />
      </>
    ),
  },
  {
    title: "Ribbon study",
    seed: "15577",
    svg: (
      <>
        <defs>
          <linearGradient id="aiig-c-bg" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stopColor="#0b1026" /><stop offset="1" stopColor="#3730a3" /></linearGradient>
          <linearGradient id="aiig-c-r1"><stop offset="0" stopColor="#f0abfc" /><stop offset="1" stopColor="#6366f1" /></linearGradient>
          <linearGradient id="aiig-c-r2"><stop offset="0" stopColor="#67e8f9" /><stop offset="1" stopColor="#a78bfa" /></linearGradient>
          <linearGradient id="aiig-c-r3"><stop offset="0" stopColor="#fde68a" /><stop offset="1" stopColor="#fb7185" /></linearGradient>
        </defs>
        <rect width="200" height="200" fill="url(#aiig-c-bg)" />
        <g fill="none" strokeLinecap="round" strokeWidth="28">
          <path d="M-20 56C40 6 96 122 220 46" stroke="url(#aiig-c-r1)" />
          <path d="M-20 108C52 58 118 172 220 98" stroke="url(#aiig-c-r2)" />
          <path d="M-20 160C62 110 112 214 220 150" stroke="url(#aiig-c-r3)" />
        </g>
      </>
    ),
  },
  {
    title: "Paper canyon",
    seed: "33608",
    svg: (
      <>
        <rect width="200" height="200" fill="#431407" />
        <circle cx="96" cy="100" r="112" fill="#7c2d12" />
        <circle cx="102" cy="104" r="90" fill="#c2410c" />
        <circle cx="108" cy="108" r="70" fill="#f97316" />
        <circle cx="113" cy="112" r="51" fill="#fdba74" />
        <circle cx="118" cy="116" r="33" fill="#ffedd5" />
        <circle cx="122" cy="119" r="13" fill="#0f766e" />
      </>
    ),
  },
];

// Timeline in milliseconds.
const TYPE_MS = 9;
const FIRST = 250; // first tile starts resolving
const STAGGER = 220; // each next tile starts this much later
const BASE = 1900; // how long a tile takes
const SLOWER = 150; // each next tile takes this much longer
const STEPS = 30;
const END = FIRST + STAGGER * (art.length - 1) + BASE + SLOWER * (art.length - 1) + 60;
const HOLD = 4200;
const GRAIN = 56; // noise resolution in pixels per side

/** How finished a tile is, 0 to 1, with a soft landing. */
function resolved(i: number, now: number) {
  const p = Math.min(1, Math.max(0, (now - FIRST - i * STAGGER) / (BASE + i * SLOWER)));
  return 1 - (1 - p) * (1 - p);
}

export default function AiImageGenerationGridResolvingFromNoise() {
  const reduce = useReducedMotion();
  const ref = useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { amount: 0.2 });
  const canvases = useRef<(HTMLCanvasElement | null)[]>([]);
  const clock = useRef(END);
  const [now, setNow] = useState(END);
  const [run, setRun] = useState(0);
  const [ratio, setRatio] = useState(0);
  const [picked, setPicked] = useState(0);

  useEffect(() => {
    if (reduce) return;
    clock.current = 0;
    setNow(0);
  }, [run, reduce]);

  // One loop for everything: advance the clock and repaint the noise about 15 times a second.
  useEffect(() => {
    if (reduce || !inView) return;
    let raf = 0;
    let last = performance.now();
    let since = 0;
    const frame = (t: number) => {
      raf = requestAnimationFrame(frame);
      const dt = Math.min(100, t - last);
      last = t;
      clock.current += dt;
      since += dt;
      if (clock.current > END + HOLD) clock.current = 0;
      if (since < 66) return;
      since = 0;
      const n = Math.min(clock.current, END);
      setNow(n);
      canvases.current.forEach((c, i) => {
        if (!c || resolved(i, n) >= 1) return;
        const ctx = c.getContext("2d");
        if (!ctx) return;
        const img = ctx.createImageData(GRAIN, GRAIN);
        for (let k = 0; k < img.data.length; k += 4) {
          img.data[k] = Math.random() * 255;
          img.data[k + 1] = Math.random() * 255;
          img.data[k + 2] = Math.random() * 255;
          img.data[k + 3] = 255;
        }
        ctx.putImageData(img, 0, 0);
      });
    };
    raf = requestAnimationFrame(frame);
    return () => cancelAnimationFrame(raf);
  }, [reduce, inView]);

  const typed = Math.min(PROMPT.length, Math.floor(now / TYPE_MS));
  const finished = art.filter((_, i) => resolved(i, now) >= 1).length;
  const done = finished === art.length;

  return (
    <section className="relative overflow-hidden bg-neutral-950 px-4 py-16 sm:px-6 sm:py-20">
      <style>{`
        @keyframes ai-image-generation-grid-caret { 0%, 49% { opacity: 1; } 50%, 100% { opacity: 0; } }
        @keyframes ai-image-generation-grid-glare { from { transform: translateX(-140%) skewX(-18deg); } to { transform: translateX(340%) skewX(-18deg); } }
        @keyframes ai-image-generation-grid-pop { from { transform: scale(0.4); opacity: 0; } to { transform: scale(1); opacity: 1; } }
        .ai-image-generation-grid-caret { animation: ai-image-generation-grid-caret 0.9s step-end infinite; }
        .ai-image-generation-grid-glare { animation: ai-image-generation-grid-glare 0.9s ease-out both; }
        .ai-image-generation-grid-pop { animation: ai-image-generation-grid-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
        @media (prefers-reduced-motion: reduce) {
          .ai-image-generation-grid-caret, .ai-image-generation-grid-pop { animation: none; }
          .ai-image-generation-grid-glare { display: none; }
        }
      `}</style>
      <div aria-hidden="true" className="pointer-events-none absolute -top-40 left-1/2 h-[480px] w-[900px] -translate-x-1/2 rounded-full bg-fuchsia-600/20 blur-[130px]" />

      <div className="relative mx-auto max-w-5xl">
        <div className="mx-auto max-w-2xl text-center">
          <h2 className="text-4xl font-semibold tracking-tight text-white sm:text-5xl">Four takes on every idea</h2>
          <p className="mt-4 text-lg leading-relaxed text-neutral-300">Write what you see in your head. Pick the take you like, then push it further.</p>
        </div>

        <div className="mx-auto mt-10 max-w-3xl rounded-2xl bg-white/[0.06] p-2 ring-1 ring-white/10">
          <div className="flex items-center gap-3 pl-3">
            <p className="min-w-0 flex-1 py-2 text-sm leading-snug text-white sm:text-base" aria-label={`Prompt: ${PROMPT}`}>
              <span aria-hidden="true">
                {PROMPT.slice(0, typed)}
                {typed < PROMPT.length && <span className="ai-image-generation-grid-caret ml-px inline-block h-[1.05em] w-0.5 translate-y-[0.18em] bg-fuchsia-300" />}
                <span className="opacity-0">{PROMPT.slice(typed)}</span>
              </span>
            </p>
            <button type="button" onClick={() => setRun((r) => r + 1)} className="h-11 shrink-0 rounded-xl bg-fuchsia-600 px-4 text-sm font-semibold text-white transition-colors hover:bg-fuchsia-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-white">
              Generate
            </button>
          </div>
          <div className="mt-2 flex flex-wrap items-center gap-2 border-t border-white/10 px-1 pt-2">
            <div role="group" aria-label="Aspect ratio" className="flex rounded-lg bg-black/40 p-0.5">
              {ratios.map((r, i) => (
                <button key={r.label} type="button" aria-pressed={ratio === i} onClick={() => setRatio(i)} className={`h-10 min-w-[48px] rounded-md px-3 font-mono text-xs font-semibold transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-fuchsia-300 ${ratio === i ? "bg-white text-neutral-950" : "text-neutral-300 hover:text-white"}`}>
                  {r.label}
                </button>
              ))}
            </div>
            <span className="rounded-lg bg-black/40 px-3 py-2.5 text-xs font-medium text-neutral-300">Style: paper cut</span>
            <p className="ml-auto px-2 font-mono text-xs tabular-nums text-neutral-300" aria-live="polite">{done ? `${art.length} images in ${(END / 1000).toFixed(1)}s` : `Rendering ${Math.min(art.length, finished + 1)} of ${art.length}`}</p>
          </div>
        </div>

        <div ref={ref} className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-4">
          {art.map((a, i) => {
            const e = resolved(i, now);
            const ready = e >= 1;
            const on = picked === i;
            return (
              <button
                key={a.title}
                type="button"
                aria-pressed={on}
                aria-label={`${a.title}, take ${i + 1}${ready ? "" : ", still rendering"}`}
                onClick={() => setPicked(i)}
                className={`group relative overflow-hidden rounded-2xl bg-neutral-900 transition-shadow duration-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-white ${ratios[ratio].box} ${on && ready ? "shadow-[0_0_44px_-6px_rgba(232,121,249,0.65)] ring-2 ring-fuchsia-400" : "ring-1 ring-white/10"}`}
              >
                <svg viewBox="0 0 200 200" preserveAspectRatio="xMidYMid slice" aria-hidden="true" className="absolute inset-0 h-full w-full" style={{ opacity: Math.min(1, e * 1.7), filter: ready ? "none" : `blur(${((1 - e) * 26).toFixed(1)}px) saturate(${(0.5 + e * 0.5).toFixed(2)})`, transform: `scale(${(1.22 - e * 0.22).toFixed(3)})` }}>
                  {a.svg}
                </svg>
                <canvas ref={(el) => { canvases.current[i] = el; }} width={GRAIN} height={GRAIN} aria-hidden="true" className="absolute inset-0 h-full w-full [image-rendering:pixelated]" style={{ opacity: ready ? 0 : (1 - e) * 0.92 }} />
                {ready && <span key={run} aria-hidden="true" className="ai-image-generation-grid-glare absolute inset-y-0 left-0 w-1/3 bg-gradient-to-r from-transparent via-white/40 to-transparent" />}

                <span className="absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t from-black/75 to-transparent p-2.5 pt-8 text-left">
                  <span className="min-w-0">
                    <span className="block truncate text-xs font-semibold text-white">{ready ? a.title : "Resolving"}</span>
                    <span className="block font-mono text-[10px] tabular-nums text-neutral-200">{ready ? `Seed ${a.seed}` : `Step ${Math.max(1, Math.round(e * STEPS))} of ${STEPS}`}</span>
                  </span>
                  {ready && on && (
                    <span key={`${run}-${i}`} className="ai-image-generation-grid-pop grid h-6 w-6 shrink-0 place-items-center rounded-full bg-fuchsia-500 text-white">
                      <svg viewBox="0 0 20 20" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="m4.5 10.5 3.5 3.5 7.5-8" /></svg>
                    </span>
                  )}
                </span>
                {!ready && <span aria-hidden="true" className="absolute inset-x-0 bottom-0 h-0.5 origin-left bg-fuchsia-400" style={{ transform: `scaleX(${e.toFixed(3)})` }} />}
              </button>
            );
          })}
        </div>

        <p className="mt-5 text-center text-sm text-neutral-300">
          {done ? <>Take {picked + 1}, <span className="font-semibold text-white">{art[picked].title}</span>, is selected. Tap another to compare.</> : "Each take starts as noise and sharpens step by step."}
        </p>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/ai-image-generation-grid-resolving-from-noise.json

When to use it

How to customize it

Accessibility

Questions

How does the noise-to-image effect work?

Two layers sit in each tile. The bottom one is the finished artwork with a CSS `blur`, a lowered `saturate` and a slight zoom. The top one is a small canvas refilled with random colour pixels about 15 times a second. A single progress value from 0 to 1 lowers the blur and zoom on the artwork and the opacity of the canvas, so the picture appears to come out of the static.

Is it expensive to animate?

No. Each noise canvas is only 56 by 56 pixels and is repainted at about 15 frames a second, and only while its tile is unfinished. The loop stops when the grid is off screen and browsers pause `requestAnimationFrame` in hidden tabs.

Does this generate real images?

No. The four pieces are fixed SVG drawings and nothing is requested from any service. To use it with a real generator, set each tile's progress from your job status and swap the SVG for the returned image.

How do I change the number of tiles?

Add or remove entries in the `art` array. The timeline is computed from its length. Update `lg:grid-cols-4` to match the count you want across on large screens.

Why draw the artwork with SVG gradients?

It keeps the component self-contained: no image hosting, no layout shift while files load, and the pieces stay sharp at any size. Each one is a few shapes filled with linear or radial gradients.

Related: Glowing AI Prompt Box with Morphing Send Button, Masonry Gallery with Staggered Reveal and Hover Lift, AI Site Builder Demo with Self-Assembling Wireframe.

More ai prompt and chat ui or all free UI components. Start for free to have a full site built around it.

More in AI Chat

We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.

Product

Who It's For

Features

Resources

Company

View Sitemap