About
The studio's own turntable, rebuilt without Emotion and without branding. The ring is a set of slots at rotateY(i * step) translateZ(radius); each slot is a phone drawn from the shared body model (phone-ring.geometry.ts): a band of flat strips standing on the rounded outline (sides, top, four facets per corner, side buttons painted along them), a front of anodised rim, bezel and screen, and a back of glass under a camera plateau. A single requestAnimationFrame loop eases the ring onto the nearest phone, carries a drag's throw, and sleeps when nothing moves. The reflection under each phone and the floor ring are CSS only.
Use it when
- A studio, agency or app wants to show several builds, sites or screens as one object.
- A product page wants a device family: the same app in several states.
- A portfolio wants something to play with above the fold.
Skip it when
- Only one screenshot: use Phone Frame.
- The audience is on low-end phones and the page is already heavy: the ring is cheap, but it is a lot of elements (about 30 per phone).
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/phone-ring - 3Use it:
import { PhoneRing } from "@/components/vibebox/phone-ring"; <PhoneRing className="mx-auto w-full max-w-5xl" phones={[ { id: "one", label: "Casa Alon", src: "/screens/casa-alon.png", alt: "Casa Alon's mobile site" }, { id: "two", label: "Kape Alon", src: "/screens/kape-alon.png", alt: "Kape Alon's mobile site" }, ]} />
Code
3 filescomponents/vibebox/phone-ring/index.tsx
/**
* VibeBox Motion · Phone Ring
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
export { PhoneRing, type PhoneRingProps, type RingPhone } from "./phone-ring.client";
export { FINISHES, FINISH_ORDER, type FinishSpec, type PhoneFinish } from "./phone-ring.geometry";components/vibebox/phone-ring/phone-ring.client.tsx
/**
* VibeBox Motion · Phone Ring
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
"use client";
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { BAND, BODY, FINISHES, FINISH_ORDER, FRONT, type PhoneFinish } from "./phone-ring.geometry";
export type RingPhone = {
/** A stable id and the name that is read out. */
id: string;
label: string;
/** A screenshot of the screen; taller than the screen and it pans. */
src?: string;
/** Alt text for the screenshot. */
alt?: string;
/** Or anything else for the screen (a live page, a video). */
children?: ReactNode;
/** The finish; the ring cycles through the four when it is left out. */
finish?: PhoneFinish;
/** A short caption shown under the ring while this phone is in front. */
caption?: string;
};
export type PhoneRingProps = {
phones: readonly RingPhone[];
/** The width of one phone in px; by default it follows the viewport (96 to 150). */
size?: number;
/** Turn on its own, a phone at a time. */
autoplay?: boolean;
/** Seconds each phone stays in front. */
interval?: number;
/** Called when a different phone comes to the front. */
onChange?: (index: number, phone: RingPhone) => void;
/** Accessible name of the whole ring. */
label?: string;
className?: string;
};
const STEP_TAU = 7.5; // how quickly the ring settles onto a phone (1/s)
const RESUME_AFTER_MS = 6500;
function Strip({ index }: { index: number }) {
const strip = BAND[index]!;
const tone = strip.shade >= 0 ? "var(--metal-hi)" : "var(--metal-lo)";
const lit = `color-mix(in srgb, var(--metal-mid), ${tone} ${Math.round(Math.abs(strip.shade) * 100)}%)`;
const seam = "calc(var(--phone) * 0.004)";
const chamfer = "calc(var(--phone) * 0.009)";
const fill = `linear-gradient(90deg, #060607 0 ${seam}, color-mix(in srgb, ${lit}, #fff 30%) ${seam}, ${lit} calc(${seam} + ${chamfer}), ${lit} calc(100% - ${seam} - ${chamfer}), color-mix(in srgb, ${lit}, #fff 30%) calc(100% - ${seam}), #060607 calc(100% - ${seam}))`;
const keys = strip.keys.length
? `${strip.keys
.map(([start, end]) => {
const a = (start * 100).toFixed(2);
const b = (end * 100).toFixed(2);
const [near, far] = strip.angle === 180 ? ["color-mix(in srgb, " + lit + ", #000 55%)", "color-mix(in srgb, " + lit + ", #fff 45%)"] : ["color-mix(in srgb, " + lit + ", #fff 45%)", "color-mix(in srgb, " + lit + ", #000 55%)"];
return `transparent ${a}%, ${near} ${a}% calc(${a}% + 1px), color-mix(in srgb, ${lit}, var(--metal-hi) 20%) calc(${a}% + 1px) calc(${b}% - 1px), ${far} calc(${b}% - 1px) ${b}%, transparent ${b}%`;
})
.join(", ")}`
: "";
const style: CSSProperties = {
width: `calc(var(--phone) * ${strip.depth})`,
height: `calc(var(--phone) * ${strip.length})`,
margin: `calc(var(--phone) * ${-strip.length / 2}) 0 0 calc(var(--phone) * ${-strip.depth / 2})`,
transform: `translate3d(calc(var(--phone) * ${strip.x}), calc(var(--phone) * ${strip.y}), calc(var(--phone) * ${strip.z})) rotateZ(${strip.angle}deg) rotateY(90deg)`,
background: keys
? `linear-gradient(180deg, ${keys}) calc(var(--phone) * ${FRONT - 0.025}) 0 / calc(var(--phone) * 0.05) 100% no-repeat, ${fill}`
: fill,
};
return <span aria-hidden="true" className="absolute top-1/2 left-1/2 [backface-visibility:hidden]" style={style} />;
}
function Screen({ phone }: { phone: RingPhone }) {
const [travel, setTravel] = useState(0);
const [down, setDown] = useState(false);
const frame = useRef<HTMLDivElement>(null);
// A tall screenshot pans to its end and back, slowly.
useEffect(() => {
if (!phone.src || travel <= 0) return;
const timer = window.setInterval(() => setDown((value) => !value), 7500);
return () => window.clearInterval(timer);
}, [phone.src, travel]);
return (
<div ref={frame} className="relative h-full overflow-hidden bg-[#0b0e15] [border-radius:calc(var(--phone)*0.119)]">
{phone.src ? (
// eslint-disable-next-line @next/next/no-img-element -- a screenshot the caller supplies, panned with a transform
<img
src={phone.src}
alt={phone.alt ?? ""}
draggable={false}
loading="lazy"
onLoad={(event) => {
const image = event.currentTarget;
const host = frame.current;
if (host) setTravel(Math.max(0, Math.round((1 - host.clientHeight / image.clientHeight) * 100)));
}}
className="block w-full transition-transform duration-[7500ms] ease-[cubic-bezier(0.65,0,0.35,1)] [@media(prefers-reduced-motion:reduce)]:transition-none"
style={{ transform: down ? `translateY(-${travel}%)` : "translateY(0)" }}
/>
) : (
phone.children
)}
<span aria-hidden="true" className="pointer-events-none absolute inset-0 z-[3] bg-[linear-gradient(118deg,rgb(255_255_255/0.14),rgb(255_255_255/0)_34%)]" />
<span aria-hidden="true" className="absolute top-[calc(var(--phone)*0.024)] left-1/2 z-[2] h-[calc(var(--phone)*0.06)] w-[21%] -translate-x-1/2 rounded-full bg-[#050506]" />
</div>
);
}
function PhoneBody({ phone, finish, index, count }: { phone: RingPhone; finish: PhoneFinish; index: number; count: number }) {
const spec = FINISHES[finish];
const style = {
"--i": index,
"--metal-hi": spec.metal[0],
"--metal-mid": spec.metal[1],
"--metal-lo": spec.metal[2],
"--glass": spec.glass,
"--plateau": spec.plateau,
"--ring": spec.ring,
width: "var(--phone)",
height: "var(--phone-h)",
margin: "calc(var(--phone-h) / -2) 0 0 calc(var(--phone) / -2)",
transform: `rotateY(calc(${index} * ${360 / count}deg)) translateZ(var(--radius))`,
} as CSSProperties;
return (
<div className="absolute top-0 left-0 [transform-style:preserve-3d]" style={style} data-phone={phone.id}>
{BAND.map((_, strip) => (
<Strip key={strip} index={strip} />
))}
{/* The front: an anodised rim, a thin black border, the screen. */}
<div
className="absolute inset-0 [backface-visibility:hidden] [border-radius:calc(var(--phone)*0.155)] [padding:calc(var(--phone)*0.0135)]"
style={{
background: "linear-gradient(140deg, var(--metal-hi) 0%, var(--metal-mid) 24%, var(--metal-lo) 52%, var(--metal-mid) 78%, var(--metal-hi) 100%)",
transform: `translateZ(calc(var(--phone) * ${FRONT}))`,
}}
>
<div className="h-full bg-[#050506] [border-radius:calc(var(--phone)*0.142)] [padding:calc(var(--phone)*0.023)]">
<Screen phone={phone} />
</div>
</div>
{/* The back: glass in its finish under a full-width camera plateau. */}
<div
className="absolute right-0 bottom-0 left-0 overflow-hidden [backface-visibility:hidden]"
style={{
top: `${BODY.plateau * 100}%`,
borderRadius: `0 0 calc(var(--phone) * ${BODY.corner}) calc(var(--phone) * ${BODY.corner})`,
background: "linear-gradient(168deg, color-mix(in srgb, var(--plateau) 90%, #fff), var(--plateau) 46%, color-mix(in srgb, var(--plateau) 78%, #000))",
transform: `translateZ(calc(var(--phone) * ${-FRONT})) rotateY(180deg)`,
}}
>
<span className="absolute inset-x-0 top-0 z-[1] h-[calc(var(--phone)*0.07)] bg-gradient-to-b from-black/40 to-transparent" />
<span
className="absolute inset-x-[3.5%] top-[calc(var(--phone)*0.05)] bottom-[calc(var(--phone)*0.035)] [border-radius:calc(var(--phone)*0.12)]"
style={{ background: "radial-gradient(110% 60% at 24% 10%, rgb(255 255 255 / 0.16), transparent 62%), linear-gradient(162deg, var(--glass), color-mix(in srgb, var(--glass) 78%, #000) 100%)" }}
/>
</div>
<div
className="absolute inset-x-0 top-0 [backface-visibility:hidden]"
style={{
height: `${BODY.plateau * 100}%`,
borderRadius: `calc(var(--phone) * ${BODY.corner}) calc(var(--phone) * ${BODY.corner}) 0 0`,
background: "radial-gradient(120% 95% at 22% 10%, rgb(255 255 255 / 0.18), transparent 60%), linear-gradient(180deg, color-mix(in srgb, var(--plateau) 86%, #fff), var(--plateau) 78%)",
transform: `translateZ(calc(var(--phone) * ${-(FRONT + BODY.rise)})) rotateY(180deg)`,
}}
>
{[
{ left: "12%", top: "14%" },
{ left: "40%", top: "14%" },
{ left: "26%", top: "52%" },
].map((position, lens) => (
<span
key={lens}
className="absolute aspect-square w-1/4 rounded-full"
style={{
...position,
background:
"radial-gradient(circle at 37% 33%, rgb(255 255 255 / 0.6) 0 5%, transparent 6%), radial-gradient(circle, #141b2c 0 33%, #05070c 34% 50%, #2d323c 51% 57%, var(--ring) 58% 73%, color-mix(in srgb, var(--ring) 55%, #fff) 74% 79%, var(--ring) 80%)",
boxShadow: "0 calc(var(--phone) * 0.008) calc(var(--phone) * 0.02) rgb(0 0 0 / 0.4)",
}}
/>
))}
<span className="absolute top-[22%] right-[12%] aspect-square w-[8%] rounded-full" style={{ background: "radial-gradient(circle, #fffbe8 0 38%, #ddd2ab 39% 62%, var(--ring) 63%)" }} />
</div>
</div>
);
}
export function PhoneRing({ phones, size, autoplay = true, interval = 3.4, onChange, label = "A ring of phones; drag to turn it", className }: PhoneRingProps) {
const count = Math.max(1, phones.length);
const step = 360 / count;
const ringRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState(0);
const state = useRef({ position: 0, target: 0, velocity: 0, dragging: false, lastInput: -RESUME_AFTER_MS, lastX: 0, raf: 0, visible: true, last: 0 });
const finishes = useMemo(() => phones.map((phone, index) => phone.finish ?? FINISH_ORDER[index % FINISH_ORDER.length]!), [phones]);
const reduced = useRef(false);
const apply = useCallback(() => {
const ring = ringRef.current;
if (ring) ring.style.transform = `rotateY(${-state.current.position * step}deg)`;
}, [step]);
const frame = useCallback(
(time: number) => {
const s = state.current;
const dt = Math.min(0.05, (time - s.last) / 1000 || 0.016);
s.last = time;
if (!s.dragging) {
// Released: coast with the throw, then settle onto the nearest phone.
s.position += s.velocity * dt;
s.velocity *= Math.exp(-dt * 3);
if (Math.abs(s.velocity) < 0.06) {
const ease = 1 - Math.exp(-dt * STEP_TAU);
s.position += (s.target - s.position) * ease;
} else {
s.target = Math.round(s.position);
}
}
apply();
const near = ((Math.round(s.position) % count) + count) % count;
setActive((value) => (value === near ? value : near));
const moving = s.dragging || Math.abs(s.velocity) > 0.01 || Math.abs(s.target - s.position) > 0.002;
s.raf = moving ? requestAnimationFrame(frame) : 0;
},
[apply, count],
);
const wake = useCallback(() => {
const s = state.current;
if (!s.raf) {
s.last = performance.now();
s.raf = requestAnimationFrame(frame);
}
}, [frame]);
const goTo = useCallback(
(delta: number, userInput = true) => {
const s = state.current;
s.velocity = 0;
s.target = Math.round(s.target) + delta;
if (userInput) s.lastInput = performance.now();
wake();
},
[wake],
);
useEffect(() => {
reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
apply();
return () => cancelAnimationFrame(state.current.raf);
}, [apply]);
// Turns on its own, but never while someone is dragging, soon after they did, or off screen.
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const observer = new IntersectionObserver(([entry]) => (state.current.visible = Boolean(entry?.isIntersecting)), { threshold: 0.2 });
observer.observe(root);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!autoplay || count < 2) return;
const timer = window.setInterval(() => {
const s = state.current;
if (reduced.current || document.hidden || !s.visible || s.dragging || performance.now() - s.lastInput < RESUME_AFTER_MS) return;
goTo(1, false);
}, Math.max(1.2, interval) * 1000);
return () => window.clearInterval(timer);
}, [autoplay, count, interval, goTo]);
const current = phones[active];
useEffect(() => {
if (current) onChange?.(active, current);
// The caller's callback should not restart this.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
// A drag turns the ring; the throw carries on.
const pxPerStep = useRef(150);
const onPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
const s = state.current;
s.dragging = true;
s.lastX = event.clientX;
s.velocity = 0;
s.lastInput = performance.now();
pxPerStep.current = Math.max(80, (event.currentTarget.clientWidth * 0.5) / Math.min(count, 4));
event.currentTarget.setPointerCapture(event.pointerId);
wake();
};
const onPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const s = state.current;
if (!s.dragging) return;
const dx = event.clientX - s.lastX;
s.lastX = event.clientX;
s.position -= dx / pxPerStep.current;
s.velocity = -(dx / pxPerStep.current) * 60 * 0.4;
s.lastInput = performance.now();
wake();
};
const onPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
const s = state.current;
s.dragging = false;
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
s.target = Math.round(s.position + s.velocity * 0.3);
wake();
};
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowRight") {
event.preventDefault();
goTo(1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
goTo(-1);
}
};
const style = {
"--phone": size ? `${size}px` : "clamp(104px, 12vw, 176px)",
"--phone-h": `calc(var(--phone) * ${BODY.height})`,
"--radius": `calc(${count} * var(--phone) * 1.26 / 6.2832)`,
} as CSSProperties;
return (
<div ref={rootRef} data-vb="phone-ring" data-active={active} className={cn("relative", className)} style={style}>
<div
role="group"
aria-roledescription="carousel"
aria-label={label}
tabIndex={0}
onKeyDown={onKeyDown}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
className="relative cursor-grab touch-pan-y select-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring active:cursor-grabbing"
style={{ height: "calc(var(--phone-h) * 1.46)", perspective: "calc(var(--radius) * 2.7)", perspectiveOrigin: "50% 8%" }}
>
<span aria-hidden="true" className="pointer-events-none absolute bottom-[2%] left-1/2 h-[46%] w-[min(1100px,120%)] -translate-x-1/2 bg-[radial-gradient(50%_50%_at_50%_50%,color-mix(in_oklab,var(--spectrum-3)_14%,transparent),transparent_70%)]" />
<div className="absolute top-[37%] left-1/2 h-0 w-0 [transform-style:preserve-3d]" style={{ transform: "translateZ(calc(var(--radius) * -1)) rotateX(-7deg)" }}>
<span
aria-hidden="true"
className="pointer-events-none absolute top-0 left-0 aspect-square rounded-full border border-[color-mix(in_oklab,var(--foreground)_12%,transparent)]"
style={{ width: "calc(var(--radius) * 2 + var(--phone) * 0.9)", transform: "translate(-50%, -50%) translateY(calc(var(--phone-h) * 0.5 + 8px)) rotateX(90deg)" }}
/>
<div ref={ringRef} className="absolute inset-0 [transform-style:preserve-3d]">
{phones.map((phone, index) => (
<PhoneBody key={phone.id} phone={phone} finish={finishes[index]!} index={index} count={count} />
))}
</div>
</div>
</div>
<div className="mt-2 flex items-center justify-center gap-4">
<button type="button" onClick={() => goTo(-1)} aria-label="Previous phone" className="grid size-11 place-items-center rounded-full border border-border-strong text-foreground transition-colors duration-150 hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M13.5 8h-11M7 3.5 2.5 8 7 12.5" />
</svg>
</button>
<p aria-live="polite" className="min-w-0 max-w-[16rem] text-center text-small">
<span className="font-mono text-caption text-subtle-foreground">{String(active + 1).padStart(2, "0")} / {String(count).padStart(2, "0")}</span>
<span className="block truncate font-medium">{current?.label}</span>
{current?.caption && <span className="block truncate text-caption text-muted-foreground">{current.caption}</span>}
</p>
<button type="button" onClick={() => goTo(1)} aria-label="Next phone" className="grid size-11 place-items-center rounded-full border border-border-strong text-foreground transition-colors duration-150 hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
<svg viewBox="0 0 16 16" aria-hidden="true" className="size-4" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round">
<path d="M2.5 8h11M9 3.5 13.5 8 9 12.5" />
</svg>
</button>
</div>
</div>
);
}components/vibebox/phone-ring/phone-ring.geometry.ts
/**
* VibeBox Motion · Phone Ring
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
/*
* A phone's body as flat CSS 3D planes, in units of the phone's width: x right, y down, z toward
* the viewer, origin at the centre. The front glass and the back sit at the two faces; the metal
* band between them follows the rounded outline as strips (the sides, the top, and a few facets
* per corner), each standing on the outline and facing out. The camera plateau stands off the
* back, so the band beside it reaches further back. The bottom edge faces the floor, away from a
* viewer looking down at the turntable, so it has no strip. Proportions follow a 6.9-inch
* flagship: 163.4 x 78 x 8.75 mm. The device is drawn, never traced from a maker's artwork, and
* carries no logo or name.
*/
export const BODY = {
/** Height over width. */
height: 2.08,
corner: 0.155,
/** Front glass to back. */
depth: 0.112,
/** How far the camera plateau stands off the back. */
rise: 0.026,
/** The plateau's share of the height. */
plateau: 0.29,
/** Flat facets per rounded corner. */
facets: 4,
} as const;
/** Side buttons as shares of the height from the top: left side, then right. */
export const KEYS: Record<"left" | "right", ReadonlyArray<readonly [number, number]>> = {
left: [
[0.17, 0.22],
[0.25, 0.335],
[0.355, 0.44],
],
right: [
[0.26, 0.38],
[0.6, 0.67],
],
};
export type BandStrip = {
x: number;
y: number;
z: number;
/** The way the strip faces in the phone's plane, in degrees: 0 right, 90 down. */
angle: number;
/** Along the outline. */
length: number;
/** Through the body. */
depth: number;
/** Fixed light from above and a little to the right, from -1 to 1. */
shade: number;
/** Buttons along the strip, as [start, end] shares of its length from its own top edge. */
keys: Array<[number, number]>;
};
const OVERLAP = 1.12;
const LIGHT = { x: 0.287, y: -0.958 };
const round = (value: number) => Math.round(value * 10000) / 10000;
const radians = (degrees: number) => (degrees * Math.PI) / 180;
function shadeFor(angle: number) {
const lambert = Math.cos(radians(angle)) * LIGHT.x + Math.sin(radians(angle)) * LIGHT.y;
return round(Math.max(-1, Math.min(1, lambert * 0.75)));
}
/** The front of the body. */
export const FRONT = BODY.depth / 2;
const DEEP = BODY.depth + BODY.rise;
const DEEP_Z = FRONT - DEEP / 2;
function strip(x: number, y: number, angle: number, length: number, deep: boolean, keys: Array<[number, number]> = []): BandStrip {
return {
x: round(x),
y: round(y),
z: round(deep ? DEEP_Z : 0),
angle: round(angle),
length: round(length),
depth: round(deep ? DEEP : BODY.depth),
shade: shadeFor(angle),
keys: keys.map(([start, end]) => [round(start), round(end)]),
};
}
function keysOn(side: "left" | "right", from: number, to: number): Array<[number, number]> {
const top = -BODY.height / 2;
const span = to - from;
return KEYS[side].flatMap(([start, end]) => {
const a = Math.max(from, top + start * BODY.height);
const b = Math.min(to, top + end * BODY.height);
if (b <= a) return [];
const near = (a - from) / span;
const far = (b - from) / span;
return [side === "right" ? ([near, far] as [number, number]) : ([1 - far, 1 - near] as [number, number])];
});
}
function buildBand(): BandStrip[] {
const half = BODY.height / 2;
const r = BODY.corner;
const ledge = -half + BODY.plateau * BODY.height;
const strips: BandStrip[] = [];
for (const side of ["right", "left"] as const) {
const x = side === "right" ? 0.5 : -0.5;
const angle = side === "right" ? 0 : 180;
const top = -half + r;
const bottom = half - r;
strips.push(strip(x, (top + ledge) / 2, angle, ledge - top, true, keysOn(side, top, ledge)));
strips.push(strip(x, (ledge + bottom) / 2, angle, bottom - ledge, false, keysOn(side, ledge, bottom)));
}
strips.push(strip(0, -half, -90, 1 - 2 * r, true));
const corners = [
{ cx: 0.5 - r, cy: -half + r, from: -90, deep: true },
{ cx: -0.5 + r, cy: -half + r, from: 180, deep: true },
{ cx: 0.5 - r, cy: half - r, from: 0, deep: false },
{ cx: -0.5 + r, cy: half - r, from: 90, deep: false },
];
const sweep = 90 / BODY.facets;
const inset = r * Math.cos(radians(sweep / 2));
const chord = 2 * r * Math.sin(radians(sweep / 2)) * OVERLAP;
for (const corner of corners) {
for (let facet = 0; facet < BODY.facets; facet++) {
const angle = corner.from + (facet + 0.5) * sweep;
strips.push(strip(corner.cx + inset * Math.cos(radians(angle)), corner.cy + inset * Math.sin(radians(angle)), angle, chord, corner.deep));
}
}
return strips;
}
export const BAND: readonly BandStrip[] = buildBand();
export type PhoneFinish = "black" | "burgundy" | "silver" | "glacier";
export type FinishSpec = {
label: string;
/** Anodised frame: highlight, mid tone, shadow. */
metal: readonly [string, string, string];
/** The back glass. */
glass: string;
/** The camera plateau. */
plateau: string;
/** Rings around the lenses. */
ring: string;
};
/** Four finishes, by colour name only. */
export const FINISHES: Record<PhoneFinish, FinishSpec> = {
black: { label: "Black", metal: ["#55575f", "#24252a", "#0a0b0d"], glass: "#1c1d21", plateau: "#26272c", ring: "#101114" },
burgundy: { label: "Burgundy", metal: ["#a24a5a", "#621c29", "#2c0810"], glass: "#581a26", plateau: "#66202e", ring: "#3b0e17" },
silver: { label: "Silver", metal: ["#ffffff", "#dfe0e3", "#a1a4a9"], glass: "#e3e4e6", plateau: "#ececee", ring: "#b4b7bb" },
glacier: { label: "Glacier", metal: ["#f4f9fc", "#c6d8e6", "#8aa4b8"], glass: "#c8d9e6", plateau: "#d4e3ee", ring: "#9eb5c7" },
};
export const FINISH_ORDER: readonly PhoneFinish[] = ["black", "burgundy", "silver", "glacier"];Props
| Prop | Type | Default | Description |
|---|---|---|---|
| phones * | RingPhone[] | — | The phones: { id, label, src?, alt?, children?, finish?, caption? }. A screenshot in src pans if it is taller than the screen. |
| size | number | — | The width of one phone in px; by default it follows the viewport (104 to 176 px). |
| autoplay | boolean | true | Turns on its own, a phone at a time, until someone drags or presses a key. |
| interval | number | 3.4 | Seconds each phone stays in front. |
| onChange | (index: number, phone: RingPhone) => void | — | Called when a different phone comes to the front. |
| label | string | "A ring of phones; drag to turn it" | Accessible name of the whole ring. |
| className | string | — | Merged with cn(); give it a width. |
Accessibility and motion
- Keyboard
- The ring is focusable: Left and Right arrows turn it by a phone. The previous and next buttons are real buttons.
- Screen readers
- A carousel named by
label; the front phone's number, name and caption are announced politely as it changes. - Touch
- Drag to spin (vertical scrolling stays available); tap an arrow for a step.
- Reduced motion
- The ring does not turn on its own; it still turns on drag, the arrows and the keys, with the phones settling at once.
Edge cases and limits
- With fewer than four phones the ring is a tight circle: use
sizesmaller or add phones. - A screenshot's top 19.5:9 is what shows; make full-page captures at the phone's width.
-webkit-box-reflectis not used; the floor ring is a plain border so it works in every browser.
Related

Card Fan
FreeA hand of cards: lift one on hover, bring it forward on tap, arrow keys between them. Pure CSS 3D.
3D · CSS

Cube Carousel
FreeContent on the faces of a turning prism: arrows, keys and drag, a face at a time. Pure CSS 3D.
3D · CSS

Brochure
FreeA 3D brochure whose paper bends: click a page, drag a corner or press the arrow keys to turn it.
3D · three.js