All components

Contact Form Reveal

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

Open live demo ↗ Raw prompt (.md)

What it does

A landing hero whose oversized display title reveals on load by splitting into per-character spans that GSAP tweens up from a clipped baseline with a staggered power4.out ease, while nav/tagline/CTA fade in. Clicking 'Apply now' plays a paused GSAP timeline that fades in a fullscreen dark contact-form overlay and staggers a giant 'Apply' wordmark upward; '[ back ]' reverses the same timeline.

How it's built

Categorytransition
Techgsap
Complexitypage
Performance costlight
Mobile-safeyes

contact-form overlay text-reveal split-text staggered clip-path timeline gsap editorial

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

Editorial Contact-Form Overlay — Giant Serif Title Reveals Char-by-Char on Load, Fullscreen Dark Form Wipes In on Click

Goal

Build a full-viewport editorial hero on a bone/off-white background whose star effect is a per-character display-title reveal: a colossal serif headline (set at 20vw) is split into single-character <span>s that GSAP tweens up from a clipped baseline (each char starts pushed 500px below and rises into a clip-path mask) with a staggered power4.out ease on load, while the nav / tagline / CTA / social links fade in a beat later. Clicking the "Apply now" button plays a paused GSAP timeline that fades a fullscreen dark contact-form overlay in over everything and then staggers a giant single-line "Apply" wordmark (sized in vw, anchored bottom-right) upward with the same clipped char-reveal; clicking "[ back ]" inside the overlay reverses the exact same timeline to dismiss it. Pure click/load driven — no scroll animation.

The overlay is a locked two-column grid — a narrow ~1/3 left column holds the stacked form (logo, vertically-stacked fields, submit button) hard against the left edge; a wide ~2/3 right column holds a short intro paragraph up top and the enormous "Apply" wordmark down in its bottom-right corner. Getting that grid, the vertical field stacking, and the vw-scaled single-line wordmark right is as important as the GSAP gesture.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) only — no plugins, no ScrollTrigger, no SplitText library, no Lenis, no canvas/WebGL. The character splitting is done by hand in plain JS (see below). Import as:

import gsap from "gsap";

All code runs inside DOMContentLoaded listeners (two of them in the original — one for the load reveal, one for the overlay toggle). Do not use GSAP's SplitText; write a tiny helper that wraps each character in a <span>.

Layout / HTML

section.hero                         (the light landing screen)
  .nav > p                           (top-center, tiny uppercase meta — 2 lines via <br>)
  .header                            (absolutely centered block; CLIP-PATH masked)
    .header-text > h1  "La Piga"     (the giant 20vw display title)
    .cta > button#toggle  "Apply now"   (opens the overlay)
  .tagline > p                       (bottom-center, tiny uppercase meta — 2 lines via <br>)
  .links                             (bottom-left) > button ×2  "Instagram" "Twitter"

.overlay                             (fixed fullscreen dark panel, starts opacity:0 / pointer-events:none)
                                     display:flex — a LOCKED two-column grid
  .col   (nth-child 1 → flex:1  ≈ 1/3 width, thin cream border-right, flex COLUMN, space-between)
    .logo > a  "La Pige"           (pinned to the top of the narrow left column)
    .form > form                   (each field is a VERTICALLY STACKED group — see note below)
      label[for=fname] "Name*"      (block, on its own line)  →  input#fname (own line, underline, placeholder "first + surname")
      label[for=lname] "Location*"  (block, on its own line)  →  input#lname (own line, underline, placeholder "e.g france")
      label[for=website] "Website"  (block, on its own line)  →  input#website (own line, underline, placeholder "https://")
      label "Disciplines"           (block, on its own line)
      .jobs
        .job-items > .item ×5   (each = <input type=checkbox> + <label>)   — column A
        .job-items > .item ×5   (each = <input type=checkbox> + <label>)   — column B
      button  "Send Application"    (bottom of the left column)
  .col   (nth-child 2 → flex:2  ≈ 2/3 width, position context for .send)
    .copy > p "[ get featured ]"  + p#back "[ back ]"     (space-between row, top)
    .about > p   (short vetting blurb, ~40% width, top area)
    .send > h1  "Apply"           (the giant display wordmark — ONE line, sized in vw, absolutely
                                    positioned bottom-right of this column; CLIP-PATH masked)

