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
.colgroups. 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-itemis its clipping mask. .loader > pstarts as the text0; JS overwrites it with the counting value..galleryis the (unpositioned) host for the 30 injected.itemcards.
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 bystartX. - They start at
top:150%(fully below the fold) and animate up totop:50%withtransform:translateY(-50%)(vertically centered). duration:1,ease:"hop",stagger:0.03left-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. onComplete → animateCounter.
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 pslidey → 0,duration:1,ease:"power3.inOut",stagger:0.075across 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'sleft/topplaces its center on the ring; each card'srotationis 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:0in the overlapping strip and the post-state has each at its tangent angle on the ring,Flip.frominterpolates 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.onEnterwould add an extra+=360spin 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 | 20 → 0 | 1 | power3.out | delay:1 | | 2–4 | Count up (setTimeout) | textContent | 0 → 100 | 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 | 20 → 0 | 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:hiddenon.container,100vhbody). - Layout math (
startX,centerX/Y) reads liveoffsetWidth/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.