# Splay Cards (`splay-cards`)

> A leaning rack of 3D plates — hover one and the stack splays open, the chosen plate turning to face you while its title takes over the caption line.

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

## AI prompt

Add a SplayCards component from the mellow library — a leaning rack of 3D plates that splays open on hover, the active plate turning to face the viewer while its title takes the caption line. Pass `items` as `{ title, meta?, content }[]` and a fallback `heading`. Tune `width`, `height`, `spacing`, `splay` and `lean`.

## Install

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

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `SplayCardsItem[]` | — | Plates in the rack. Each item is `{ title, meta?, content }`. |
| `heading` | `string` | — | Caption line shown while nothing is active. |
| `width` | `number` | `230` | Plate width in px. |
| `height` | `number` | `330` | Plate height in px. |
| `spacing` | `number` | `56` | Horizontal offset between resting plates in px. |
| `splay` | `number` | `96` | How far the rear plates slide when the stack parts, in px. |
| `lean` | `number` | `46` | Resting lean of each plate in degrees. |
| `onActiveChange` | `(index: number \| null) => void` | — | Fired when the active plate changes. |
| `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, { useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

export interface SplayCardsItem {
  /** Caption headline shown while this plate is active. */
  title: string;
  /** Mono kicker above the caption title. */
  meta?: string;
  /** Plate face — give it an opaque background if it holds imagery. */
  content: React.ReactNode;
}

export interface SplayCardsProps {
  items: SplayCardsItem[];
  /** Caption line shown while nothing is active. */
  heading?: string;
  /** Plate size in px. */
  width?: number;
  height?: number;
  /** Horizontal offset between resting plates in px. */
  spacing?: number;
  /** How far the rear plates slide when the stack parts, in px. */
  splay?: number;
  /** Resting lean of each plate in degrees. */
  lean?: number;
  onActiveChange?: (index: number | null) => void;
  className?: string;
  style?: React.CSSProperties;
}

/**
 * A leaning rack of 3D plates — hover (or focus) one and the stack splays
 * open: the plates behind slide aside while the chosen plate turns to face
 * you, its title taking over the caption line below.
 */
export function SplayCards({
  items,
  heading,
  width = 230,
  height = 330,
  spacing = 56,
  splay = 96,
  lean = 46,
  onActiveChange,
  className,
  style,
}: SplayCardsProps) {
  const [active, setActive] = useState<number | null>(null);
  const reduced = useReducedMotion();
  const n = items.length;
  const rackRef = useRef<HTMLDivElement>(null);

  const set = (i: number | null) => {
    setActive((prev) => {
      if (prev !== i) onActiveChange?.(i);
      return i;
    });
  };

  // Resolve which plate is under the cursor from its x within the rack, using
  // the *resting* sliver bands. The inner plates move as the stack splays, so
  // hit-testing them directly causes mouseenter/leave storms that leave the
  // springs stuck mid-flight — the rack box never moves, so map against it.
  const pickFromPointer = (clientX: number) => {
    const rect = rackRef.current?.getBoundingClientRect();
    if (!rect) return;
    const x = clientX - rect.left;
    const i = Math.max(0, Math.min(n - 1, Math.floor(x / spacing)));
    if (i !== active) set(i);
  };

  const spring = { type: "spring", stiffness: 280, damping: 30 } as const;

  if (reduced) {
    return (
      <div
        className={["flex flex-wrap justify-center gap-4", className]
          .filter(Boolean)
          .join(" ")}
        style={style}
      >
        {items.map((item, i) => (
          <figure key={i} className="m-0" style={{ width }}>
            <div
              className="overflow-hidden rounded-xl border border-[var(--rule)] bg-[var(--background)]"
              style={{ height }}
            >
              {item.content}
            </div>
            <figcaption className="mt-2 [font-family:var(--font-serif)] text-sm text-[var(--ink)] italic">
              {item.title}
            </figcaption>
          </figure>
        ))}
      </div>
    );
  }

  const activeItem = active !== null ? items[active] : null;
  const rackWidth = width + (n - 1) * spacing;

  return (
    <div
      className={["flex flex-col items-center", className]
        .filter(Boolean)
        .join(" ")}
      style={style}
      onMouseLeave={() => set(null)}
    >
      {/* padded mask wrapper — plates that splay outward stay inside its
          border box, so the bottom fade can't clip them horizontally */}
      <div
        className="[mask-image:linear-gradient(to_bottom,black_66%,transparent_100%)]"
        style={{ padding: `24px ${splay + 32}px 0` }}
      >
        <div
          ref={rackRef}
          role="group"
          aria-label={heading ?? "Plate rack"}
          className="relative"
          style={{ width: rackWidth, height, perspective: 1100 }}
          onMouseMove={(e) => pickFromPointer(e.clientX)}
        >
        {items.map((item, i) => {
          const isActive = active === i;
          const behind = active !== null && i > active;
          return (
            /* outer owns the entrance slot */
            <motion.div
              key={i}
              className="absolute top-0"
              /* Transform only — never opacity. An opacity below 1 is a
                 grouping property, and grouping forces this element to flatten
                 regardless of `transformStyle`. While it flattened, the inner
                 plate's rotateY lost the rack's perspective and drew flat;
                 the instant the fade hit exactly 1, preserve-3d re-engaged and
                 the projection changed in a single frame — the entrance ended
                 on a snap. The fade lives on the inner plate instead. */
              initial={{ x: -i * spacing * 0.85 }}
              animate={{ x: 0 }}
              transition={{ ...spring, delay: (n - 1 - i) * 0.07 }}
              style={{
                left: i * spacing,
                width,
                height,
                zIndex: isActive ? n + 1 : i,
                transformStyle: "preserve-3d",
              }}
            >
              {/* inner owns the gesture */}
              <motion.div
                role="button"
                tabIndex={0}
                aria-pressed={isActive}
                aria-label={`${item.title}${item.meta ? ` — ${item.meta}` : ""}`}
                /* Without an explicit initial the first paint carries no
                   transform at all, so the rack renders flat and then snaps to
                   the lean on the next frame. Starting at the resting lean
                   means the cards slide in already turned — the entrance is
                   the outer slot's glide, and nothing rotates on mount. */
                initial={{ x: 0, z: 0, rotateY: lean, opacity: 0 }}
                animate={{
                  x: behind ? splay : 0,
                  z: isActive ? 56 : 0,
                  rotateY: isActive ? lean * 0.12 : lean,
                  opacity: 1,
                }}
                /* The entrance fade rides here, on its own tween keyed to the
                   slot's stagger. Fading this element only flattens its own
                   (2D) children, so the plate keeps its perspective the whole
                   way in. Hover never re-runs it — opacity stays at 1. */
                transition={{
                  ...spring,
                  opacity: { duration: 0.32, delay: (n - 1 - i) * 0.07 },
                }}
                onFocus={() => set(i)}
                onBlur={() => set(null)}
                onKeyDown={(e) => {
                  if (e.key === "Enter" || e.key === " ") {
                    e.preventDefault();
                    set(isActive ? null : i);
                  }
                }}
                className="relative h-full w-full cursor-pointer overflow-hidden rounded-xl border border-[var(--rule)] bg-[var(--background)] shadow-[0_28px_56px_-28px_rgba(0,0,0,0.5)] outline-none focus-visible:ring-2 focus-visible:ring-[rgba(var(--ink-rgb),0.35)]"
                style={{ transformOrigin: "left center", transformStyle: "preserve-3d" }}
              >
                {item.content}
                {/* leaning-edge shade — clears when the plate faces you */}
                <motion.div
                  aria-hidden="true"
                  animate={{ opacity: isActive ? 0 : 1 }}
                  transition={{ duration: 0.3 }}
                  className="pointer-events-none absolute inset-0 bg-[linear-gradient(to_right,transparent_30%,rgba(var(--background-rgb),0.55)_100%)]"
                />
              </motion.div>
            </motion.div>
          );
        })}
        </div>
      </div>

      <div className="relative mt-5 flex h-12 w-full flex-col items-center justify-center text-center">
        <AnimatePresence mode="wait" initial={false}>
          {activeItem ? (
            <motion.div
              key={active}
              initial={{ opacity: 0, y: 6 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: -6 }}
              transition={{ duration: 0.18 }}
            >
              {activeItem.meta && (
                <div className="[font-family:var(--font-mono)] text-[0.5625rem] font-medium tracking-[0.16em] text-[rgba(var(--ink-rgb),0.45)] uppercase">
                  {activeItem.meta}
                </div>
              )}
              <div className="[font-family:var(--font-serif)] text-xl leading-tight text-[var(--ink)] italic">
                {activeItem.title}
              </div>
            </motion.div>
          ) : (
            heading && (
              <motion.div
                key="heading"
                initial={{ opacity: 0, y: 6 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: -6 }}
                transition={{ duration: 0.18 }}
                className="[font-family:var(--font-serif)] text-xl leading-tight text-[rgba(var(--ink-rgb),0.4)] italic"
              >
                {heading}
              </motion.div>
            )
          )}
        </AnimatePresence>
      </div>
    </div>
  );
}

export default SplayCards;

```

