Scroll-Revealed ASCII Footer
Goal
Build a long-scroll page whose fixed, full-viewport footer is revealed from behind the page as you reach the bottom. The star effect is the reveal choreography that fires when the footer scrolls into view: the giant two-word heading (Blank / Canvas) slides its letters up from a masked baseline, staggered from the center outward; the footer nav links and paragraph un-mask line by line; and two big hand images slide in from the left and right edges. Those hands are not shown as photos — each is rasterized live into orange ASCII art on a <canvas>, and hovering the hands lights up random clusters of characters in bright orange while a lerped mouse-parallax drifts and scales both hands. Smooth scroll via Lenis.
Tech
Vanilla HTML/CSS/JS with ES module imports (<script type="module">). Use gsap (npm) plus the GSAP plugins ScrollTrigger and SplitText, and lenis for smooth scroll. Register once: gsap.registerPlugin(ScrollTrigger, SplitText). No framework. Runs in a fresh Vite project. The ASCII rendering, hover highlight, and parallax are all hand-written Canvas 2D + requestAnimationFrame — GSAP only drives the reveal/hide of the heading, lines, and the hands' slide-in offset.
Layout / HTML
Three full-height opaque spacer sections, then a spacer div.footer-revealer (this is the ScrollTrigger target — it does not render anything itself), then the fixed <footer>:
<section class="one"><h1>One</h1></section>
<section class="two"><h1>Two</h1></section>
<section class="three"><h1>Three</h1></section>
<div class="footer-revealer"></div>
<footer>
<div class="footer-images">
<div class="footer-hand-img">
<img class="ascii-hand" src="/images/hand-left.jpg" alt="" />
</div>
<div class="footer-hand-img">
<img class="ascii-hand" src="/images/hand-right.jpg" alt="" />
</div>
</div>
<div class="footer-content">
<nav class="footer-links">
<a href="#">Work</a>
<a href="#">About</a>
<a href="#">Journal</a>
<a href="#">Contact</a>
</nav>
<div class="footer-text">
<p>A multidisciplinary studio working across direction, design and
motion. We build considered digital experiences for brands that
care about the details.</p>
</div>
</div>
<div class="footer-header">
<h1>Blank</h1>
<h1>Canvas</h1>
</div>
</footer>
<script type="module" src="./script.js"></script>
Classes the JS/CSS depend on: .footer-revealer (scroll trigger), .footer-images, .footer-hand-img (the two wrappers that get the parallax/slide transform), img.ascii-hand (each hidden source image + injected <canvas> sibling), .footer-content, .footer-links a, .footer-text p (line-split), .footer-header h1 (char-split). The two <canvas> elements are created in JS and appended into each .footer-hand-img.
Styling
Font — Google Fonts Instrument Sans (italic + weight 400..700). body { font-family: "Instrument Sans" }. Global reset * { margin:0; padding:0; box-sizing:border-box }.
Color tokens
- Section bg
#1b1b1b, section text#ff6a00(orange). - Footer bg
#0f0f0f(near-black), footer text#fff. - ASCII base char color
#803500(dark burnt orange), hover highlight fill#ff6a00(bright orange), highlighted char color#0f0f0f.
Sections — position: relative; z-index: 1; width: 100%; height: 100svh; background: #1b1b1b; color: #ff6a00; padding: 2rem; display: flex; justify-content: center; align-items: center; overflow: hidden. section h1 { font-size: clamp(2rem,5vw,8rem); font-weight: 500; letter-spacing: -2% }. The z-index:1 + opaque bg is what makes the sections cover the fixed footer while scrolling.
.footer-revealer — position: relative; width: 100%; height: 100svh. A transparent full-height spacer; scrolling it into view exposes the fixed footer sitting behind it.
footer — position: fixed; top: 0; left: 0; width: 100%; height: 100svh; background: #0f0f0f; overflow: hidden; z-index: 0. Fixed and behind everything; revealed as the sections/revealer scroll past.
.footer-images — position: absolute; inset: 0; display: flex; justify-content: space-between; align-items: center (pins one hand to each edge).
.footer-hand-img — position: relative; width: 40%; min-width: 200px; will-change: transform. This is the element JS transforms every frame.
.footer-hand-img img { display: block; width: 100%; opacity: 0 }— the source photo is never visible; it only feeds pixels to the canvas and defines the wrapper's height..footer-hand-img canvas { position: absolute; inset: 0; width: 100%; height: 100% }— the ASCII output, overlaid on the (invisible) img.
.footer-content — position: absolute; top: 0; left: 0; width: 100%; padding: 2rem; display: flex; justify-content: space-between; gap: 2rem; color: #fff.
.footer-links { display: flex; flex-direction: column; gap: 0.25rem },.footer-links a { color: #fff; text-decoration: none; font-size: 1.1rem }..footer-text { max-width: 28rem },.footer-text p { font-size: 1.1rem; line-height: 1.4 }.
.footer-header — position: absolute; bottom: 0; left: 0; width: 100%; padding: 2rem; display: flex; justify-content: space-between; align-items: flex-end; color: #fff.
.footer-header h1 { font-size: clamp(5rem,15vw,15rem); font-weight: 500; line-height: 1; letter-spacing: -2%; overflow: hidden }. Theoverflow: hiddenis the mask the char slide-up plays inside.
Responsive @media (max-width:1000px): .footer-content { flex-direction: column }; .footer-text { max-width: 100% }; .footer-header h1 { font-size: 3rem }.
The effect (be exhaustive)
1. Lenis smooth scroll wiring
const lenis = new Lenis();
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
2. Split the text (initial hidden states)
- Heading chars.
SplitText.createeach.footer-header h1with{ type: "chars", charsClass: "char" }; collect all.chars. Thengsap.set(chars, { position: "relative", yPercent: 125 })— every letter starts pushed 125% below its line, clipped by theh1'soverflow:hidden. - Content lines.
SplitText.createeach.footer-links aand.footer-text pwith{ type: "lines", mask: "lines", linesClass: "line" }(themask:"lines"wraps each line in its own overflow-hidden clip). Collect all.lines, thengsap.set(lines, { yPercent: 100 })— each line starts one full line-height below its mask.
3. ASCII canvas rendering (per hand)
Exact constants:
ASCII_CHARS = "........:::=+xX#0369" // 20-glyph ramp: 8 dots, then :::=+xX#0369
FONT_SIZE = 18 // px, monospace
CELL_SIZE = 20 // px per grid cell (canvas-logical)
ASCII_COLUMNS = 80 // fixed grid width in cells
DPR = 2 // fixed device-pixel-ratio multiplier
CHAR_COLOR = "#803500"
HOVER_COLOR = "#ff6a00"
HOVER_CHAR_COLOR= "#0f0f0f"
backgroundCharIndex = ASCII_CHARS.lastIndexOf(".") // = 7
Sampling → cells. For each hand image:
rows = Math.round(ASCII_COLUMNS / (naturalWidth / naturalHeight))(square 1:1 sources → 80 rows).- Draw the image into an offscreen canvas sized
ASCII_COLUMNS × rows(one pixel per cell, extreme downscale), readgetImageData(0,0,ASCII_COLUMNS,rows).data. - For every
col,row, perceptual brightness of that pixel:(R*0.299 + G*0.587 + B*0.114) / 255. Char index =Math.min(ASCII_CHARS.length-1, Math.floor((1 - brightness) * ASCII_CHARS.length))— note inverted brightness, so dark pixels map to the dense end of the ramp and bright pixels to dots. - Skip any cell whose
charIndex <= backgroundCharIndex(7): the near-white background of the source is discarded, only the darker hand renders. Store surviving cells in aMapkeyed"col,row"→{ col, row, char: ASCII_CHARS[charIndex], highlightEndTime: 0 }.
Canvas setup. Append a <canvas> into the hand wrapper. canvas.width = ASCII_COLUMNS*CELL_SIZE*DPR (3200), canvas.height = rows*CELL_SIZE*DPR. ctx.setTransform(DPR,0,0,DPR,0,0). ctx.font = "18px monospace", textAlign:"center", textBaseline:"alphabetic". Compute a vertical baseline offset from measureText("X") so glyphs sit centered in their cell: baselineOffset = CELL_SIZE/2 + glyphHeight/2 - actualBoundingBoxDescent where glyphHeight = actualBoundingBoxAscent + actualBoundingBoxDescent.
Render loop (requestAnimationFrame, per hand, forever). Clear the canvas. For each cell: x = col*CELL_SIZE, y = row*CELL_SIZE. If highlightEndTime > Date.now() → fill a CELL_SIZE×CELL_SIZE HOVER_COLOR rect at (x,y) and draw the char in HOVER_CHAR_COLOR; otherwise draw the char in CHAR_COLOR. Draw at (x + CELL_SIZE/2, y + baselineOffset).
Start each hand once its image is decoded (img.complete && img.naturalWidth ? start() : img.addEventListener("load", start)).
4. Hover cluster highlight (mousemove → random walk)
Constants: HOVER_RADIUS = 8 (cells), CLUSTER_SIZE = 10, HIGHLIGHT_LIFETIME = 300 (ms). On window mousemove, for each hand: map the cursor into grid space via the canvas getBoundingClientRect() (mouseCol = (clientX-rect.left)/rect.width * ASCII_COLUMNS, mouseRow = (clientY-rect.top)/rect.height * rows), find the nearest surviving cell by Euclidean distance. If that distance <= HOVER_RADIUS, ignite a cluster:
- Seed cell:
highlightEndTime = now + HIGHLIGHT_LIFETIME. - Take a random-length walk of
steps = floor(random()*CLUSTER_SIZE)+1(1..10). At each step, gather the up-to-8 neighbours (dx,dy ∈ {-1,0,1}, excluding self and already-lit), pick one at random, set itshighlightEndTime = now + HIGHLIGHT_LIFETIME + step*10(each hop lingers slightly longer), advance to it. Stop early if no unlit neighbour exists.
Net feel: sweeping the cursor over a hand paints short branching streaks of bright-orange lit glyphs that fade out ~300 ms later.
5. Mouse parallax (lerped, per-hand, requestAnimationFrame)
Constants: PARALLAX_STRENGTH = 20, PARALLAX_EASE = 0.05, parallaxScale = 1 + (PARALLAX_STRENGTH*2)/200 (= 1.2).
reveal = { left: -125, right: 125 }— the two hands' slide-in offset in % (GSAP animates these; see §6).- On
windowmousemove, set apointertarget from the footer's rect:pointer.x = ((clientX-rect.left)/rect.width - 0.5) * PARALLAX_STRENGTH*2(range ≈ ±20px), same forpointer.y. - A rAF loop eases a
drifttowardpointer:drift.x += (pointer.x - drift.x) * PARALLAX_EASE(0.05), same for y. - Each frame, for wrapper
i(0 = left hand, 1 = right hand):direction = i===0 ? 1 : -1,revealX = i===0 ? reveal.left : reveal.right,x = drift.x * direction,y = -drift.y. Writewrapper.style.transform = translate(calc(${x}px + ${revealX}%), ${y}px) scale(1.2). So the two hands drift in opposite horizontal directions with the mouse, share an inverted vertical drift, sit at 1.2× scale, and theirrevealX(±125% → 0) is what slides them on/off screen.
6. The reveal — ScrollTrigger callbacks (the star)
No scrub, no pin — two plain ScrollTriggers on .footer-revealer fire the in/out timelines via callbacks:
ScrollTrigger.create({ trigger: ".footer-revealer", start: "top 50%", onEnter: animateIn });
ScrollTrigger.create({ trigger: ".footer-revealer", start: "top 85%", onLeaveBack: animateOut });
animateIn (all fired together, all overwrite: true):
gsap.to(reveal, { left: 0, right: 0, duration: 1, ease: "power3.out" })— hands slide in from±125%to flush at the edges (the parallax loop readsreveallive).gsap.to(headingChars, { yPercent: 0, duration: 1, ease: "power3.out", stagger: { each: 0.04, from: "center" } })— letters rise from the masked baseline, rippling out from the center of each word.gsap.to(contentLines, { yPercent: 0, duration: 1, ease: "power3.out", stagger: 0.08 })— nav links + paragraph lines un-mask upward in sequence.
animateOut (mirror, faster, power2.in, all overwrite: true):
gsap.to(reveal, { left: -125, right: 125, duration: 0.4, ease: "power2.in" }).gsap.to(headingChars, { yPercent: 125, duration: 0.4, ease: "power2.in", stagger: { each: 0.01, from: "center" } }).gsap.to(contentLines, { yPercent: 100, duration: 0.4, ease: "power2.in", stagger: 0.02 }).
So scrolling down past the revealer's top 50% plays the full reveal; scrolling back up through top 85% snaps it all back out (hands off-screen, letters/lines re-masked).
Assets / images
- 2 distinct hand photographs, square 1:1 (source ~1440×1440), one per edge — not a single mirrored image. Both are the same bare human hand shot in grayscale on a near-white, softly-graded pale-grey studio background, the hand rendered in light-to-mid greys (skin, no jewelry) with the darkest values in the finger creases, knuckles and cast shadow. The two poses differ:
hand-left.jpg(left edge) — back of an open hand seen from above, filling the upper-right of the frame, the four fingers extended roughly horizontally toward the right with the thumb angling down; wrist exits the left side.hand-right.jpg(right edge) — the same hand viewed nearly edge-on / in profile, a slim horizontal sliver of fingers pointing left with the thumb dropping below, wrist exiting the right side; more empty background than the left image.- Both are loaded but rendered fully transparent (
opacity:0) — each exists only as a brightness source the script rasterizes into the orange ASCII canvas, so the load-bearing property is the contrast between the light/mid-grey hand and the brighter background (the background samples out to dots and is discarded, leaving only the hand as characters). No brand imagery; any neutral grayscale hand-on-light-background pair works — one hand reaching in from the left, a second from the right.
Behavior notes
- The reveal is callback-triggered, not scrubbed — it plays as a fixed-duration timeline once the threshold is crossed, and reverses on leave-back; re-entering replays it.
- The ASCII render loops, hover highlight, and parallax run continuously from load (no reduced-motion guard in the original). The hover/parallax read from
windowmousemove, so they are pointer-only; on touch the hands still render and slide in but sit static. DPRis hard-coded to 2 (notdevicePixelRatio) and the grid is a fixed 80 columns, so the ASCII resolution is constant regardless of screen; the CSS scales the canvas to fit the wrapper.- The three opaque full-height sections + the transparent revealer are what sell the "footer emerges from behind the page" illusion, combined with the hands sliding in and the heading/lines un-masking.