All components

Landing Page Reveal With Circular Gallery

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

Open live demo ↗ Raw prompt (.md)

What it does

On load a numeric preloader counts up to 100, then 30 photo cards rise from below into a centered horizontal strip and, using GSAP Flip, morph into a rotating circular gallery while the nav labels slide up into view. Fully load-triggered with a CustomEase 'hop' curve.

How it's built

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

gsap flip custom-ease preloader counter circular-gallery reveal editorial loader

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 with Circular Gallery — Counter Preloader → Flip Ring

Goal

Build a full-viewport editorial landing intro that plays once on page load (≈7 seconds, no scroll). A tiny numeric preloader near the bottom-center counts up 0 → 100; meanwhile 30 small photo cards rise from below the fold into a tightly-overlapped, horizontally-centered stack. When the counter finishes it slides away, and the whole stack morphs — via GSAP Flip — into a rotating circular gallery (a ring of 30 cards, each rotated tangent to the circle), while the fixed nav labels slide up into view. The star effect is the Flip-driven linear-stack → circle transform where every card simultaneously travels to its slot on the ring and spins to its tangent angle, all on a custom hop ease.

Tech

Vanilla HTML/CSS/JS with ES-module imports. Use gsap (npm) plus the GSAP plugins Flip and CustomEase — nothing else (no ScrollTrigger, no Lenis, no SplitText, no Three.js/WebGL/canvas). Register with gsap.registerPlugin(Flip, CustomEase). Fire the whole sequence inside a DOMContentLoaded listener. Ship one index.html (<link rel="stylesheet" href="./styles.css"> and <script type="module" src="./script.js">), one styles.css, one ES-module script.js. Must run in a fresh Vite + npm project.

Layout / HTML

Class names are load-bearing (the JS/CSS query them). The .gallery ships empty — JS injects all 30 cards.

<div class="container">
  <nav>
    <div class="col">                                  <!-- col 1 (flex 4) -->
      <div class="nav-items"><div class="nav-item"><p>Meridian</p></div></div>
      <div class="nav-items"><div class="nav-item"><p>Youtube Channel</p></div></div>
    </div>
    <div class="col">                                  <!-- col 2 (flex 2) -->
      <div class="nav-items">
        <div class="nav-item"><p>Work</p></div>
        <div class="nav-item"><p>Studio</p></div>
        <div class="nav-item"><p>Contact</p></div>
      </div>
      <div class="nav-items">
        <div class="nav-item"><p>Twitter</p></div>
        <div class="nav-item"><p>Instagram</p></div>
      </div>
    </div>
  </nav>

  <div class="loader"><p>0</p></div>

  <div class="gallery"></div>
</div>
<script type="module" src="./script.js"></script>

Notes:

  • 7 nav labels total, split across two .col groups. Col 1 holds two side-by-side single-line labels; col 2 holds two stacked lists (Work/Studio/Contact and Twitter/Instagram). Use neutral fictional labels — the logo can be any placeholder studio name (e.g. "Meridian"); no real brand names.
  • Each label text sits inside .nav-item > p — the <p> is the element that animates; the .nav-item is its clipping mask.
  • .loader > p starts as the text 0; JS overwrites it with the counting value.
  • .gallery is the (unpositioned) host for the 30 injected .item cards.

Styling

Font: the original uses a Söhne-style grotesque. Use a neutral grotesque sans fallback (system sans / Inter / Helvetica Neue). All text is tiny — font-size: 13px for every p/a.

Palette: page background #dad8d4 (warm light grey), text #000, card placeholder background #b0b0b0 (grey shown before images load).

Reset & globals:

* { margin:0; padding:0; box-sizing:border-box; }
html, body { width:100%; height:100vh; font-family:"Söhne", sans-serif; background:#dad8d4; color:#000; }
p, a { text-decoration:none; font-size:13px; }
img { width:100%; height:100%; object-fit:cover; }

Container (the stage):

.container { position:relative; width:100%; height:100%; overflow:hidden; }

overflow:hidden is essential — cards start below the fold (top:150%) and the ring extends past center; nothing should spill/scroll.

Nav (fixed top):

nav { position:fixed; top:0; width:100%; padding:1.5em; display:flex; gap:4em; }
.col { display:flex; gap:1em; }
.col:nth-child(1){ flex:4; }
.col:nth-child(2){ flex:2; display:flex; }
.nav-items{ flex:1; }               /* block children stack vertically */

Nav label mask (each label is a 20px-tall clip window):

.nav-item{
  width:max-content; height:20px; margin-bottom:0.125em;
  clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);   /* full-rect mask */
}

Cards (all absolutely positioned in the container):

