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.
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>