Landing Page Reveal — Typographic Preloader with Split-Screen Unveil
Goal
Build a full-screen landing intro that plays automatically once on page load (~7.5s). On a dark preloader, the studio name "Nullspace Studio" drops in character by character through masks while three small grey corner tags flip in; every character except the leading "N" then exits downward, a large "10" drops in beside it, the "N" and the "10" slide toward each other and morph into a logo lockup (the "N" shrinks and goes extra-bold, the "10" blows up to 14rem). Then the dark screen splits in half along a horizontal seam: the top half slides up, the bottom half slides down, and the page content (full-bleed hero image, nav, footer, and a white center card whose title rises per character) is revealed through an expanding clip-path letterbox. One GSAP timeline, one custom hop ease.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins CustomEase and SplitText. No smooth-scroll library. Register with gsap.registerPlugin(CustomEase, SplitText) and run everything inside DOMContentLoaded.
Layout / HTML
Class names are load-bearing — the JS/CSS query them:
<div class="preloader">
<div class="intro-title"><h1>Nullspace Studio</h1></div>
<div class="outro-title"><h1>10</h1></div>
</div>
<div class="split-overlay">
<div class="intro-title"><h1>Nullspace Studio</h1></div>
<div class="outro-title"><h1>10</h1></div>
</div>
<div class="tags-overlay">
<div class="tag tag-1"><p>Negative Space</p></div>
<div class="tag tag-2"><p>Form & Void</p></div>
<div class="tag tag-3"><p>Light Studies</p></div>
</div>
<div class="container">
<nav>
<p id="logo">N10</p>
<p>Menu</p>
</nav>
<div class="hero-img"><img src="..." alt="" /></div>
<div class="card"><h1>Nullspace</h1></div>
<footer>
<p>Scroll Down</p>
<p>Made by Nullspace Studio</p>
</footer>
</div>
Key idea: .split-overlay is an exact duplicate of the preloader's content. It sits underneath the preloader and is pre-positioned (via gsap.set) to the preloader's FINAL logo-lockup state. At the end of the lockup animation the preloader is clipped to the top half of the screen and the split-overlay to the bottom half — a pixel-perfect invisible swap — so the two halves can then slide apart in opposite directions.
Styling
Font (Google Fonts): DM Sans — the only family.
* { margin:0; padding:0; box-sizing:border-box };body { font-family:"DM Sans", sans-serif }.img { width:100%; height:100%; object-fit:cover }.h1 { text-transform:uppercase; font-size:6rem; font-weight:600; line-height:1 }.p { text-transform:uppercase; font-size:13px; font-weight:500 }.
Layers:
.preloader, .split-overlay, .tags-overlay { position:fixed; width:100vw; height:100svh }..preloader, .split-overlay { background-color:#0a0a0a; color:#fff }.- z-index:
.preloaderand.tags-overlay=2;.split-overlay=1. .intro-title { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:100%; text-align:center }(applies inside both dark layers)..outro-title { position:absolute; top:50%; left:calc(50% + 10rem); transform:translate(-50%,-50%) }.
Tags:
.tag { position:absolute; width:max-content; color:#5a5a5a; overflow:hidden }..tag-1 { top:15%; left:15% },.tag-2 { bottom:15%; left:25% },.tag-3 { bottom:30%; right:15% }.
Page content:
.container { position:relative; width:100%; height:100%; min-height:100svh; display:flex; flex-direction:column; justify-content:space-between; z-index:2; clip-path: polygon(0 48%, 0 48%, 0 52%, 0 52%) }— initial clip-path is a zero-width sliver at the left edge (all x = 0, y between 48% and 52%), so the content is invisible until the timeline sweeps the slit open..container .hero-img { position:absolute; width:100%; height:100% }(full-bleed image behind everything in the container).nav, footer { position:relative; width:100vw; padding:2em; display:flex; justify-content:space-between; align-items:center; color:#fff; z-index:2 }.nav p#logo { font-weight:600; font-size:20px }..card { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:30%; height:70%; display:flex; justify-content:center; align-items:center; background-color:#fff; clip-path: polygon(0% 50%, 100% 50%, 100% 50%, 0% 50%) }— initial clip-path is a degenerate horizontal line at mid-height (card hidden). Itsh1 { text-align:center; width:100%; font-size:3rem }.
SplitText piece styles (these create the masks):
.intro-title .char, .outro-title .char, .card .char { position:relative; display:inline-block; overflow:hidden }— each char is its own overflow-hidden mask..intro-title .char, .outro-title .char { margin-top: 0.75rem }..intro-title .char span, .outro-title .char span, .tag .word { position:relative; display:inline-block; transform:translateY(-100%); will-change:transform }— title chars and tag words start hidden ABOVE their mask..card .char span { position:relative; display:inline-block; transform:translateY(100%); will-change:transform }— card chars start hidden BELOW their mask..intro-title .first-char { transform-origin: top left }(matters for itsscaletween).
GSAP effect (be exact)
Setup
CustomEase.create("hop", ".8, 0, .3, 1");
SplitText helper — split, then manually wrap each char's text in an inner <span> (the CSS above animates .char span, not .char):
const splitTextElements = (selector, type = "words,chars", addFirstChar = false) => {
document.querySelectorAll(selector).forEach((element) => {
const splitText = new SplitText(element, {
type,
wordsClass: "word",
charsClass: "char",
});
if (type.includes("chars")) {
splitText.chars.forEach((char, index) => {
const originalText = char.textContent;
char.innerHTML = `<span>${originalText}</span>`;
if (addFirstChar && index === 0) char.classList.add("first-char");
});
}
});
};
splitTextElements(".intro-title h1", "words, chars", true); // hits BOTH preloader & split-overlay copies
splitTextElements(".outro-title h1"); // words + chars ("1","0"), both copies
splitTextElements(".tag p", "words"); // words only, no inner spans
splitTextElements(".card h1", "words, chars", true);
const isMobile = window.innerWidth <= 1000;
Pre-position the split-overlay duplicate to the FINAL state
gsap.set(
[".split-overlay .intro-title .first-char span",
".split-overlay .outro-title .char span"],
{ y: "0%" } // its "N" and "10" are already visible
);
gsap.set(".split-overlay .intro-title .first-char", {
x: isMobile ? "7.5rem" : "18rem",
y: isMobile ? "-1rem" : "-2.75rem",
fontWeight: "900",
scale: 0.75,
});
gsap.set(".split-overlay .outro-title .char", {
x: isMobile ? "-3rem" : "-8rem",
fontSize: isMobile ? "6rem" : "14rem",
fontWeight: "500",
});
(The non-first intro chars of the split-overlay keep their CSS translateY(-100%), i.e. hidden — the duplicate shows only the finished "N + 10" lockup.)
The timeline
One timeline, gsap.timeline({ defaults: { ease: "hop" } }). All position params below are absolute times in seconds. tags = gsap.utils.toArray(".tag").
- Tags in — for each tag
i(0,1,2), at time0.5 + i * 0.1: itsp .word→{ y: "0%", duration: 0.75 }(words drop down into view from-100%). - Intro title in — at
0.5:".preloader .intro-title .char span"→{ y: "0%", duration: 0.75, stagger: 0.05 }. "Nullspace Studio" drops in char by char from above through the per-char masks. - Intro title out (except first char) — at
2:".preloader .intro-title .char:not(.first-char) span"→{ y: "100%", duration: 0.75, stagger: 0.05 }. Every char except the leading "N" continues DOWN and out of its mask (in through the top, out through the bottom). - Outro "10" in — at
2.5:".preloader .outro-title .char span"→{ y: "0%", duration: 0.75, stagger: 0.075 }. - Slide together — at
3.5, two simultaneous 1s tweens: ".preloader .intro-title .first-char"→{ x: isMobile ? "9rem" : "21.25rem", duration: 1 }(the "N" travels right toward center; it overshoots its final x slightly).".preloader .outro-title .char"→{ x: isMobile ? "-3rem" : "-8rem", duration: 1 }(the "10" chars travel left).- Morph into logo lockup — at
4.5, two simultaneous 0.75s tweens: ".preloader .intro-title .first-char"→{ x: isMobile ? "7.5rem" : "18rem", y: isMobile ? "-1rem" : "-2.75rem", fontWeight: "900", scale: 0.75, duration: 0.75 }— the "N" pulls back/up, shrinks to 75% (transform-origin top left) and becomes extra-bold.".preloader .outro-title .char"→{ x: isMobile ? "-3rem" : "-8rem", fontSize: isMobile ? "6rem" : "14rem", fontWeight: "500", duration: 0.75 }— the "10" scales up via animatedfontSize(6rem → 14rem on desktop). These values EXACTLY match the split-overlaygsap.setpre-positioning.- The second tween's
onCompleteperforms the invisible swap:
``js gsap.set(".preloader", { clipPath: "polygon(0 0, 100% 0, 100% 50%, 0 50%)" }); // top half gsap.set(".split-overlay", { clipPath: "polygon(0 50%, 100% 50%, 100% 100%, 0 100%)" }); // bottom half `` Since both layers now render identical pixels, nothing appears to change.
- Letterbox slit opens — at
5:".container"→{ clipPath: "polygon(0% 48%, 100% 48%, 100% 52%, 0% 52%)", duration: 1 }. The content's clip sweeps from the zero-width left-edge sliver to a thin full-width band between 48% and 52% viewport height — a glowing slit of the hero image appears across the seam. - Tags out — for each tag
i, at5.5 + i * 0.1: itsp .word→{ y: "100%", duration: 0.75 }(exit downward). - The split — at
6, three overlapping tweens: [".preloader", ".split-overlay"]→{ y: (i) => (i === 0 ? "-50%" : "50%"), duration: 1 }— top half slides up offscreen, bottom half slides down.".container"→{ clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", duration: 1 }— the letterbox expands to full screen in sync with the halves parting.- at
6.25:".container .card"→{ clipPath: "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)", duration: 0.75 }— the white card unmasks vertically from its center line. - Card title in — at
6.5:".container .card h1 .char span"→{ y: "0%", duration: 0.75, stagger: 0.05 }— "Nullspace" rises from BELOW (its spans start attranslateY(100%), unlike the title chars which started at-100%).
Ease reference
hop=CustomEase.create("hop", ".8, 0, .3, 1")— used by every tween (timeline default). No other eases.
Assets / images
One image: a full-bleed hero background revealed behind the split — a wide, desaturated architectural interior with strong negative space and directional light (calm, minimal, editorial mood). Landscape orientation (roughly 16:9), displayed with object-fit: cover filling the viewport. White nav/footer text sits on top of it, so darker/muted imagery reads best.
Behavior notes
- Autoplay once on load; no scroll/hover/click triggers, no ScrollTrigger. Total runtime ≈ 7.25s.
isMobilebreakpoint atwindow.innerWidth <= 1000swaps the rem offsets/font sizes as listed above.- Responsive CSS (
max-width: 1000px):h1 { font-size:2.5rem };.outro-title { left: calc(50% + 4rem) };.card { width:75% };.card h1 { font-size:2.5rem };.intro-title .char, .outro-title .char { margin-top:0.5rem }. - Use
100svhfor all full-height layers so mobile browser chrome doesn't clip. - The preloader/split-overlay never get removed from the DOM — after sliding ±50% they sit offscreen behind the revealed page (the container is z-index 2, above the split-overlay's z-index 1).
- Keep the
will-change: transformhints on the animated.char span/.wordelements.