Sci-Fi Terminal Preloader Button → Hero Clip-Path Reveal
Goal
Build a self-playing terminal-styled preloader that fills a circular SVG "loading" button, then waits for a click to wipe the whole preloader away and reveal a hero headline. On load: monospace status lines rise into view from behind masks (SplitText), a thin circular SVG outline draws itself while the whole ring rotates 270°, and a second progress stroke fills in randomized, jittery stages like a loader stalling and jumping. When it finishes, the centered logo fades out and an "Engage" label rises up — the button is now armed. Clicking the button scales the black preloader down, unwinds both SVG strokes off the ring, swaps the label from "Engage" up-and-out to "Access Granted" in, then wipes the black preloader and a white revealer panel leftward via clip-path while the hero scales up from 0.75 → 1 and its headline words stagger up out of masks. The star effect is the self-drawing + rotating + randomly-stepping SVG loader ring, followed by the synchronized left clip-path wipe → hero word reveal.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins SplitText and CustomEase. No smooth-scroll library and no scroll interaction at all — the intro is a load-triggered timeline and the reveal is a click-triggered timeline. Imports:
import gsap from "gsap";
import { SplitText } from "gsap/SplitText";
import CustomEase from "gsap/CustomEase";
Register once at module top: gsap.registerPlugin(SplitText, CustomEase). Immediately create two custom eases:
CustomEase.create("hop", "0.9, 0, 0.1, 1");
CustomEase.create("glide", "0.8, 0, 0.2, 1");
All logic runs inside document.addEventListener("DOMContentLoaded", ...).
Layout / HTML
Three stacked fixed/relative layers, painted back-to-front by z-index:
.preloader-backdrop(fixed, full-viewport, white bg, gray text,z-index: 0) — a decorative terminal HUD sitting behind everything. Flex column,justify-content: space-between, so its content pins to top and bottom. It holds two.pb-rows (eachdisplay:flex; justify-content:space-between; padding:1.5rem):- Top
.pb-row: five.pb-cols of monospace<p>lines — e.g. a repeated call-sign stacked 5×, a sector/id pair, a material/status pair, then a.pb-colcontaining#pb-logo(a small<img>with a dashed border), then a decorative glyph line. - Bottom
.pb-row: six.pb-cols of short status<p>lines (single-word and two-line labels, a code like "F-9"), aligned toflex-end. .preloader(fixed, full-viewport, black bg, white text,z-index: 2,overflowvisible) — the active layer. Flex column,justify-content: space-between. Its initialclip-pathis the full rectanglepolygon(0% 0%, 100% 0%, 100% 100%, 0% 100%). Contains:.p-rowwith a single<p>"Initiating"..p-rowwith a.p-col(two.p-sub-colgroups of stacked<p>labels,gap: 6rem, alignedflex-end) and a second.p-colwith a<p>code (e.g. "PX-17")..preloader-btn-container— absolutely centered, 20rem × 20rem, the click target. Inside, all absolutely centered on the same point:#pbc-logo— a white<img>logo mark,4rem × 4rem.#pbc-label—<p>"Engage".#pbc-outro-label—<p>"Access Granted"..pbc-svg-strokes— an<svg viewBox="0 0 320 320">containing two concentric<circle>s,cx=160 cy=160 r=155 stroke-width=2:.stroke-track— dark stroke (#2b2b2b)..stroke-progress— white stroke (#fff).
Both start with stroke-dasharray and stroke-dashoffset equal to the circumference (≈974) so nothing is drawn.
.hero(relative, full-viewport, black bg, white text,z-indexbelow preloader) — flex-centered,text-align:center, and startstransform: scale(0.75). Contains:.preloader-revealer— an absolutely positioned white panel covering the hero, with initialclip-path: polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)(full rectangle → hero hidden behind white).<h1>headline — e.g. "The system is now visible", width 90%.
Keep all copy neutral/fictional sci-fi terminal text (call-signs, phase labels, "Engage", "Access Granted"). No real brand or client names.
Styling
Google Fonts: Barlow Condensed (weights 100–900, italics available) and Geist Mono (100–900).
Color tokens:
--base-100: #fff(white)--base-200: #7a7a7a(mid gray)--base-300: #000(black)
Type:
h1: Barlow Condensed, uppercase,font-size: clamp(5rem, 15vw, 15rem), weight 800,letter-spacing: -2%,line-height: 0.8.p: Geist Mono, uppercase,font-size: 0.75rem, weight 500,line-height: 1(button labels#pbc-label/#pbc-outro-labelare0.9rem)..preloader-backdropis white bg +--base-200gray text;.preloaderand.heroare black bg + white text.
Split-piece initial state (load-bearing for the masked reveals):
h1 .word,
p .line {
position: relative;
transform: translateY(100%); /* every split line/word starts one line below */
will-change: transform;
}
Key positioning / will-change (the animation depends on these):
.preloader→will-change: transform, clip-path..preloader-btn-container→position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:20rem; height:20rem..pbc-svg-strokes,#pbc-logo,#pbc-label,#pbc-outro-label→ allposition:absolute; top:50%; left:50%; transform:translate(-50%,-50%)(perfectly stacked)..pbc-svg-strokesand itssvgarewidth:100%; height:100%; will-change:transform.#pbc-logo→4rem × 4rem.#pb-logo→2.5rem × 2.5rem,padding:0.25rem,border:1px dashed var(--base-200)..hero→transform: scale(0.75); will-change:transform..hero .preloader-revealer→position:absolute; inset:0; width:100%; height:100%; background:var(--base-100); will-change:clip-path.
Responsive @media (max-width:1000px): hide the top .pb-row's 1st, 2nd, and 5th .pb-cols (display:none) to declutter the HUD on narrow screens. Nothing else about the animation changes.
GSAP effect (exhaustive)
Setup (once, inside DOMContentLoaded)
let preloaderComplete = false;
const preloaderTexts = document.querySelectorAll(".preloader p");
const preloaderBtn = document.querySelector(".preloader-btn-container");
const btnOutlineTrack = document.querySelector(".stroke-track");
const btnOutlineProgress = document.querySelector(".stroke-progress");
const svgPathLength = btnOutlineTrack.getTotalLength(); // ≈ 973.9 for r=155
gsap.set([btnOutlineTrack, btnOutlineProgress], { strokeDasharray: svgPathLength, strokeDashoffset: svgPathLength })— normalize both circles to the measured circumference and hide them (nothing drawn).- SplitText: for each
.preloader p,new SplitText(p, { type: "lines", linesClass: "line", mask: "lines" })(masked lines). For the hero,new SplitText(".hero h1", { type: "words", wordsClass: "word", mask: "words" })(masked words). Themaskoption wraps each piece in a clipped element so thetranslateY(100%)start state hides it fully.
Intro timeline (auto-plays on load)
const introTl = gsap.timeline({ delay: 1 }); — a 1s initial delay, then:
- Status lines rise.
.to(".preloader .p-row p .line", { y: "0%", duration: 0.75, ease: "power3.out", stagger: 0.1 })— every masked line inside the two.p-rows slides up into view, 0.1s apart. (Only.p-rowlines here — the button labels are split too but animated later.) - Track draws itself.
.to(btnOutlineTrack, { strokeDashoffset: 0, duration: 2, ease: "hop" }, "<")— position"<"= starts together with step 1. The dark ring outline draws from nothing to a full circle over 2s. - Whole ring rotates.
.to(".pbc-svg-strokes svg", { rotation: 270, duration: 2, ease: "hop" }, "<")— also starts with step 1; the SVG spins 0° → 270° while it draws, so the stroke appears to sweep around. - Randomized progress fill. Build stops:
``js const progressStops = [0.2, 0.25, 0.85, 1].map((base, i) => i === 3 ? 1 : base + (Math.random() - 0.5) * 0.1); // last is exactly 1; others jitter ±0.05 ` For each stop (index i), append: `js introTl.to(btnOutlineProgress, { strokeDashoffset: svgPathLength - svgPathLength * stop, // fills to stop fraction duration: 0.75, ease: "glide", delay: i === 0 ? 0.3 : 0.3 + Math.random() * 0.2, // stalls between jumps }); `` These are sequential (default end-of-timeline position). Net feel: the white progress stroke jumps to ~20%, pauses, nudges to ~25%, pauses, leaps to ~85%, then snaps to 100% — a stuttering, "real loader" fill.
- Logo fades out.
.to("#pbc-logo", { opacity: 0, duration: 0.35, ease: "power1.out" }, "-=0.25")— overlaps back 0.25s into the last progress jump. - Button shrinks.
.to(preloaderBtn, { scale: 0.9, duration: 1.5, ease: "hop" }, "-=0.5")— the whole button container eases from scale 1 → 0.9. - "Engage" label rises + arm the button.
.to("#pbc-label .line", { y: "0%", duration: 0.75, ease: "power3.out", onComplete: () => { preloaderComplete = true; } }, "-=0.75")— the masked "Engage" text slides up where the logo was, and itsonCompleteflipspreloaderComplete = trueso the click handler will now fire.
Exit timeline (click .preloader-btn-container)
Listener on preloaderBtn: bail if !preloaderComplete; otherwise set preloaderComplete = false (one-shot) and run const exitTl = gsap.timeline();:
- Preloader scales down.
.to(".preloader", { scale: 0.75, duration: 1.25, ease: "hop" }). - Both strokes unwind off the ring.
.to([btnOutlineTrack, btnOutlineProgress], { strokeDashoffset: -svgPathLength, duration: 1.25, ease: "hop" }, "<")— offset goes to negative circumference, so both circles animate out of view (unraveling), starting together with step 1. - "Engage" exits up.
.to("#pbc-label .line", { y: "-100%", duration: 0.75, ease: "power3.out" }, "-=1.25")— the current label slides up and out of its mask. - "Access Granted" enters.
.to("#pbc-outro-label .line", { y: "0%", duration: 0.75, ease: "power3.out" }, "-=0.75")— the outro label rises up into the same spot right behind it. - Preloader wipes left.
.to(".preloader", { clipPath: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)", duration: 1.5, ease: "hop" })— the two right-hand corners collapse onto the left edge, so the black preloader wipes off to the left. - Revealer wipes left (reveals hero).
.to(".preloader-revealer", { clipPath: "polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%)", duration: 1.5, ease: "hop", onComplete: () => gsap.set(".preloader", { display: "none" }) }, "-=1.45")— starts 1.45s before the previous ends, i.e. nearly in lockstep with the preloader wipe. The white revealer covering the hero collapses to the left too, uncovering the black hero + headline; on complete the now-empty preloader layer is hidden. - Hero scales up.
.to(".hero", { scale: 1, duration: 1.25, ease: "hop" })— hero eases 0.75 → 1 (settles to full size as it's revealed). - Headline words stagger in.
.to(".hero h1 .word", { y: "0%", duration: 1, ease: "glide", stagger: 0.05 }, "-=1.75")— each masked word slides up into place 0.05s apart, overlapping back 1.75s so the words are rising while the hero is still scaling/revealing.
Eases used
hop=CustomEase "0.9, 0, 0.1, 1"— a hard, near-symmetric ease used for the ring draw/rotate, the scale changes, the stroke unwind, and the clip-path wipes.glide=CustomEase "0.8, 0, 0.2, 1"— softer S-curve for the progress fill and the final headline words.power3.outfor the masked text rises,power1.outfor the logo fade.
Assets / images
Two logo images (a third dark variant may exist but is unused by the component), all ~1:1 square PNGs on transparent background, each a small monochrome geometric emblem (e.g. a fragmented/split-hexagon glyph):
- Neutral/gray mark — used as
#pb-logo, the tiny dashed-bordered badge in the white backdrop HUD (2.5rem). - White mark — used as
#pbc-logo, centered inside the circular button (4rem), and fades out mid-intro.
Provide simple, high-contrast marks; exact glyph is not important, aspect 1:1.
Behavior notes
- Two triggers only: the intro is
load-driven (1s delay, ~6s total), then it waits for a user click. ThepreloaderCompleteboolean gates the click — the exit can't run until "Engage" has finished rising, and it's reset tofalseon click so the exit fires exactly once. - No ScrollTrigger, no Lenis, no scroll interaction anywhere.
- The whole thing is percentage/
clip-path/vw-based, so it scales fluidly; the≤1000pxmedia query only hides three decorative HUD columns. - Reduced-motion is not handled in the original — the animation always plays.
Images
This component ships with 3 reference assets, 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/aminezegmou-landing-page/logo-dark.png
https://motionprompts.dev/c/aminezegmou-landing-page/logo-light.png
https://motionprompts.dev/c/aminezegmou-landing-page/logo.png
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.
- Palette on
:root—--paper,--paper-grid,--ink-soft,--ink-strong,--console,--console-text,--phosphor. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - **Rules on
*** — 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 ring draws, "Engage" rises, the click wipes the preloader away, and only later does something misbehave 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. The exit path here is gated by one boolean, preloaderComplete, closed over by the click listener on .preloader-btn-container — and neither that flag nor the listener is a tween, a timeline, or a trigger, so a gsap.context wrapped around the effect cannot see either of them. A double mount that reverts introTl but leaves the listener attached hands the button two closures on the same node: the first mount's, whose introTl already ran to completion and flipped its own preloaderComplete true, and the live mount's. One click then fires two exitTl instances at once, both scaling the same .preloader, unwinding the same btnOutlineTrack/btnOutlineProgress pair, and wiping the same .preloader-revealer — invisible until a user actually clicks, and by then it reads as the wipe stuttering or the hero settling at the wrong scale, not as a doubled mount. None of this reproduces in a production build, because React only does the double mount in development. Treat the click listener's removal as load-bearing cleanup, on the same footing as the ticker rule below — it is not a GSAP object, and ctx.revert() will not touch it.
*(1) The entry point* — The script waits for DOMContentLoaded. By the time a React component mounts, that event has already fired, so the listener is never called and the effect never runs — no lines split, no ring drawn, no click ever arms. Delete the listener and move its entire body — both SplitText passes, introTl, the progressStops construction, and the click listener registration — directly inside a useEffect with an empty dependency array.
*(2) Element lookups* — preloaderTexts, preloaderBtn, btnOutlineTrack and btnOutlineProgress are unscoped lookups, and svgPathLength is a one-time getTotalLength() read off whichever .stroke-track the query happens to find. Give the component a root ref over the element wrapping .preloader-backdrop, .preloader and .hero, and resolve all four from it. The circle's own geometry fixes what getTotalLength() returns, so a stray match during the StrictMode remount would not corrupt the number itself — but if btnOutlineTrack or btnOutlineProgress end up holding the outgoing copy, every tween built against those direct node references — the track draw, the ring rotation, the progress jumps, the unwind on exit — animates a circle the DOM has already discarded, and the ring the user is looking at never moves.
*(3) Cleanup* — Wrap the SplitText passes and introTl in a gsap.context scoped to the root ref. introTl is built synchronously inside the effect body, so the context's own tracking captures it for free. exitTl is not: it is only constructed inside the click listener's callback, which fires later, on the user's click, well outside the context factory's one synchronous pass. Wrapping the listener's *registration* in the context does nothing for the timeline it builds when the listener actually runs. Register the exit logic as a context method instead, and invoke that method from the listener:
useEffect(() => {
const ctx = gsap.context((self) => {
// both SplitText passes, then introTl exactly as constructed above
self.add("runExit", () => {
// exitTl exactly as constructed above
});
}, rootRef);
const btn = rootRef.current.querySelector(".preloader-btn-container");
const onClick = () => {
if (!preloaderComplete.current) return;
preloaderComplete.current = false;
ctx.runExit();
};
btn.addEventListener("click", onClick);
return () => {
btn.removeEventListener("click", onClick);
ctx.revert();
};
}, []);
Build exitTl straight from the closure instead of through ctx.add, and the next ctx.revert() kills only introTl — any exitTl already in flight, and the inline clipPath/scale/strokeDashoffset values it wrote onto .preloader, .preloader-revealer and .hero, survive the revert untouched. Keep preloaderComplete itself in a ref (useRef(false)) rather than state: it is read only inside the click handler and inside introTl's own completion callback, and neither needs to trigger a render. gsap.registerPlugin(SplitText, CustomEase) and the two CustomEase.create calls for the two named eases stay at module scope, outside the component — rebuilding them on every mount is harmless but pointless.
This component runs SplitText once per element the .preloader p selector matches — every status line under both .pb-rows, plus #pbc-label and #pbc-outro-label themselves, since both are <p> tags the same query picks up — and a further, separate pass on .hero h1. Collect every instance the loop produces into an array alongside the hero split, and revert all of them, inside the context, in the cleanup, not just the more visible hero one:
const splits = [
...Array.from(root.querySelectorAll(".preloader p")).map((p) => new SplitText(p /* lines, mask */)),
new SplitText(root.querySelector(".hero h1") /* words, mask */),
];
// return () => splits.forEach((s) => s.revert());
Miss the .preloader p loop and only revert the hero split, and the next mount's pass over #pbc-label/#pbc-outro-label wraps .line spans that are already .line spans — nested one level deeper than introTl's "#pbc-label .line" and exitTl's "#pbc-outro-label .line" selectors expect, so the button's labels stop rising or exiting at all.
Both passes also run before either font — Barlow Condensed for the hero headline, Geist Mono for every status label and both button labels — is guaranteed to have loaded, since nothing in this script waits on document.fonts.ready. The masked-line technique measures each line box against whatever face is current the instant SplitText runs; split against the fallback face and the swap-in reflows the very lines introTl is about to animate up out of their masks. If your font pipeline makes this a real risk, gate the two SplitText passes and the construction of introTl behind document.fonts.ready, keep the effect callback itself synchronous, and check a cancellation flag before touching anything once that promise resolves — a StrictMode unmount that lands before fonts settle would otherwise run new SplitText against status lines and a headline that are already gone.