web design
COMPONENT C / ASCII LAVA BACKGROUND · FIXED WHILE SCROLLING

Engineered,
not decorated.
Each pixel earns its place.

The ASCII field animation behind this text rises like a lava lamp from the bottom of the screen. Scroll and it holds position while the page moves over it.

001 / SCROLL CHECK

The background does not move with the page.

GUIDE / ASCII LAVA BACKGROUND

Overview

A fixed full-viewport ASCII field that breathes upward like a lava lamp, sitting behind the page content. A cheap three-sine pseudo-noise sampled on a character grid, rendered as one textContent write per tick at roughly 11fps.

When to use

The hero and top of the home page, where the field can hold steady while the page scrolls over it. Pair with a solid headline and keep body copy short, so the texture stays behind the message, not under it.

Spec

Tokens
rgba cyan .09 field, --line-faint rules
Motion
3-sine field, ~11fps, y-phase drift
A11y
aria-hidden, pointer-events none, reduced-motion static
Perf
one textContent write per tick, no canvas

Anti-patterns

Do not raise the field opacity above .12, it competes with text. Do not animate top or left, only the character buffer. Keep it at z-index 0 behind the content.

<!-- Requires GSAP: https://cdnjs.cloudflare.com/ajax/libs/gsap/3.13.0/gsap.min.js -->

<style>
:root{
  --black:#050505;--white:#FFFFFF;--grey:#A3A3A3;--grey-dim:#6B6B6B;--accent:#22D3EE;
  --line:rgba(255,255,255,.16);--line-faint:rgba(255,255,255,.07);
  --font-display:'Space Grotesk',system-ui,sans-serif;--font-mono:'JetBrains Mono',ui-monospace,monospace;
}
*{margin:0;padding:0;box-sizing:border-box;border-radius:0!important}
body{background:var(--black);color:var(--white);font-family:var(--font-display)}
#ascii{
  position:fixed;inset:0;z-index:0;overflow:hidden;
  font-family:var(--font-mono);font-size:12px;line-height:1.35;
  color:rgba(34,211,238,.09);
  white-space:pre;pointer-events:none;user-select:none;
}
main{position:relative;z-index:1;padding:0 48px}
.hero{min-height:90vh;display:flex;flex-direction:column;justify-content:center;max-width:1440px;margin:0 auto}
.hero h1{font-size:clamp(2.75rem,7vw,6rem);font-weight:700;line-height:1.05;letter-spacing:-.03em;max-width:14ch}
.hero h1 em{font-style:normal;color:var(--grey-dim)}
.hero p{color:var(--grey);max-width:52ch;margin-top:40px}
section.filler{border-top:1px solid var(--line);padding:96px 0;min-height:60vh}
section.filler .k{font-family:var(--font-mono);font-size:12px;letter-spacing:.08em;text-transform:uppercase;color:var(--grey);display:block;margin-bottom:24px}
section.filler h2{font-size:clamp(2rem,4vw,3.5rem);font-weight:700;letter-spacing:-.02em;line-height:1.05}
</style>

<div class="hero">
    <h1>Engineered,<br>not decorated.<br><em>Each pixel earns its place.</em></h1>
    <p>The ASCII field animation behind this text rises like a lava lamp from the bottom of the screen. Scroll and it holds position while the page moves over it.</p>
  </div>
  <section class="filler"><span class="k">001 / SCROLL CHECK</span><h2>The background does not move with the page.</h2></section>

<script>
/* Lava-lamp ASCII field. A cheap 3-sine pseudo-noise field sampled on a
   character grid; the y-phase advances with time so the blobs rise from
   the bottom of the screen. Rendered as one textContent write per tick. */
const el = document.getElementById("ascii");
const RAMP = " .:-=+*#";
let cols = 0, rows = 0;

function measure(){
  const probe = document.createElement("span");
  probe.textContent = "MMMMMMMMMM";
  probe.style.cssText = "position:absolute;visibility:hidden;font:inherit";
  el.appendChild(probe);
  const cw = probe.getBoundingClientRect().width / 10;
  el.removeChild(probe);
  const lh = parseFloat(getComputedStyle(el).lineHeight);
  cols = Math.ceil(innerWidth / cw);
  rows = Math.ceil(innerHeight / lh) + 1;
}

function render(t){
  let out = "";
  for (let y = 0; y < rows; y++){
    for (let x = 0; x < cols; x++){
      const u = x * 0.45; // aspect correction so blobs read round, not stretched
      let n = Math.sin(u * 0.35 + Math.sin(t * 0.5 + y * 0.12) * 1.4)
            + Math.sin(y * 0.16 + t * 1.1)   /* +t on the y phase = upward drift */
            + Math.sin((u * 0.12 + y * 0.09) + t * 0.4);
      n = (n + 3) / 6; // normalize to 0..1
      out += n > 0.72 ? RAMP[Math.min(RAMP.length - 1, (((n - 0.72) / 0.28) * RAMP.length) | 0)] : " ";
    }
    out += "\n";
  }
  el.textContent = out;
}

/* ~11fps via ticker throttle - chunky character motion is the
   aesthetic; move to canvas if a profiler ever says the pre repaint hurts */
let last = -1;
function tick(time){
  if (time - last < 0.09) return;
  last = time;
  render(time * 0.7);
}

const mm = gsap.matchMedia();
mm.add({
  full:   "(prefers-reduced-motion: no-preference)",
  reduce: "(prefers-reduced-motion: reduce)"
}, (ctx) => {
  measure();
  if (ctx.conditions.full){
    gsap.ticker.add(tick);
    return () => gsap.ticker.remove(tick);
  }
  render(0); // reduced motion: one static frame
});
addEventListener("resize", measure);
</script>