# Ring Gallery (`ring-gallery`)

> A 3D carousel ring — cards stand on an invisible cylinder; drag to spin it with real inertia, let go and it coasts, idle and it slowly revolves.

- **Docs:** https://www.mellowui.com/components/ring-gallery
- **Markdown:** https://www.mellowui.com/components/ring-gallery.md
- **Registry:** https://www.mellowui.com/r/ring-gallery.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/ring-gallery
- **Categories:** display, 3d, interactive, animation
- **Dependencies:** motion

## AI prompt

Add a RingGallery component from the mellow library — a 3D carousel ring. Cards stand on an invisible cylinder; drag to spin it with real inertia, let go and it coasts, idle and it slowly revolves. Pass `items` (one ReactNode per card), tune `cardWidth` / `cardHeight` / `gap`, set `autoRotate` in deg/s (0 disables), and `segments` for how many vertical slices bend each face around the cylinder (1 = flat cards). Transform-only; reduced-motion falls back to a horizontal strip.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/ring-gallery.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `ReactNode[]` | — | Card faces standing on the ring. |
| `cardWidth` | `number` | `210` | Card size in px. |
| `cardHeight` | `number` | `270` | Card height in px. |
| `gap` | `number` | `48` | Extra ring radius beyond the minimum, in px. |
| `autoRotate` | `number` | `5` | Idle rotation in deg/s — pauses on hover and drag. 0 disables. |
| `segments` | `number` | `10` | Vertical slices each card is bent into. 1 renders flat cards. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useRef, useState } from "react";
import {
  motion,
  useAnimationFrame,
  useMotionValue,
  useTransform,
  type MotionValue,
} from "motion/react";

export interface RingGalleryProps {
  items: React.ReactNode[];
  /** Card size in px. */
  cardWidth?: number;
  cardHeight?: number;
  /** Extra ring radius beyond the minimum, in px. */
  gap?: number;
  /** Idle rotation in deg/s — pauses on hover and drag. 0 disables. */
  autoRotate?: number;
  /**
   * Vertical slices each card is bent into so its face follows the ring's
   * curve — the barrel reads as one continuous cylinder instead of a set of
   * flat planes. 1 renders flat cards.
   */
  segments?: number;
  className?: string;
  style?: React.CSSProperties;
}

/**
 * A 3D carousel ring — cards stand on an invisible cylinder; drag to spin it
 * with real inertia, let go and it coasts, idle and it slowly revolves.
 * Rear cards recede into the dark.
 */
