All components

LukeBaffait Animated Footer

GSAP animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Fixed full-viewport footer revealed behind the page on scroll: a spacer div fires ScrollTrigger callbacks (in at top 50%, out on leave-back at top 85%) that stagger SplitText chars of the split 'Half tone' wordmark heading from the center, unmask footer links/paragraph lines, and slide two hand images in from the sides. The hands render as orange ASCII art on canvas (brightness sampling) with random cluster highlights on hover and lerped mouse parallax; Lenis drives the smooth scroll.

How it's built

Categoryfooter
Techgsap, lenis
GSAP pluginsScrollTrigger, SplitText
Complexitysection
Performance costmedium
Mobile-safeyes
Scrollhijacks scrolling

footer scroll reveal ascii canvas splittext stagger parallax hover lenis

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

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.

Sectionsposition: 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-revealerposition: relative; width: 100%; height: 100svh. A transparent full-height spacer; scrolling it into view exposes the fixed footer sitting behind it.

footerposition: 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-imagesposition: absolute; inset: 0; display: flex; justify-content: space-between; align-items: center (pins one hand to each edge).

.footer-hand-imgposition: 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-contentposition: 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-headerposition: 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 }. The overflow: hidden is 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.create each .footer-header h1 with { type: "chars", charsClass: "char" }; collect all .chars. Then gsap.set(chars, { position: "relative", yPercent: 125 }) — every letter starts pushed 125% below its line, clipped by the h1's overflow:hidden.
  • Content lines. SplitText.create each .footer-links a and .footer-text p with { type: "lines", mask: "lines", linesClass: "line" } (the mask:"lines" wraps each line in its own overflow-hidden clip). Collect all .lines, then gsap.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), read getImageData(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 a Map keyed "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 its highlightEndTime = 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 window mousemove, set a pointer target from the footer's rect: pointer.x = ((clientX-rect.left)/rect.width - 0.5) * PARALLAX_STRENGTH*2 (range ≈ ±20px), same for pointer.y.
  • A rAF loop eases a drift toward pointer: 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. Write wrapper.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 their revealX (±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 reads reveal live).
  • 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 window mousemove, so they are pointer-only; on touch the hands still render and slide in but sit static.
  • DPR is hard-coded to 2 (not devicePixelRatio) 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.