All components

Maxmilkin Orbit Text Preloader

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

Open live demo ↗ Raw prompt (.md)

What it does

Portfolio preloader where eight words ride concentric SVG circular orbits, stretching and sliding around their rings via animated textPath textLength/startOffset while the whole SVG wobbles in random ±25° rotations and a central counter tweens 0 to 100. After ~6s the orbit words fade out ring by ring, the loader dissolves away and the hero reveals with a 1.25→1 background zoom-out and word-masked SplitText copy sliding up on a custom ease. Plays automatically on page load.

How it's built

Categorypreloader
Techgsap
GSAP pluginsSplitText, CustomEase
Complexitypage
Performance costmedium
Mobile-safeyes

preloader svg textpath orbit counter splittext customease reveal intro hero

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

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 , 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):

  • .loaderposition: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 svgwidth:85%; height:85% (centered inside the loader).
  • .loader svg pathfill:none (the orbit circles are invisible guide paths — only the text on them shows).
  • .loader svg .orbit-textfill:var(--base-300); text-transform:uppercase; font-size:2.75rem; font-weight:500.
  • .loader .counterposition:absolute; top:50%; left:50%; transform:translate(-50%,-50%) (dead-center number over the orbits).
  • .heroposition:relative; width:100%; height:100svh; display:flex; justify-content:center; align-items:center; overflow:hidden.
  • .hero-bgposition:absolute; width:100%; height:100%; transform:scale(1.25); will-change:transform (starts zoomed in 25%).
  • .hero-copy pcolor: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 = current textLength attrs = [300, 280, 240, 260, 290, 200, 210, 190].
  • startTextOffsets = current startOffset attrs (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.25 fast-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-bg scales from its CSS scale(1.25) back to 1 over 2 s on the hop ease (steep, cinematic settle).
  • .hero-copy p .word each translate from translateY(100%) (below, masked) to y: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 .loader is removed from the DOM at the end.
  • Uses 100svh so mobile browser chrome doesn't clip the panels.
  • Keep the will-change hints (opacity on .loader, transform on .hero-bg and .word) — they matter for smooth compositing.
  • Responsive (≤1000px): the SVG fills the whole loader (width/height:100%) and orbit text bumps to 3rem; the animation logic is identical.
  • No reduced-motion handling in the original; add one if desired, but it is not part of the reference behavior.