Skip to content

FreeIn reviewv1.0.0

Phone Ring

Phones on a turntable: drag to spin with inertia, tap an arrow, keyboard-friendly. Pure CSS 3D.
npx shadcn@latest add @vibebox/phone-ring

Controls

3.4
140
<PhoneRing />

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

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/phone-ring
  3. 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 files
components/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

PropTypeDefaultDescription
phones *RingPhone[]—The phones: { id, label, src?, alt?, children?, finish?, caption? }. A screenshot in src pans if it is taller than the screen.
sizenumber—The width of one phone in px; by default it follows the viewport (104 to 176 px).
autoplaybooleantrueTurns on its own, a phone at a time, until someone drags or presses a key.
intervalnumber3.4Seconds each phone stays in front.
onChange(index: number, phone: RingPhone) => void—Called when a different phone comes to the front.
labelstring"A ring of phones; drag to turn it"Accessible name of the whole ring.
classNamestring—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 size smaller or add phones.
  • A screenshot's top 19.5:9 is what shows; make full-page captures at the phone's width.
  • -webkit-box-reflect is not used; the floor ring is a plain border so it works in every browser.
  • A fan of five colourful cards with the middle one lifted

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

    3D · CSS

  • A cube of coloured cards turning, with the next face coming to the front

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

    3D · CSS

  • An open 3D brochure on a desk with a page turning and its corner lifted

    A 3D brochure whose paper bends: click a page, drag a corner or press the arrow keys to turn it.

    3D · three.js

↑ ↓ to move↵ to openesc to close