.item{ position:absolute; width:175px; height:250px; background:#b0b0b0; }

Fixed 175×250 px (≈5:7 portrait). They are positioned entirely by JS via left/top/rotation/transform — never by flow.

Loader (bottom-center numeric preloader, also a 20px clip window):

.loader{
  position:absolute; bottom:15%; left:50%; transform:translate(-50%,-50%);
  width:40px; height:20px; text-align:center;
  clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);   /* full-rect mask */
}

The two masked text elements start pushed BELOW their window:

.loader p, .nav-item p{ position:relative; display:block; transform:translateY(20px); }

Because each mask is exactly 20px tall and the <p> starts translated down 20px, the text is initially hidden just below the clip window; animating y → 0 slides it up into view, and animating y → -20 slides it up out of view.

GSAP effect (be exhaustive)

Setup
import gsap from "gsap";
import { Flip } from "gsap/Flip";
import { CustomEase } from "gsap/CustomEase";

gsap.registerPlugin(Flip, CustomEase);
CustomEase.create("hop", "M0,0 C0.053,0.604 0.157,0.72 0.293,0.837 0.435,0.959 0.633,1 1,1");

const itemsCount = 30;
const container = document.querySelector(".container");
const gallery   = document.querySelector(".gallery");

hop is a fast-rise, soft-settle ease (steep at the start, decelerating to 1 with no overshoot) — used for the card rise and the Flip morph.

Step 0 — Build the cards

Loop i = 1..30: create div.item, create an img with img.src = the i-th portrait photo (e.g. /images/img${i}.jpg) and alt="Image ${i}", append img to the item, append the item to .gallery. (30 .item cards, each holding one full-bleed img.)

Step 1 — Initial linear stack + rise-in (fires immediately on load)
const items = document.querySelectorAll(".item");
const totalItemsWidth = (items.length - 1) * 10 + items[0].offsetWidth;  // 29*10 + 175 = 465
const startX = (container.offsetWidth - totalItemsWidth) / 2;            // centers the strip

items.forEach((item, index) => {
  gsap.set(item, { left: `${startX + index * 10}px`, top: "150%", rotation: 0 });
});

gsap.to(items, {
  top: "50%",
  transform: "translateY(-50%)",
  duration: 1,
  ease: "hop",
  stagger: 0.03,
});
  • Cards are 175px wide but stepped only 10px apart on left → a heavily-overlapping, near-collapsed horizontal deck, centered by startX.
  • They start at top:150% (fully below the fold) and animate up to top:50% with transform:translateY(-50%) (vertically centered).
  • duration:1, ease:"hop", stagger:0.03 left-to-right (index order) → the deck rises like a fan, ~0.9s of stagger tail.
Step 2 — Counter reveal (starts at delay 1s)
gsap.to(".loader p", {
  y: 0, duration: 1, ease: "power3.out", delay: 1,
  onComplete: animateCounter,
});

The loader 0 slides up from translateY(20px) into its 20px window over 1s (power3.out), starting 1s after load. onCompleteanimateCounter.

Step 3 — The count-up (manual, NOT a tween)

animateCounter() ticks the number with setTimeout, not GSAP:

function animateCounter(){
  const el = document.querySelector(".loader p");
  let currentValue = 0;
  const updateInterval = 300;   // ms per tick
  const maxDuration    = 2000;  // ms total counting window
  const endValue       = 100;
  const startTime = Date.now();

  const update = () => {
    const elapsed = Date.now() - startTime;
    if (elapsed < maxDuration){
      currentValue = Math.min(currentValue + Math.floor(Math.random() * 30) + 5, 100);
      el.textContent = currentValue;
      setTimeout(update, 300);
    } else {
      el.textContent = 100;
      setTimeout(() => {
        gsap.to(el, {
          y: -20, duration: 1, ease: "power3.inOut",
          onComplete: () => {
            animateToCircularLayout();
            setTimeout(() => {
              gsap.to(".nav-item p", { y: 0, duration: 1, ease: "power3.inOut", stagger: 0.075 });
            }, 100);
          },
        });
      }, -300);   // negative delay → fires on next tick (≈0ms)
    }
  };
  update();
}
  • Every 300ms the value jumps by a random 5–34 (floor(random*30)+5), clamped to 100 — so it counts up erratically, not smoothly, over the 2s window.
  • After 2s it's pinned to 100, then the number slides up and out (y:-20, 1s, power3.inOut) above its mask.
  • On that tween's complete: fire the Flip morph (animateToCircularLayout), and 100ms later reveal the nav labels — .nav-item p slide y → 0, duration:1, ease:"power3.inOut", stagger:0.075 across all 7 labels (DOM order).