## Demo

```tsx
"use client";

import React, { useEffect, useState } from "react";
import { SplayCards } from "../mellow/splay-cards";

const PLATES = [
  {
    title: "The printing room",
    meta: "Antwerp",
    image: "https://picsum.photos/id/1067/460/660",
  },
  {
    title: "Studies in grain",
    meta: "Kyoto",
    image: "https://picsum.photos/id/1080/460/660",
  },
  {
    title: "A measured coast",
    meta: "Bergen",
    image: "https://picsum.photos/id/1039/460/660",
  },
  {
    title: "Night edition",
    meta: "Turin",
    image: "https://picsum.photos/id/1069/460/660",
  },
  {
    title: "The last proof",
    meta: "Porto",
    image: "https://picsum.photos/id/1041/460/660",
  },
];

/** The plate faces, built once — exported so the Lab shows the same rack. */
export const SPLAY_CARDS_ITEMS = PLATES.map((p, i) => ({
  title: p.title,
  meta: p.meta,
  content: (
    <div className="relative h-full w-full select-none">
      <img
        src={p.image}
        alt={p.title}
        draggable={false}
        className="absolute inset-0 h-full w-full object-cover saturate-[0.85] contrast-[1.05]"
      />
      <div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(var(--background-rgb),0.55),transparent_35%)]" />
      <div className="absolute inset-x-0 top-0 flex items-center justify-between p-3 [font-family:var(--font-mono)] text-[0.5625rem] font-medium tracking-[0.16em] text-[var(--ink)] uppercase">
        <span>{String(i + 1).padStart(2, "0")}</span>
        <span>Archive</span>
      </div>
    </div>
  ),
}));

/**
 * The docs preview box is ~340px wide on phones — shrink to fit it. Null until
 * the query has been read: mounting at the desktop size and correcting a frame
 * later restarts the rack's entrance mid-flight and snaps the lean.
 */
function useNarrow() {
  const [narrow, setNarrow] = useState<boolean | null>(null);
  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 SplayCardsDemo() {
  const narrow = useNarrow();

  return (
    <div className="flex flex-col items-center gap-3 px-3 py-5 sm:gap-6 sm:px-6 sm:py-10">
      {narrow !== null && (
        <SplayCards
          heading="Selected cards, 2024 — 2026"
          width={narrow ? 120 : 230}
          height={narrow ? 172 : 330}
          spacing={narrow ? 18 : 56}
          splay={narrow ? 20 : 96}
          lean={narrow ? 32 : 46}
          items={SPLAY_CARDS_ITEMS}
        />
      )}
      <p className="[font-family:var(--font-mono)] text-[0.5625rem] tracking-[0.12em] text-[rgba(var(--ink-rgb),0.35)] uppercase">
        Hover a card · the rack splays open
      </p>
    </div>
  );
}

```
