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.overlaypanel background (a hair warmer than pure black — keep the exact hex).#000— pure black: default button border + hover fill,#togglebackground,.form buttontext.#fff— white:#toggletext.
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#toggle → background:#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.
.overlayisdisplay: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 — keepflex:1/flex:2, notflex: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 withspace-between, so.logosits at the very top and theSend Applicationbutton at the very bottom, with the form filling between. The thin creamborder-rightis the divider between the two columns..col:nth-child(2) { flex:2; padding:0 1em; }— this column is the positioning context (its.sendchild 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:nowrapso "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-itemscolumns 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
requestAnimationFrameloop. - 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)):.logogainsmargin-bottom:2em;.linksbecomesdisplay:none;.overlayswitches toflex-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;.sendkeepsright: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-motionguard; motion is short and user-initiated.