Orbit-Text SVG Preloader → Hero Reveal
Goal
Build a full-screen portfolio preloader whose star effect is eight words riding eight concentric circular SVG orbits. Each word is an SVG <textPath> that, as it animates, stretches (its textLength grows enormously) and slides around its ring (its startOffset shifts), so the words appear to elongate and smear along nested circles while the whole SVG wobbles back and forth in random ±25° rotations and a central counter tweens 0 → 100. After ~6 s the orbit words fade out ring by ring, the loader panel dissolves, and the hero underneath is revealed with a background 1.25 → 1 zoom-out and a word-masked SplitText line sliding up on a custom ease. Everything plays automatically once on page load; there is no scroll.
Tech
Vanilla HTML/CSS/JS with ES module imports. Use gsap (npm) plus the GSAP plugins SplitText and CustomEase. No smooth-scroll library — the whole thing is a pure load-triggered set of tweens, the page does not scroll. Register: gsap.registerPlugin(SplitText, CustomEase). The animations are created immediately on module execution (no DOMContentLoaded/fonts.ready wrapper is required, though wrapping in fonts.ready is fine).
Layout / HTML
Two stacked layers. The .loader is a fixed full-viewport overlay (z-index: 2) that sits on top of a .hero section (natural flow underneath). Class/tag names are load-bearing — the JS queries them.
<div class="loader">
<svg viewBox="-425 -425 1850 1850" xmlns="http://www.w3.org/2000/svg">
<!-- 8 concentric orbit paths (see geometry below) -->
<path id="loader-orbit-1" d="..." />
<path id="loader-orbit-2" d="..." />
...
<path id="loader-orbit-8" d="..." />
<!-- 8 words, each riding one orbit path -->
<text class="orbit-text"><textPath href="#loader-orbit-1" startOffset="30%" textLength="300">Developer</textPath></text>
<text class="orbit-text"><textPath href="#loader-orbit-2" startOffset="31%" textLength="280">Frontend</textPath></text>
<text class="orbit-text"><textPath href="#loader-orbit-3" startOffset="33%" textLength="240">Creative</textPath></text>
<text class="orbit-text"><textPath href="#loader-orbit-4" startOffset="32%" textLength="260">Designer</textPath></text>
<text class="orbit-text"><textPath href="#loader-orbit-5" startOffset="30%" textLength="290">Portfolio</textPath></text>
<text class="orbit-text"><textPath href="#loader-orbit-6" startOffset="31%" textLength="200">Digital</textPath></text>
<text class="orbit-text"><textPath href="#loader-orbit-7" startOffset="33%" textLength="210">Modern</textPath></text>
<text class="orbit-text"><textPath href="#loader-orbit-8" startOffset="32%" textLength="190">Design</textPath></text>
</svg>
<div class="counter"><p>0</p></div>
</div>
<section class="hero">
<div class="hero-bg"><img src="<hero image>" alt="" /></div>
<div class="hero-copy"><p>Your content begins here</p></div>
</section>
Orbit geometry (critical)
All eight circles are concentric at (500, 500) in SVG user units. The viewBox -425 -425 1850 1850 is deliberately oversized so its own center is exactly (500, 500) and the largest ring has margin. Radii, outermost → innermost:
| id | radius r | top point (500, 500−r) | bottom point (500, 500+r) | |----|----------|------------------------|---------------------------| | loader-orbit-1 | 775 | 500,−275 | 500,1275 | | loader-orbit-2 | 700 | 500,−200 | 500,1200 | | loader-orbit-3 | 625 | 500,−125 | 500,1125 | | loader-orbit-4 | 550 | 500,−50 | 500,1050 | | loader-orbit-5 | 475 | 500,25 | 500,975 | | loader-orbit-6 | 400 | 500,100 | 500,900 | | loader-orbit-7 | 325 | 500,175 | 500,825 | | loader-orbit-8 | 250 | 500,250 | 500,750 |
Each path traces its full circle three times so a huge textLength has room to wrap around the ring multiple times. The d for radius r is two semicircle arcs (top→bottom→top) repeated 3×, ending just shy of the top point to avoid a degenerate close:
M 500,{500-r} A r,r 0 0,1 500,{500+r} A r,r 0 0,1 500,{500-r} (×3, last endpoint 499.99,{500-r})
e.g. orbit-1 (r=775): M 500,-275 A 775,775 0 0,1 500,1275 A 775,775 0 0,1 500,-275 A 775,775 0 0,1 500,1275 A 775,775 0 0,1 500,-275 A 775,775 0 0,1 500,1275 A 775,775 0 0,1 499.99,-275
So each path's total length is 2 × π × r × 3 (three circumferences) — this factor of 3 matters for the offset math in the GSAP section.
Styling
Google Font: Inter (variable, ital + opsz 14..32 + wght 100..900). body { font-family: "Inter", sans-serif }.
Palette (CSS custom properties):
--base-100: #fff— white (hero copy text)--base-200: #b8d9b9— soft sage green (the loader panel background)--base-300: #0f0f0f— near-black (loader text + orbit-text fill)
Global reset: * { margin:0; padding:0; box-sizing:border-box }. img { width:100%; height:100%; object-fit:cover }. p { text-transform:uppercase; font-weight:500 }.
Key elements and their initial states (the animation depends on these):
.loader—position:fixed; top:0; left:0; width:100%; height:100svh; display:flex; justify-content:center; align-items:center; background:var(--base-200); color:var(--base-300); will-change:opacity; z-index:2..loader svg—width:85%; height:85%(centered inside the loader)..loader svg path—fill:none(the orbit circles are invisible guide paths — only the text on them shows)..loader svg .orbit-text—fill:var(--base-300); text-transform:uppercase; font-size:2.75rem; font-weight:500..loader .counter—position:absolute; top:50%; left:50%; transform:translate(-50%,-50%)(dead-center number over the orbits)..hero—position:relative; width:100%; height:100svh; display:flex; justify-content:center; align-items:center; overflow:hidden..hero-bg—position:absolute; width:100%; height:100%; transform:scale(1.25); will-change:transform(starts zoomed in 25%)..hero-copy p—color:var(--base-100)..hero-copy p .word— (this class is produced by SplitText)position:relative; will-change:transform; transform:translateY(100%)(each word parked one line below, hidden behind its word-mask).
Responsive @media (max-width:1000px): .loader svg { width:100%; height:100% } and .loader svg .orbit-text { font-size:3rem }. The animation itself is unchanged.
GSAP effect (be exact)
Setup
gsap.registerPlugin(SplitText, CustomEase);
CustomEase.create("hop", "0.9, 0, 0.1, 1"); // steep symmetric in-out, used for the hero reveal only
const split = SplitText.create(".hero-copy p", {
type: "words",
mask: "words",
wordsClass: "word", // → each word wrapped in an overflow-hidden mask, tagged .word (pre-translated 100% down by CSS)
});
These are independent tweens, all created at load — not one master timeline. The hero reveal is chained off the orbit-text fade-out's onComplete. There are four concurrent behaviors (A–D) plus the reveal (E).
A — Orbit words stretch + slide (perpetual yoyo)
Read the eight <textPath> nodes (document.querySelectorAll(".loader svg textPath")) and capture their start attribute values:
startTextLengths= currenttextLengthattrs =[300, 280, 240, 260, 290, 200, 210, 190].startTextOffsets= currentstartOffsetattrs (as numbers, percent) =[30, 31, 33, 32, 30, 31, 33, 32].
Constants:
const targetTextLengths = [4000, 3500, 3250, 3000, 2500, 2000, 1500, 1250];
const orbitRadii = [775, 700, 625, 550, 475, 400, 325, 250];
const maxOrbitRadius = 775; // orbitRadii[0]
const maxAnimDuration = 1.25;
const minAnimDuration = 1;
For each textPath at index (0…7):
const animationDelay = (textPaths.length - 1 - index) * 0.1; // index 0 → 0.7s, index 7 → 0.0s (inner rings start first)
const r = orbitRadii[index];
const currentDuration = minAnimDuration + (r / maxOrbitRadius) * (maxAnimDuration - minAnimDuration); // 1.0 … 1.25, larger ring = slower
const pathLength = 2 * Math.PI * r * 3; // 3 circumferences (the path is traced 3×)
const textLengthIncrease = targetTextLengths[index] - startTextLengths[index];
const offsetAdjustment = (textLengthIncrease / 2 / pathLength) * 100; // percent to shift so growth stays roughly centered
const targetOffset = startTextOffsets[index] - offsetAdjustment;
gsap.to(textPath, {
attr: {
textLength: targetTextLengths[index], // e.g. 300 → 4000: the word stretches ~13× along its ring
startOffset: targetOffset + "%", // shift back by offsetAdjustment so the stretch spreads both ways
},
duration: currentDuration,
delay: animationDelay,
ease: "power2.inOut",
yoyo: true,
repeat: -1, // forever: stretch out, then contract back, endlessly
repeatDelay: 0,
});
Net effect: each word repeatedly balloons out to a huge textLength (wrapping several times around its circle) and snaps back, with the inner rings leading and outer rings lagging by 0.1 s each, giving a rippling, breathing nest of text.
B — Whole SVG random wobble (perpetual)
A self-chaining recursive rotation. Start loaderRotation = 0, then:
function animateRotation() {
const spinDirection = Math.random() < 0.5 ? 1 : -1;
loaderRotation += 25 * spinDirection; // ±25° accumulated
gsap.to(".loader svg", {
rotation: loaderRotation,
duration: 2,
ease: "power2.inOut",
onComplete: animateRotation, // loop forever
});
}
animateRotation();
The entire SVG swings a random 25° left or right every 2 s (accumulating on loaderRotation), so the whole orbit stack gently rocks unpredictably.
C — Center counter 0 → 100
const counterText = document.querySelector(".counter p");
const count = { value: 0 };
gsap.to(count, {
value: 100,
duration: 4,
delay: 1,
ease: "power1.out",
onUpdate: () => { counterText.textContent = Math.floor(count.value); },
onComplete: () => {
gsap.to(".counter", { opacity: 0, duration: 0.5, delay: 1 }); // fade the number away 1s after it hits 100
},
});
The number ticks up from 0 to 100 over 4 s (starting 1 s in, power1.out), then 1 s later fades out over 0.5 s.
D — Orbit words fade in (ring by ring)
const orbitTextElements = document.querySelectorAll(".orbit-text");
gsap.set(orbitTextElements, { opacity: 0 }); // all words start invisible
const orbitTextsReversed = Array.from(orbitTextElements).reverse(); // innermost first
gsap.to(orbitTextsReversed, {
opacity: 1,
duration: 0.75,
stagger: 0.125, // inner ring appears first, outer rings cascade in
ease: "power1.out",
});
E — Orbit words fade out → hero reveal (the finale)
gsap.to(orbitTextsReversed, {
opacity: 0,
duration: 0.75,
stagger: 0.1,
delay: 6, // starts at t≈6s (after the counter and a good look at the orbits)
ease: "power1.out",
onComplete: () => {
// 1) dissolve the loader panel, then rip it out of the DOM
gsap.to(".loader", {
opacity: 0,
duration: 1,
onComplete: () => document.querySelector(".loader").remove(),
});
// 2) hero background de-zoom 1.25 → 1
gsap.to(".hero-bg", { scale: 1, duration: 2, delay: -0.5, ease: "hop" });
// 3) hero copy words rise up out of their masks
gsap.to(".hero-copy p .word", { y: 0, duration: 2, delay: -0.25, stagger: 0.1, ease: "hop" });
},
});
Notes:
- The orbit words fade out one ring at a time (0.1 s stagger), and only when the last word's fade completes does the reveal fire.
- Negative delays are intentional:
delay: -0.5/-0.25fast-forward those tweens by half/quarter of a second so the hero background and copy are already mid-motion the instant the loader begins dissolving — the de-zoom and word-rise overlap the loader fade rather than waiting for it. .hero-bgscales from its CSSscale(1.25)back to1over 2 s on thehopease (steep, cinematic settle)..hero-copy p .wordeach translate fromtranslateY(100%)(below, masked) toy:0, staggered 0.1 s, 2 s,hop— the line slides up word by word.
Approximate timeline (seconds from load)
| t | event | |---|-------| | 0 | orbit stretch (A) begins per-ring (delays 0–0.7s), SVG wobble (B) starts, orbit words fade in ring by ring (D) | | 1–5 | counter (C) ticks 0→100 | | ~6 | counter fades out; orbit words begin fading out ring by ring (E, delay 6, stagger 0.1) | | ~7.5 | last orbit word gone → onComplete: loader dissolves (1s) + hero de-zoom (2s) + copy words rise (2s), overlapped via negative delays | | ~9 | loader .remove()d, hero settled |
A/B (orbit stretch + wobble) run as infinite loops but are destroyed with the loader when it is removed.
Assets / images
One hero background image, full-bleed (object-fit: cover, starts scale(1.25)), landscape (~3:2). It is a close-up of glossy white-and-grey marble: smooth, sculptural wave-like folds in polished stone with wispy dark-grey/charcoal veining threading through a bright white ground. Dominant colors are white and light grey with soft grey shadows and thin charcoal veins — a calm, high-key, monochrome texture. Aspect ratio is flexible since it is cover-cropped; any soft, tactile editorial stone/liquid texture works in the same role. It is the single subject of the 1.25 → 1 de-zoom reveal.
Behavior notes
- Autoplay once on load; no scroll, hover, or click. Total intro ~8–9 s.
- The orbit stretch (A) and SVG wobble (B) are
repeat: -1/ self-chaining loops that only stop when.loaderis removed from the DOM at the end. - Uses
100svhso mobile browser chrome doesn't clip the panels. - Keep the
will-changehints (opacityon.loader,transformon.hero-bgand.word) — they matter for smooth compositing. - Responsive (
≤1000px): the SVG fills the whole loader (width/height:100%) and orbit text bumps to3rem; the animation logic is identical. - No
reduced-motionhandling in the original; add one if desired, but it is not part of the reference behavior.