export function RingGallery({
  items,
  cardWidth = 210,
  cardHeight = 270,
  gap = 48,
  autoRotate = 5,
  segments = 10,
  className,
  style,
}: RingGalleryProps) {
  const rotation = useMotionValue(0);
  const [reduced, setReduced] = useState(false);
  // The 3D ring's motion transforms serialize with different float precision
  // on the server vs the client (radius is an irrational px value), which
  // trips React hydration. Render the flat fallback for SSR + first paint,
  // then swap to the ring once mounted on the client.
  const [mounted, setMounted] = useState(false);
  const draggingRef = useRef(false);
  const hoverRef = useRef(false);
  const velocityRef = useRef(0);
  const lastXRef = useRef(0);
  const lastTRef = useRef(0);

  const n = items.length;
  const radius = cardWidth / 2 / Math.tan(Math.PI / Math.max(3, n)) + gap;

  // The front card sits at translateZ(radius) under this perspective, so it
  // renders scaled up by perspective/(perspective - radius). Size the frame to
  // that scaled height (plus breathing room) or the top/bottom gets clipped.
  const perspective = 1100;
  const frontScale = radius < perspective ? perspective / (perspective - radius) : 1;
  const frameHeight = Math.round(cardHeight * frontScale) + 48;

  useEffect(() => {
    setMounted(true);
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);

  useAnimationFrame((_, delta) => {
    if (reduced || draggingRef.current) return;
    const dt = Math.min(delta, 50) / 1000;
    const v = velocityRef.current;
    if (Math.abs(v) > 2) {
      rotation.set(rotation.get() + v * dt);
      velocityRef.current = v * Math.exp(-dt * 2.4);
    } else if (!hoverRef.current && autoRotate) {
      rotation.set(rotation.get() - autoRotate * dt);
    }
  });

  const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
    if (reduced) return;
    draggingRef.current = true;
    velocityRef.current = 0;
    lastXRef.current = e.clientX;
    lastTRef.current = performance.now();
    try {
      e.currentTarget.setPointerCapture(e.pointerId);
    } catch {
      // pointer may already be gone (pen lift, synthetic events)
    }
  };
  const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
    if (!draggingRef.current) return;
    const now = performance.now();
    const dx = e.clientX - lastXRef.current;
    const dt = Math.max(1, now - lastTRef.current) / 1000;
    const dDeg = dx * 0.3;
    rotation.set(rotation.get() + dDeg);
    velocityRef.current = dDeg / dt;
    lastXRef.current = e.clientX;
    lastTRef.current = now;
  };
  const endDrag = () => {
    draggingRef.current = false;
    velocityRef.current = Math.max(-480, Math.min(480, velocityRef.current));
  };

  if (reduced || !mounted) {
    return (
      <div
        className={["flex gap-4 overflow-x-auto p-4", className]
          .filter(Boolean)
          .join(" ")}
        style={style}
      >
        {items.map((item, i) => (
          <div key={i} className="shrink-0" style={{ width: cardWidth, height: cardHeight }}>
            {item}
          </div>
        ))}
      </div>
    );
  }

  return (
    <div
      role="group"
      aria-label={`Ring gallery with ${n} cards — drag to spin`}
      onPointerDown={onPointerDown}
      onPointerMove={onPointerMove}
      onPointerUp={endDrag}
      onPointerCancel={endDrag}
      onPointerEnter={() => (hoverRef.current = true)}
      onPointerLeave={() => (hoverRef.current = false)}
      className={[
        "relative flex cursor-grab touch-pan-y items-center justify-center overflow-hidden select-none active:cursor-grabbing",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={{ height: frameHeight, perspective, ...style }}
    >
      <div
        className="relative"
        style={{
          width: cardWidth,
          height: cardHeight,
          transformStyle: "preserve-3d",
        }}
      >
        {items.map((item, i) => (
          <RingCard
            key={i}
            i={i}
            n={n}
            radius={radius}
            rotation={rotation}
            width={cardWidth}
            height={cardHeight}
            segments={segments}
          >
            {item}
          </RingCard>
        ))}
      </div>
    </div>
  );
}

function RingCard({
  i,
  n,
  radius,
  rotation,
  width,
  height,
  segments,
  children,
}: {
  i: number;
  n: number;
  radius: number;
  rotation: MotionValue<number>;
  width: number;
  height: number;
  segments: number;
  children: React.ReactNode;
}) {
  const step = 360 / n;
  const transform = useTransform(
    rotation,
    (r) => `rotateY(${i * step + r}deg) translateZ(${radius}px)`
  );
  // Full cosine remap (no Math.max clamp) so the fade stays smooth all the way
  // around — clamping at 0 leaves a kink at 90° where dimming stops abruptly.
  const opacity = useTransform(rotation, (r) => {
    const a = ((i * step + r) * Math.PI) / 180;
    return 0.18 + 0.82 * (0.5 + 0.5 * Math.cos(a));
  });

  if (segments <= 1) {
    return (
      <motion.div
        className="absolute inset-0"
        style={{ width, height, transform, opacity }}
      >
        {children}
      </motion.div>
    );
  }

  // Curved face: the card is cut into vertical slices, each one rotated to the
  // cylinder's tangent at its own position and pushed back by the sagitta, so
  // the middle bulges toward the viewer and the edges recede into the ring.
  //
  // The depth fade rides a CSS variable down to the slices rather than sitting
  // on the card: an opacity below 1 anywhere up the chain forces
  // transform-style back to flat, which would collapse the whole bend. Each
  // slice stays fully opaque and washes itself toward the page background with
  // a scrim instead — two overlapping translucent slices would blend twice and
  // draw exactly the seam the overlap is there to hide.
  const slice = width / segments;
  // Each slice overhangs its band on *both* sides. WebKit rasterises every
  // rotated slice into its own layer and leaves the edge pixels partly
  // transparent; with the overhang on one side only, two neighbouring fringes
  // land on the same pixel column and the page background shows through as a
  // hairline (Chrome merges the subtree into one surface and hides it). A
  // symmetric bleed puts each fringe over the neighbour's opaque interior —
  // same pixels, so it composites away — and keeps the slice's transform
  // origin on its own tangent point instead of half a pixel off it.
  const bleed = 1;

  return (
    <motion.div
      className="absolute inset-0"
      style={
        {
          width,
          height,
          transform,
          transformStyle: "preserve-3d",
          "--ring-fade": opacity,
        } as React.ComponentProps<typeof motion.div>["style"]
      }
    >
      <div
        aria-hidden="true"
        className="absolute inset-0"
        style={{ transformStyle: "preserve-3d" }}
      >
        {Array.from({ length: segments }, (_, s) => {
          const cx = -width / 2 + (s + 0.5) * slice; // slice centre, card space
          const a = cx / radius; // its angle around the cylinder, in radians
          const dx = radius * Math.sin(a) - cx;
          const dz = radius * Math.cos(a) - radius; // negative — edges recede
          const left = s * slice - bleed;
          return (
            <div
              key={s}
              className="absolute top-0 h-full overflow-hidden"
              style={{
                left,
                width: slice + bleed * 2,
                transform: `translateX(${dx}px) translateZ(${dz}px) rotateY(${a}rad)`,
              }}
            >
              <div
                className="absolute top-0 h-full"
                style={{ left: -left, width }}
              >
                {children}
              </div>
              <div
                className="pointer-events-none absolute inset-0"
                style={{
                  background:
                    "rgba(var(--background-rgb), calc(1 - var(--ring-fade)))",
                }}
              />
            </div>
          );
        })}
      </div>
      {/* The slices repeat the content, so give assistive tech one clean copy. */}
      <div className="sr-only">{children}</div>
    </motion.div>
  );
}

export default RingGallery;

```

## Demo

```tsx
"use client";

import React, { useEffect, useState } from "react";
import { RingGallery } from "../mellow/ring-gallery";

export const RING_GALLERY_PLATES = [
  { num: "01", word: "Aurora", img: "https://picsum.photos/id/10/400/520" },
  { num: "02", word: "Basalt", img: "https://picsum.photos/id/11/400/520" },
  { num: "03", word: "Cinder", img: "https://picsum.photos/id/12/400/520" },
  { num: "04", word: "Drift", img: "https://picsum.photos/id/13/400/520" },
  { num: "05", word: "Ember", img: "https://picsum.photos/id/14/400/520" },
  { num: "06", word: "Fathom", img: "https://picsum.photos/id/15/400/520" },
  { num: "07", word: "Gossamer", img: "https://picsum.photos/id/16/400/520" },
  { num: "08", word: "Harbor", img: "https://picsum.photos/id/17/400/520" },
];

/**
 * One card face standing on the ring. Exported so the Lab renders the exact
 * same card as this demo.
 */
export function RingPlate({ p }: { p: (typeof RING_GALLERY_PLATES)[number] }) {
  return (
    <div className="relative flex h-full w-full flex-col justify-between overflow-hidden rounded-lg border border-[var(--rule)] p-4">
      <div
        aria-hidden="true"
        className="absolute inset-0 bg-cover bg-center brightness-110 saturate-125"
        style={{ backgroundImage: `url(${p.img})` }}
      />
      {/* dark tint — keeps the overlaid text readable over any photo */}
      <div
        aria-hidden="true"
        className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/25 to-black/40"
      />
      <span className="relative [font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-white/85 uppercase">
        Plate {p.num}
      </span>
      <div className="relative">
        <div className="[font-family:var(--font-serif)] text-3xl text-white italic [text-shadow:0_1px_12px_rgba(0,0,0,0.5)]">
          {p.word}
        </div>
        <div className="mt-1 h-px w-8 bg-white/50" />
      </div>
    </div>
  );
}


/** The docs preview box is ~340px wide on phones — shrink to fit it. */
function useNarrow() {
  const [narrow, setNarrow] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(max-width: 640px)");
    const sync = () => setNarrow(mq.matches);
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);
  return narrow;
}

export default function RingGalleryDemo() {
  const narrow = useNarrow();

  return (
    <div className="flex w-full flex-col items-center gap-2 py-3 sm:py-6">
      <RingGallery
        cardWidth={narrow ? 148 : 200}
        cardHeight={narrow ? 185 : 250}
        className="w-full"
        items={RING_GALLERY_PLATES.map((p) => (
          <RingPlate key={p.num} p={p} />
        ))}
      />
      <p className="text-[0.8125rem] text-[rgba(var(--ink-rgb),0.35)]">
        Drag to spin · release to coast
      </p>
    </div>
  );
}

```
