---
title: "Floating Phones: an A24-Style 3D iPhone Carousel for My Reels"
description: "How I built a Three.js carousel of floating iPhones that play my most watched Reels, with a foldable intro, a Codex poster to zoom through and a hand-drawn hover loop. Code and prompts inside."
date: "2026-10-27"
type: "build"
tags: ["threejs", "webgl", "3d", "gltf", "codex", "creative coding", "nextjs"]
output: "/lab/phones"
---

I saw [Ravi Klaassens' A24 concept](https://a24.raviklaassens.com/) and kept scrolling it just to watch the discs move. Every film is a DVD lying back in 3D, the discs run along a diagonal, the one in the middle is the film you are on, and its credits sit in the top left like the back of a box. Hover a disc and a black marker loop scribbles itself around it.

I wanted that for my Reels, with phones instead of discs. [It is live at avivashishta.com/lab/phones](/lab/phones).

While it loads, a white foldable iPhone floats on black with its back to you. When everything is in, it makes one half turn, unfolds onto a film poster, and the camera zooms into the poster until it fills the screen. The poster dissolves and sixteen burgundy iPhones fly in, showing their backs, then turn to face you. The one in the middle plays its Reel. Scroll and the next one comes to the middle.

<div class="highlight"><p><strong>The short version:</strong> two iPhone models as GLB files, squeezed with gltf-transform. Three.js clones the Pro Max once per Reel and puts each Reel's video on its screen. The foldable's own open-close animation runs backwards for the intro, and both its screens are canvases I draw on. Codex made the poster picture; the title is drawn over it in the site's fonts. Every number on the page is real, from my Instagram dashboard.</p></div>

## Step 1: the models (gltf-transform)

I used two models: a foldable "iPhone Duo" in white, which came with an open and close animation, and an iPhone 18 Pro Max in burgundy. Together they were 10 MB. One command each got them under 1 MB:

```bash
npx @gltf-transform/cli optimize duo.glb public/lab/phones/duo.glb \
  --compress meshopt --texture-compress webp --texture-size 1024 \
  --join false --flatten false --palette false
```

Turn off `join`, `flatten` and `palette` for anything animated or anything whose parts you need to find by name. The foldable's hinge is driven by bones, and the screens are found by their material names, so those had to survive. Load meshopt files with `loader.setMeshoptDecoder(MeshoptDecoder)`; the decoder ships with Three.js, so there is nothing extra to host.

Before writing any code, print the model's node tree and its animation. That is how I found out the clip runs from open to closed, so the intro plays it backwards:

```ts
const fold = (t: number) => { action.time = t * 2; mixer.update(0); }; // 0 = open, 1 = closed
fold(1 - unfold); // unfold runs 0 to 1 during the intro
```

## Step 2: the intro (one half turn, then unfold)

The foldable starts with its back to you and sways a little while everything loads. When loading is done, it turns once, from wherever the sway left it, to the front:

```ts
if (!readyAt) yawNow = Math.PI + Math.sin(t * 0.8) * 0.42;      // its back, swaying
const yaw = readyAt ? yawFrom + (Math.PI * 2 - yawFrom) * face : yawNow; // one half turn, never more
```

My first version spun the phone while it loaded and then wound it on to the next full turn. It always looked like it was twisting twice. Starting from the back fixes that: there is exactly one turn, and it means something, because it shows you the front.

Both of its screens are canvases. The cover is a lock screen with the real time. The inner screen is a film poster: the picture from step 3 with the title drawn over it, each line fading up in turn. Drawing text in a canvas means it uses the site's own fonts, and I can put real numbers in the billing block at the bottom.

## Step 3: the poster (Codex)

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">
<p>A cinematic 35mm film still in the style of an A24 movie poster. A vast calm field of tall pale grass at dusk under an enormous soft sky. High above the field, a few slim glowing smartphones float in the air at different angles and heights like quiet lanterns, their screens softly lit, small against the sky. Warm fading light on the horizon, deep burgundy and dusty rose tones in the upper sky blending to warm amber at the horizon. Lots of calm empty sky in the upper and middle part of the frame for a title. Fine film grain, gentle halation, shallow haze, painterly and quiet, premium and minimal. No text, no logos, no letters, no watermark, no people, no border. Landscape 3:2, 1536x1024.</p>
  </div>
</div>

Ask for empty sky where the title goes, and no text: you want to set the words yourself. I asked for burgundy so the sky would match the phones.

## Step 4: zoom into the poster

The camera flies at the middle of the poster, speeding up, and stops at exactly the distance where the screen covers the whole view. That distance is the same rule as CSS `background-size: cover`:

```ts
const near = Math.min(screenH, screenW / camera.aspect) / (2 * Math.tan(fov / 2));
```

At that moment a full-screen `div` appears with the same poster as its background, `center / cover`. The canvas has the poster's exact shape, so the switch can't be seen. From there the `div` keeps zooming as it fades, and the phones fly in over it.

## Step 5: the strip

Each phone's place is one step along a diagonal times its distance from the middle. The phone in the middle turns most of the way toward you, and every phone bobs a little on its own timing:

```ts
pose.copy(STEP).multiplyScalar(d);   // STEP = right, up and nearer the camera
q.copy(tilted).slerp(facing, inOut(1 - Math.min(1, Math.abs(d))));
```

Phones two steps ahead turn around, so you see their backs and camera bumps, and they turn back to the screen as they reach the middle. Phones more than one step behind shrink away, so they never cover the credits on the left. A "Turn it around" button spins the middle one so you can see its back.

One wheel notch is one phone. A trackpad flick sends a long tail of small scroll events, so after each step the wheel is locked until the gesture pauses or a harder flick starts:

```ts
if (now - wheelAt > 160) { wheel = 0; locked = false; }
if (locked && amount > lastDelta * 2 + 8) locked = false;
```

## Step 6: the marker loop

I first drew one clean loop and froze it. Then I looked at the original again: it is two or three rough passes, and the whole scribble is redrawn many times a second, so it shimmers and keeps turning, like a hand-drawn animation. So the loop is regenerated from a new random seed about twelve times a second, and its starting angle turns slowly:

```ts
const boil = Math.floor(now / 85); // a new hand-drawn frame every 85 ms
path.setAttribute("d", scribble(centre, across, down, seed, boil, t * 0.9));
```

`across` and `down` are the phone's own sides projected onto the screen, so the loop hugs the phone even while it is tilted and floating. It draws on with `stroke-dashoffset` and `pathLength="1"`, which keeps working even though the path changes every frame.

## Step 7: make it fast

The first version dropped frames. Four changes got it to a steady 120 fps:

- **Hover tests a box, not the phone.** Every phone has an invisible box the size of the phone. Testing the real meshes meant tens of thousands of triangles per phone, every frame.
- **Merge the phone into a few meshes.** The Pro Max is around forty parts. Merging every part that shares a material (`mergeGeometries`) leaves a handful of draw calls per phone.
- **Cap the pixel ratio at 1.5.** It is still sharp on a retina screen, at about half the pixels of 2x.
- **No blend modes over the canvas.** My film grain used `mix-blend-mode`, which made the browser repaint the whole screen every frame. Plain opacity looks the same.

## The numbers

The page shows only the sixteen Reels I have full stats for, sorted by views. The credits show likes and comments, the big number counts up to the views, and the three lines under the phone are saves, how many viewers liked it (1 in 18 for the top one) and shares. They come from `data/stats.json`, the same file as my media kit.

## Build your own

<div class="promptbox">
  <button class="copybtn" type="button" data-copy>Copy prompt</button>
  <div class="prompt">
<p>Build a full-screen Three.js page (React, no extra 3D libraries) that shows a list of short videos as floating 3D phones, in the style of the A24 disc carousel at a24.raviklaassens.com. Load one phone model (GLB, meshopt compressed), merge its parts by material, and clone it once per video; put each video's poster, then its muted looping video, on the phone's screen material, cropped like object-fit: cover (glTF UVs, flipY false).</p>
<p>Lay the phones along a diagonal: each step goes right, up and nearer the camera. The middle phone turns most of the way toward the viewer and plays; phones two steps ahead show their backs and turn round as they arrive; phones more than one step behind shrink away. Every phone bobs slightly. One wheel notch or one trackpad flick moves exactly one phone (lock the wheel after a step until the gesture pauses); drag also works; clicking a side phone brings it to the middle.</p>
<p>On hover, draw a hand-drawn marker loop around the phone in an SVG overlay: two to three rough passes from the phone's projected centre and side vectors, regenerated with a new seed every 85 ms and slowly rotating, drawn on with stroke-dashoffset and pathLength 1. Dark page. Top left: the item's title in a large uppercase serif plus a ruled table of details; bottom left: a big number that counts up; under the phone: three short stats. Hover tests use one invisible box per phone, and the pixel ratio is capped at 1.5.</p>
  </div>
</div>

To change the phones' order or add a Reel, update its numbers in `data/stats.json`; the page picks up every Reel that has full stats.
