← Learn · Prompt · 18 October 2026 · 8 min

The Prompt Behind My Homepage: A Loader That Deals Cards Into a Ring, Then Opens Into the 3D Carousel Hero

A copy-pasteable prompt and the code concepts behind avivashishta.com: Reel cards dealt around a small tilted ring while a counter loads, then the same cards expand into the full-screen carousel. One pose function, no handoff, no second scene.

Open avivashishta.com and you see a white page, a big counter and a small ring of Reels tilted like a coin on a table. The cards are dealt out one by one while the percentage climbs. When it hits 100, the ring does not disappear. It opens. The camera swings down, the radius grows from a few hundred pixels to a few thousand, and the same cards are now the carousel you drag on the homepage.

That is the whole idea: the loader and the hero are one scene at two moments. There is no loading screen that gets replaced by a hero. Everything below serves that sentence.

The short version: every card has a resting angle on a cylinder. One function, reelPose(angle, formation, scene), turns that angle into a position and rotation. The loader is scene at expansion 0 (small ring, tilted camera, dutch roll). The hero is scene at expansion 1 (big ring, level camera). Animate two numbers, formation and expansion, and the whole sequence falls out.

The prompt

Paste this into Claude Code, Cursor or Replit. It describes the behaviour, not the file layout, so it works in any React project.

Build a homepage hero where the loader and the hero are the same 3D scene. React, Motion (framer-motion), plain CSS 3D transforms. No WebGL, no Three.js.

THE SCENE: 16 vertical video cards (270 x 480 px, 9:16) sit on the inside of a cylinder. Each card has a resting angle (slot * 360 / 16). 0 degrees is the far centre of the ring, plus or minus 180 is the card nearest the camera. The ring spins slowly on its own, and the visitor can drag, swipe or wheel to turn it. Drag and swipe move a target angle, and the visible angle follows through a spring so it never snaps.

TWO MOTION VALUES DRIVE EVERYTHING: formation (0 to 1) and expansion (0 to 1). Nothing else is animated by hand.

FORMATION (the loader): all cards start stacked at the front of the ring, looking like one card. As formation runs 0 to 1 they are dealt out around the ring one after another, each swinging out with an ease-out cubic. Order each card by its angle so the deal travels around the ring. Cards fade in as they are dealt so the waiting stack is never visible. Formation takes about 2.6 seconds, linear.

EXPANSION (the reveal): at expansion 0 the ring is small (radius 460), seen from above and outside (tilt -22 degrees), with a dutch-angle roll (-20 degrees), perspective 1000px, cards scaled to 0.52. At expansion 1 the ring is large (radius 1800), the camera is level and inside it (tilt 0, roll 0, perspective 450), cards scaled 2.3. Interpolate radius, tilt, roll, perspective and scale from one to the other. Expansion takes about 1.9 seconds with a strong ease (cubic-bezier .5, 0, .15, 1). It starts only when formation has finished AND the real assets are ready, whichever is later.

ONE POSE FUNCTION: write a pure function reelPose(angle, formation, scene) that returns x, y, z, yaw, scale, appear and behindCamera. x = -sin(a) * radius, z = -cos(a) * radius, yaw = a. Write it as a pure function with unit tests, because it carries the whole animation.

LOADER UI: a large mono counter (tabular numerals) at the bottom right that reads real progress, meaning how many front-facing covers and video previews have actually loaded, not a fake timer. Each digit is its own column that rolls vertically. A small scrambled caption says Loading Reels. The brand word sits top left and, when the loader clears, flies into the header wordmark's exact position and size.

HANDOFF: the loader backdrop is a white page that fades out while the cards keep moving, so nothing resets. The header, frame and headline start while the cards are still settling. Do not queue the steps, overlap them.

PERFORMANCE: cards recycle on the back of the cylinder where they are hidden: 16 DOM cards show any number of reels by swapping which reel each slot holds once it passes the back. Only the cards facing the camera play video. Write a style to the DOM only when its value changed, and set the shared ring values as CSS variables on the ring element, not per card. Do hover hit-testing every third frame.

ACCESSIBILITY AND ESCAPES: Escape, Tab and a visible skip button end the entrance instantly. With prefers-reduced-motion the loader never shows and the carousel is simply there. Every card is a real link with a text label for screen readers. Scroll does nothing while the entrance plays.

Show me the pose function and its tests first, then the loader, then the wall.

Concept 1: a cylinder is just angles

Every card gets a number. That is the entire data model.

// reel-ring.ts
export const REEL_SLOTS = 16;
export function reelAngle(slot: number, angle: number, slots = REEL_SLOTS) {
  return ((slot * 360 / slots + angle + 180) % 360 + 360) % 360 - 180;
}

angle is how far the whole ring has turned. Spinning the carousel is angle += 0.3 per frame. Dragging is setting a target and letting a spring chase it. No card has its own animation, which is why 16 cards stay cheap.