script type="module" src="./script.js"

Form-field stacking (load-bearing — the #1 fidelity fix): every text field is a *vertical* group — the <label> renders on its own line as a block, and its <input> renders on the next line as its own full-line underlined field. Label and input are NEVER on the same line. In markup this is done with <label>…</label><br /><input … /><br /><br /> (block label + explicit <br> breaks); an equivalent robust approach is to wrap each field in a display:flex; flex-direction:column container. The three inputs and the "Disciplines" label all sit flush against the left edge of the narrow left column, stacked one under the next — so nothing floats to the right of a previous input.

Load-bearing ids/classes the JS needs: #toggle, #back, .overlay, .header-text h1, .send h1, .cta, .nav, .tagline, .links. The clip-path masks live on .header and .send.

Copy / naming: use a neutral fictional identity — a freelance-directory demo called "La Pige" (giant hero title spelled "La Piga"). The .nav meta reads like *"The freelance directory / a small studio creation"* and the .tagline like *"Indépendant; à la pige; adj release / v1.0 coming soon"*. The .about blurb: *"To be considered for a listing on La Pige, please fill out your details opposite. Each application will be carefully reviewed and vetted while v1.0 release is being worked on."* Discipline checkbox labels (10 total): Digital Design, Front-end, Webgl, Photography, Illustration, Brand Design, Back-end, 3D, Motion, Strategy. No real brands or personal names.

Styling

Reset: * { margin:0; padding:0; box-sizing:border-box; }. html, body { width:100%; height:100%; background:#eae9e4; font-family:"Neue Montreal"; }.

Palette (lock these four hex values exactly — do not drift to generic black/white):

  • #eae9e4 — warm bone / off-white: base page + hero background, and the text/border/placeholder/cream color for *everything inside* the dark overlay (logo, labels, inputs, underlines, the "Apply" wordmark, the thin column divider).
  • #262625 — near-black charcoal: the .overlay panel background (a hair warmer than pure black — keep the exact hex).
  • #000 — pure black: default button border + hover fill, #toggle background, .form button text.
  • #fff — white: #toggle text.

Typography lock: the two giant display words — the hero "La Piga" and the overlay "Apply" — are both set in the high-contrast serif display face (font-weight: lighter). Everything else (nav/tagline meta, labels, placeholders, the intro blurb, buttons, the "La Pige" logo) is the neutral grotesque sans. Do not set the giant words in a sans, and do not set the body UI in a serif.

Fonts (both proprietary, both intentionally NOT loaded via @font-face — so they fall back to platform defaults; the look is defined by the sizes, not the family):

  • Body / UI: "Neue Montreal" — a neutral grotesque sans. Substitute any clean neutral sans (e.g. a Helvetica/Inter-like grotesque).
  • Giant display titles: "Timmons NY 2.005" — a high-contrast condensed serif display face, font-weight: lighter. Substitute any elegant high-contrast display serif (Didone/Times-style).

Buttons: background:none; outline:none; border:1px solid #000; text-transform:uppercase; padding:0.5em 1em; border-radius:2em; cursor:pointer;. button:hover and button#togglebackground:#000; color:#fff;. .form button overrides to color:#000; background:#eae9e4; border:1px solid #eae9e4;.

Structural CSS (load-bearing for the effect & look):

  • .nav, .tagline { position:absolute; width:100%; display:flex; justify-content:center; text-transform:uppercase; font-size:12px; padding:1.5em 0; text-align:center; }; .tagline { bottom:0; } (nav sits at top).
  • .links { position:absolute; bottom:1.5em; left:1.5em; display:flex; gap:1em; }.
  • .header { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); text-align:center; }.
  • .header-text h1 { font-family:"Timmons NY 2.005"; font-size:20vw; font-weight:lighter; }.
  • .cta { margin:1em 0; }.
  • .overlay { position:fixed; top:0; left:0; width:100vw; height:100vh; background:#262625; display:flex; padding:1em; opacity:0; pointer-events:none; } — the panel that fades in over the hero.
  • The two-column grid is a hard lock. .overlay is display:flex; .col:nth-child(1) { flex:1; ... } and .col:nth-child(2) { flex:2; ... } split the panel into a narrow ≈1/3 left column and a wide ≈2/3 right column. This ratio is what anchors the form hard against the left edge instead of drifting toward center — keep flex:1 / flex:2, not flex:1 / flex:1.
  • .col:nth-child(1) { flex:1; height:100%; border-right:1.5px solid #eae9e4; display:flex; flex-direction:column; justify-content:space-between; padding-bottom:3em; } — a flex column with space-between, so .logo sits at the very top and the Send Application button at the very bottom, with the form filling between. The thin cream border-right is the divider between the two columns.
  • .col:nth-child(2) { flex:2; padding:0 1em; } — this column is the positioning context (its .send child is absolutely positioned within it).
  • .logo { width:max-content; border:1px solid #eae9e4; }; .logo a { padding:0 0.25em; text-decoration:none; color:#eae9e4; text-transform:uppercase; }.
  • .about { width:40%; color:#eae9e4; margin:1em 0; }.
  • .copy { display:flex; justify-content:space-between; color:#eae9e4; text-transform:uppercase; font-size:12px; }; #back { cursor:pointer; }.
  • The giant "Apply" wordmark — must stay one line and scale with the viewport. .send { position:absolute; bottom:0; right:0; } anchors it to the bottom-right corner of the wide right column. .send h1 { color:#eae9e4; font-family:"Timmons NY 2.005"; text-transform:uppercase; font-weight:lighter; white-space:nowrap; font-size:~20vw; }. Two rules are critical: white-space:nowrap so "APPLY" never breaks into two lines, and a viewport-relative size (~18–22vw, use ~20vw) so it grows/shrinks with the viewport and always fits inside the ~2/3 right column. Do not use a fixed pixel size (e.g. 400px) — that is exactly what wrapped "APP / LY" onto two lines during reproduction.
  • Form inputs are stacked, full-line, underlined fields — never inline with their label. .form label { color:#eae9e4; font-size:20px; display:block; } (block, so the label owns its own line above the input). input[type="text"] { display:block; width:100%; outline:none; background:none; border:none; border-bottom:1px solid #eae9e4; padding:0.5em 0; margin:1em 0; color:#eae9e4; } — each input is a block on its own line directly under its label, its underline spanning the full width of the narrow left column, left-aligned. ::placeholder { text-transform:uppercase; color:#eae9e4; font-size:12px; }. The result is a clean vertical stack: Name / underline / Location / underline / Website / underline / Disciplines — every label flush-left, every underline flush-left, nothing floating to the right.
  • .jobs { margin:1em 0 3em 0; display:flex; gap:1em; } (two .job-items columns side by side); .job-items label { text-transform:uppercase; font-size:12px; }; .item { display:flex; gap:0.5em; align-items:center; }.

The clip-path masks (critical for the reveal):

.send, .header {
  clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);   /* full-box rect → acts as an overflow mask */
}
.send h1 span,
.header-text h1 span {
  position: relative;
  top: 500px;                                            /* each char starts 500px BELOW, clipped out of view */
}

The full-box clip-path rectangle clips each title element to its own box; the per-character spans, pushed top:500px, sit below that box and are hidden. GSAP animates each span's top back to 0, so characters rise up into the masked window from below — a per-character upward reveal with a hard clean baseline.

GSAP effect (exhaustive)

0. Character-splitting helper

Write a plain helper (NOT SplitText): given a selector, take the element's innerText, split on "" (every character, spaces included), wrap each char in <span>…</span>, and set the element's innerHTML to the joined string:

function splitTextIntoSpans(selector) {
  const el = document.querySelector(selector);
  if (!el) return;
  el.innerHTML = el.innerText.split("").map(c => `<span>${c}</span>`).join("");
}

Call it for .send h1 and .header-text h1 on load, before any tween. ("La Piga" → 7 spans incl. the space; "Apply" → 5 spans. The spans inherit position:relative; top:500px from the CSS above.)

1. Load reveal (first DOMContentLoaded) — fires immediately, no trigger

After splitting, run two tweens:

a) Giant header title rises in, char by char:

gsap.to(".header-text h1 span", {
  top: 0,            // 500px → 0 : each char rises into the clip mask
  duration: 1,
  ease: "power4.out",
  stagger: 0.075,    // left-to-right, 75ms between chars
});

b) Meta / CTA / links fade up a beat later:

gsap.from(".cta, .nav, .tagline, .links", {
  opacity: 0,
  duration: 1,
  stagger: 0.1,
  delay: 1,          // waits ~1s so the title reveal reads first
});

Net load choreography: title characters climb up out of the masked baseline (staggered power4.out), then after the delay the tiny nav, tagline, social buttons and the "Apply now" CTA fade on in a 0.1s-staggered cascade.

2. Overlay toggle timeline (second DOMContentLoaded) — a single paused timeline reused for open AND close
const timeline = gsap.timeline({ paused: true });

timeline.to(".overlay", {
  opacity: 1,
  duration: 0.3,
  pointerEvents: "all",        // panel fades up over the hero and becomes interactive
});

timeline.to(".send h1 span", {
  top: 0,                      // giant "Apply" wordmark rises into its clip mask
  duration: 1,
  ease: "power4.out",
  stagger: 0.075,             // same char cadence as the header
});

The two steps are sequential (default timeline position): first the dark .overlay fades in over 0.3s, then the .send h1 "Apply" characters stagger up from top:500px → 0.

3. Play / reverse wiring (shared isOpen flag across both buttons)
let isOpen = false;
function toggle() {
  isOpen ? timeline.reverse() : timeline.play();
  isOpen = !isOpen;
}
document.querySelector("#toggle").addEventListener("click", toggle);  // "Apply now"
document.querySelector("#back").addEventListener("click", toggle);     // "[ back ]"

Both "Apply now" (#toggle) and "[ back ]" (#back) call the same handler and share one isOpen boolean. So: first click on "Apply now" plays the timeline (overlay fades in, "Apply" reveals) and sets isOpen = true; clicking "[ back ]" then reverses the same timeline (letters retract downward into the mask, overlay fades back to opacity:0 / pointer-events:none) and sets isOpen = false. Reversing plays both steps backward, so pointer-events returns to none and the overlay is dismissed cleanly. There is no separate close animation — it is literally the open timeline run in reverse.

Assets / images

No images. This component uses zero image assets — the entire visual is typography, borders, and the two flat background colors. Do not add any <img> tags or background images.

Behavior notes

  • No scroll animation whatsoever — everything is load-triggered (the hero reveal) or click-triggered (the overlay). No ScrollTrigger, no Lenis, no requestAnimationFrame loop.
  • Overlay starts hidden at opacity:0; pointer-events:none; so it never blocks the hero until "Apply now" is clicked.
  • Re-entrancy: the original uses a single paused timeline plus one boolean; rapid clicks simply play/reverse. No animating-guard is used.
  • Responsive (@media (max-width:900px)): .logo gains margin-bottom:2em; .links becomes display:none; .overlay switches to flex-direction:column-reverse (form stacks under the giant title, so the single-column layout keeps the form left-aligned above); .col:nth-child(1) drops its border; .send keeps right:1em. Because the hero title (20vw) and the "Apply" wordmark (~20vw, white-space:nowrap) are already viewport-relative, they stay on one line and rescale automatically at narrow widths — no fixed-px override needed (if you cap it, keep it single-line, e.g. font-size:150px).
  • Reduced motion: the original ships no prefers-reduced-motion guard; motion is short and user-initiated.