All components

P10 Landing Page Reveal

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

Open live demo ↗ Raw prompt (.md)

What it does

A preloader-driven landing reveal that plays automatically on load: SplitText masked characters slide the studio name and a large '10' into view, the intro character slides and scales into a logo mark while animated corner tags flip in and out, then two dark overlays split apart along a clip-path seam to unveil a full-bleed hero image, a center card and a masked per-character card heading. Driven by a single GSAP timeline using a custom 'hop' CustomEase and staggered clip-path/transform tweens.

How it's built

Categorypreloader
Techgsap
GSAP pluginsCustomEase, SplitText
Complexitypage
Performance costmedium
Mobile-safeyes

preloader reveal clip-path splittext customease landing hero minimal stagger

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

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: .preloader and .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). Its h1 { 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 its scale tween).

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").

  1. Tags in — for each tag i (0,1,2), at time 0.5 + i * 0.1: its p .word{ y: "0%", duration: 0.75 } (words drop down into view from -100%).
  2. 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.
  3. 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).
  4. Outro "10" in — at 2.5: ".preloader .outro-title .char span"{ y: "0%", duration: 0.75, stagger: 0.075 }.
  5. Slide together — at 3.5, two simultaneous 1s tweens:
  6. ".preloader .intro-title .first-char"{ x: isMobile ? "9rem" : "21.25rem", duration: 1 } (the "N" travels right toward center; it overshoots its final x slightly).
  7. ".preloader .outro-title .char"{ x: isMobile ? "-3rem" : "-8rem", duration: 1 } (the "10" chars travel left).
  8. Morph into logo lockup — at 4.5, two simultaneous 0.75s tweens:
  9. ".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.
  10. ".preloader .outro-title .char"{ x: isMobile ? "-3rem" : "-8rem", fontSize: isMobile ? "6rem" : "14rem", fontWeight: "500", duration: 0.75 } — the "10" scales up via animated fontSize (6rem → 14rem on desktop). These values EXACTLY match the split-overlay gsap.set pre-positioning.
  11. The second tween's onComplete performs 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.

  1. 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.
  2. Tags out — for each tag i, at 5.5 + i * 0.1: its p .word{ y: "100%", duration: 0.75 } (exit downward).
  3. The split — at 6, three overlapping tweens:
  4. [".preloader", ".split-overlay"]{ y: (i) => (i === 0 ? "-50%" : "50%"), duration: 1 } — top half slides up offscreen, bottom half slides down.
  5. ".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.
  6. 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.
  7. 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 at translateY(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.
  • isMobile breakpoint at window.innerWidth <= 1000 swaps 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 100svh for 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: transform hints on the animated .char span / .word elements.

Images

This component ships with 1 reference asset, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/p10-landing-page-reveal-gsap/hero-img.jpg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Rules on body — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.
  • Full-screen overlay — a fixed element covers the viewport (a loader or transition). Only one may exist per page and it must remove itself when done. If your page already has one, keep that and drop this; otherwise the second silently hides the first.

Adapting this to React

Everything above describes a standalone document: one script that runs once, reaches into the page with document.querySelector, and never has to undo itself. React withdraws all three of those guarantees at once, and it does it quietly — the component renders, looks right for a moment, and then misbehaves in a way that does not point back at any of this.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Here that leaves you two of nearly everything this timeline touches: two gsap.timeline() instances both racing to drop the same "Nullspace Studio" characters through the same masks, two invisible clip-path swaps trying to flip .preloader and .split-overlay between their halves at once, and — because splitTextElements runs a second time without undoing the first — a second SplitText pass wrapping .char spans that are already wrapped, nesting the mask markup one level deeper than the CSS positioning .char span expects. The visible symptom is a stutter through the tag-flip-in / lockup-morph / split sequence and a card title whose characters never rise into place, and none of it reproduces in a production build, because React only does the double mount in development. Treat the cleanup as part of the effect, not as an afterthought.

*(1) The entry point* — The script waits for DOMContentLoaded with no readiness guard. By the time a React component mounts, that event has already fired, so the listener never runs and none of the roughly seven-and-a-quarter-second sequence — the tag flips, the "N"/"10" drop-and-morph into the logo lockup, the halves parting, the letterbox opening onto the hero image — ever plays: no error, no animation, nothing to debug. Delete the listener and move its body — the plugin registration, the hop custom ease, all four splitTextElements calls, the .split-overlay pre-position gsap.sets, and the timeline itself — directly into a useEffect with an empty dependency array.

*(2) Element lookups* — Give the component a root ref on the outermost element — the one that renders .preloader, .split-overlay, .tags-overlay and .container together — and scope every lookup in this script to it. That covers two different kinds of lookup here. Every .to() and .set() call in the timeline, plus gsap.utils.toArray(".tag"), hands GSAP a selector string (.preloader .intro-title .char span, .split-overlay .outro-title .char, .container .card h1 .char span, and so on) — wrap the whole effect in a gsap.context scoped to that ref and every one of those strings, toArray included, resolves against the ref automatically, with nothing to touch on any of them. What the context does not reach is splitTextElements's own four document.querySelectorAll calls (.intro-title h1, .outro-title h1, .tag p, .card h1) — those are plain DOM lookups the helper makes itself, outside any GSAP API, so point them at rootRef.current.querySelectorAll(...) by hand. Skipping that half matters more here than in most components: .intro-title h1 and .outro-title h1 each match two elements at once — the copy inside .preloader and its duplicate inside .split-overlay — and an unscoped query during the StrictMode remount can end up splitting the outgoing pair instead of the incoming one.

*(3) Cleanup* — Register gsap.registerPlugin(CustomEase, SplitText) and CustomEase.create("hop", …) once at module scope, not inside the effect; neither needs to run per mount. Everything else goes inside a context scoped to the root ref, and splitTextElements needs one change on the way in: as written it builds and discards a SplitText instance per matched element inside its own forEach and never keeps a reference, so there is nothing left to revert later. Collect them:

useEffect(() => {
  const splits = [];
  const ctx = gsap.context((self) => {
    const splitTextElements = (selector, type = "words,chars", addFirstChar = false) => {
      rootRef.current.querySelectorAll(selector).forEach((element) => {
        const splitText = new SplitText(element, { type, wordsClass: "word", charsClass: "char" });
        splits.push(splitText);
        // wrap each char's text in an inner span, tag the first char, exactly as above
      });
    };
    splitTextElements(".intro-title h1", "words, chars", true);
    // the other three splitTextElements calls, then the .split-overlay pre-position
    // gsap.set()s, then the timeline — in that order, since the pre-position sets target
    // the .first-char / .char classes the splits above are what create
  }, rootRef);
  return () => { splits.forEach((s) => s.revert()); ctx.revert(); };
}, []);

Without splits.push(splitText), a remount runs SplitText again against markup the previous mount already rewrote — the doubled-mask failure described above. And because .split-overlay's pre-position gsap.set()s style .first-char and .char directly, reverting the split out of order (or not at all) before the next mount's split runs leaves those classes one level deeper than the pre-position code expects, so the "finished lockup" the duplicate is supposed to show statically ends up positioned on the wrong nodes.

The one animation call that sits outside the context's synchronous pass is the clip-path swap inside the lockup-morph tween's onComplete — it fires when that tween finishes, roughly five seconds into the sequence, long after the factory function above has already returned, so a bare gsap.set(".preloader", …) there is neither scoped to the root nor tracked for revert. Wrap it in the single-argument form of self.add, which runs its callback immediately but *inside* the context:

onComplete: () => {
  self.add(() => {
    gsap.set(".preloader", { clipPath: "polygon(0 0, 100% 0, 100% 50%, 0 50%)" });
    gsap.set(".split-overlay", { clipPath: "polygon(0 50%, 100% 50%, 100% 100%, 0 100%)" });
  });
},

Do not give this one a name — nothing outside the factory ever needs to call it back, so the named self.add("name", fn) form would just leave an unused method hanging off ctx.

One gap the current script doesn't cover but is worth closing in the port: none of the four splitTextElements calls wait for the "DM Sans" face to finish loading, and .intro-title h1, .outro-title h1 and .card h1 all sit at several rem sizes apiece — including the 14rem the "10" grows into during the lockup morph — so the per-.char mask geometry SplitText computes is exactly the kind of measurement a fallback-then-swap font reflow throws off. Gate the four calls behind document.fonts.ready, keep the effect itself synchronous around that gate, and guard the deferred split with the same cancellation flag the cleanup sets — fonts.ready can resolve after a StrictMode unmount has already run ctx.revert().