Concept 2: one pose function owns the animation

The card position is a circle in the XZ plane. The loader trick is that undealt cards sit at angle -180 (the front) and get dealt toward their real angle.

// reel-entrance.ts
export function reelPose(angle: number, formation: number, scene: Scene) {
  const around = ((angle + 180) % 360 + 360) % 360;
  const order = around / 360;
  // Each card has its own slice of the 0..1 formation, staggered by order.
  const t = Math.max(0, Math.min(1, (formation - order * (1 - DEAL)) / DEAL));
  const dealt = 1 - (1 - t) ** 3;               // ease-out cubic
  const placed = -180 + around * dealt;          // swing from the front to its angle
  const r = placed * Math.PI / 180;
  const radius = scene.radius - (1 - dealt) * order * 24; // stack reads as one card
  return {
    x: -Math.sin(r) * radius,
    z: -Math.cos(r) * radius,
    yaw: placed,
    appear: Math.min(1, t * 2.5),               // never show the waiting stack
  };
}

DEAL is the share of the formation each card spends moving (0.3 here). The rest is the stagger between cards. Lower it for a faster, snappier deal, raise it for a lazy fan.

Concept 3: the loader and hero are two ends of one interpolation

const START = { radius: 460, perspective: 1000, tilt: -22, roll: -20, scale: .52 };

export function reelScene(expansion: number, camera = 450) {
  return {
    radius: START.radius + (1800 - START.radius) * expansion,
    perspective: START.perspective + (camera - START.perspective) * expansion,
    tilt: START.tilt * (1 - expansion),
    roll: START.roll * (1 - expansion),
    scale: START.scale + (2.3 - START.scale) * expansion * expansion,
  };
}

At 0 you get a small ring seen from above with a lean. At 1 you get the level carousel. The squared easing on scale keeps the cards small for the first half of the reveal and lets them swell at the end, which is what makes it feel like the camera is dropping into the ring.

One detail that took me longest: as the tilt flattens, the far side of the ring would drift vertically. A small anchor offset cancels that so the back of the ring travels on one smooth path.

Concept 4: two motion values, no timeline

const formation = useMotionValue(0);
const expansion = useMotionValue(0);

// Deal the cards while assets load.
animate(formation, 1, { duration: 2.6, ease: "linear", onComplete: () => { formed = true; tryReveal(); } });

// Open the ring only when the cards are dealt AND the real assets are ready.
const tryReveal = () => {
  if (!formed || waiting) return;
  animate(expansion, 1, { duration: 1.9, ease: [.5, 0, .15, 1] });
  animate(0, 1, { duration: 1.05, onComplete: () => setHomeEntrance(true) }); // header + sentence start early
};

Two gates make this feel honest. The deal always runs its full length, so the loader never flashes by on a fast connection. The reveal waits for real covers, so it never opens onto empty cards on a slow one. The counter reads actual progress, and when it reaches 100 the ring is already ready to open.

The page chrome starts at 1.05 seconds while expansion is still running until 1.9. That overlap is what stops it feeling like a loading screen followed by a website.

Concept 5: write styles only when they change

A 3D ring with 16 videos can drop frames if you restyle everything every frame. Two rules made it smooth:

const written = new Map<string, string>();
const write = (key: string, value: string, apply: () => void) => {
  if (written.get(key) === value) return;
  written.set(key, value); apply();
};
  1. Shared values (--intro-tilt, --intro-roll, perspective) live as CSS variables on the ring, written through the cache above. Setting them per card would restyle all 16 cards and their videos on each frame.
  2. Hover hit-testing runs every third frame and before the frame's writes, because reading layout after writing styles forces a synchronous layout pass.

Cards also recycle on the back of the cylinder. There are 16 DOM cards, but any number of reels: when a card passes behind the ring, where nobody can see it, it swaps to the next reel. Only cards within 78 degrees of the camera play video.

Concept 6: leave a way out

A big entrance is a gift the first time and a tax the fifth. The loader listens for Escape, Tab, a skip button, and prefers-reduced-motion, and any of them jumps both motion values to 1 and goes straight to the hero. Scrolling does nothing while it plays, so a stray wheel flick can't cut the animation short. On phones, a swipe anywhere on the page turns the ring once it has finished.

What I'd tell you before you build it

  • Test the pose function by itself. It is pure, so it takes ten lines to assert that every card lands on one circle and that the two halves of the deal cover equal distances. Those tests caught more bugs than eyeballing the animation.
  • Make the counter real. Fake progress bars feel fake. Ours counts the covers and previews that have loaded.
  • Put the cards above the loader. The white loader page is position: fixed at z-index 100, and the wall sits at 101 while it is entering, so the cards rise over the white instead of being hidden by it.
  • Name the handoff. If the brand word in the loader ends up in the header, measure the real target with getBoundingClientRect and animate to it. Don't guess coordinates.

If you build your own version, send me the link. I'd like to see what you do with the first two seconds.