Step 4 — THE STAR EFFECT: Flip linear-stack → circular ring

Compute the target ring layout, capture the pre-state, apply it, then let Flip animate the delta:

const setCircularLayout = () => {
  const items = document.querySelectorAll(".item");
  const n = items.length;                       // 30
  const angleIncrement = (2 * Math.PI) / n;     // 12° per card
  const radius  = 210;
  const centerX = container.offsetWidth  / 2;
  const centerY = container.offsetHeight / 2;

  items.forEach((item, index) => {
    const angle = index * angleIncrement;
    const x = centerX + radius * Math.cos(angle) - item.offsetWidth  / 2;
    const y = centerY + radius * Math.sin(angle) - item.offsetHeight / 2;
    gsap.set(item, {
      left: `${x}px`,
      top:  `${y}px`,
      rotation: (angle * 180) / Math.PI - 90,   // tangent to the circle
      transform: "translateY(0%)",              // drop the -50% centering offset
    });
  });
};

const animateToCircularLayout = () => {
  const items = document.querySelectorAll(".item");
  const state = Flip.getState(items);           // capture current (linear-stack) position + rotation
  setCircularLayout();                          // instantly jump cards to their ring slots
  Flip.from(state, {
    duration: 2,
    ease: "hop",
    stagger: -0.03,                             // NEGATIVE: last card leads, first trails
    onEnter: (element) => gsap.to(element, { rotation: "+=360" }),
  });
};

Key mechanics:

  • Ring geometry: 30 cards spaced 2π/30 (12°) apart on a circle of radius 210px centered in the container. Each card's left/top places its center on the ring; each card's rotation is its polar angle in degrees minus 90°, i.e. tangent to the circle (cards lie along the rim, not all upright).
  • Flip captures both position AND rotation. Because the pre-state has every card at rotation:0 in the overlapping strip and the post-state has each at its tangent angle on the ring, Flip.from interpolates both — so cards simultaneously slide out to their slot and spin into their tangent orientation. This dual travel+spin is the signature moment.
  • duration:2, ease:"hop".
  • stagger:-0.03 (negative) → reverse-order stagger: the last-indexed card begins first and the first-indexed trails, so the ring assembles in reverse.
  • onEnter would add an extra +=360 spin to any *entering* element, but since all 30 cards exist in both states none actually enter — it does not fire in practice; keep it for fidelity.
Step 5 — init
createItems();
setInitialLinearLayout();   // called at load; Step 2's loader tween is also scheduled at load
Timing / easing summary (from load)

| t (s) | Action | Property | From → To | Duration | Ease | Stagger / Notes | |---|---|---|---|---|---|---| | 0 | Cards rise into strip | top / translateY | 150%50% / -50% | 1 | hop | 0.03 (index order) | | 1 | Loader 0 reveals | y | 200 | 1 | power3.out | delay:1 | | 2–4 | Count up (setTimeout) | textContent | 0100 | 2s window | — | +random 5–34 every 300ms | | ≈4 | Number slides out | y | 0-20 | 1 | power3.inOut | fires ~immediately after count | | ≈5 | Flip stack → ring | left/top/rotation | strip → circle (radius 210) | 2 | hop | -0.03 (reverse) | | ≈5.1 | Nav labels reveal | y | 200 | 1 | power3.inOut | 0.075, 7 labels, +100ms after Flip |

Total runtime ≈ 7s.

Assets / images

30 editorial fashion / portrait photographs, portrait orientation, cropped by object-fit:cover to the 175×250 (≈5:7) cards. Single subjects — models and portraits, plus a couple of still-lifes — shot against saturated or moody studio backdrops (terracotta, deep red-orange, green, cobalt blue, warm neutral grey), cinematic editorial mood, mixed warm/cool color stories (they need not match as a set). Because each card is small, initially heavily overlapped, then splayed onto a rotating ring, exact content barely reads — any cohesive set of moody portrait/editorial images works. Provide them as an array or a /images/img${i}.jpg path for i = 1..30. If fewer than 30 are available, repeat to fill.

Behavior notes

  • Autoplay once on load; no scroll, hover, or click triggers. The page never scrolls (overflow:hidden on .container, 100vh body).
  • Layout math (startX, centerX/Y) reads live offsetWidth/offsetHeight, so the strip centers and the ring centers to the viewport at load time; it is not re-run on resize in the original.
  • No reduced-motion branch in the original.
  • The two clip-path masks (nav labels + loader) plus the translateY(20/-20) on their <p> are what make the label/number "slide in/out of a slit" reveal — keep both the 20px mask height and the 20px translate in sync.