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.