# Card Fan (`card-fan`)

> A hand of cards fanned in an arc — hover one and it straightens and rises out of the fan while its neighbours part in sympathy.

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

## AI prompt

Add a CardFan component from the mellow library — a hand of cards fanned in an arc — hover one and it straightens and rises out of the fan while its neighbours part in sympathy. Key props: `items`, `width`, `height`, `arc`, `lift`. Copy the file into your project; it is self-contained and respects prefers-reduced-motion.

## Install

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

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `ReactNode[]` | — | Card faces, left of the fan first. |
| `width` | `number` | `190` | Card size in px. |
| `height` | `number` | `264` | Card height in px. |
| `arc` | `number` | `52` | Total fan angle in degrees. |
| `lift` | `number` | `56` | How far a hovered card rises out of the fan, in px. |
| `onSelect` | `(index: number) => void` | — | Called with the card index on click or Enter. |
| `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, { useState } from "react";
import { motion, useReducedMotion } from "motion/react";

export interface CardFanProps {
  /** Card faces, left of the fan first. */
  items: React.ReactNode[];
  /** Card size in px. */
  width?: number;
  height?: number;
  /** Total fan angle in degrees. */
  arc?: number;
  /** How far a hovered card rises out of the fan, in px. */
  lift?: number;
  /** Called with the card index on click or Enter. */
  onSelect?: (index: number) => void;
  className?: string;
  style?: React.CSSProperties;
}

/**
 * A hand of cards fanned in an arc — hover (or focus) one and it straightens
 * and rises out of the fan while its neighbours part in sympathy, like a
 * card drawn from a held hand.
 */
export function CardFan({
  items,
  width = 190,
  height = 264,
  arc = 52,
  lift = 56,
  onSelect,
  className,
  style,
}: CardFanProps) {
  const [active, setActive] = useState<number | null>(null);
  const reduced = useReducedMotion();
  const n = items.length;

  const radius = height * 1.6;
  const step = n > 1 ? arc / (n - 1) : 0;
  const baseAngle = (i: number) => (i - (n - 1) / 2) * step;
  // pivot sits `radius` below the card's centre
  const origin = `50% ${height / 2 + radius}px`;

  // bounding box of the fanned edge cards, so the container never overflows
  const half = ((arc / 2) * Math.PI) / 180;
  const side =
    Math.sin(half) * radius +
    (width * Math.cos(half) + height * Math.sin(half)) / 2;
  const fanWidth = 2 * (side + 8);
  // how far an edge card's outer bottom corner dips below the resting baseline
  const dip =
    (1 - Math.cos(half)) * (radius - height / 2) +
    Math.sin(half) * (width / 2);
  const fanHeight = height + lift + dip + 8;

  const spring = reduced
    ? ({ duration: 0 } as const)
    : ({ type: "spring", stiffness: 300, damping: 26 } as const);

  return (
    <div
      role="group"
      aria-label={`Fan of ${n} cards`}
      className={["relative", className].filter(Boolean).join(" ")}
      style={{ width: fanWidth, height: fanHeight, ...style }}
      onMouseLeave={() => setActive(null)}
    >
      {items.map((item, i) => {
        const isActive = active === i;
        // neighbours lean away from the drawn card, closest the most
        const push =
          active === null || isActive
            ? 0
            : (Math.sign(i - active) * 7) / Math.abs(i - active);

        return (
          /* outer owns the fan slot and the pointer target — it never moves
             on hover, so the lifted card can't slip out from under the cursor */
          <motion.div
            key={i}
            role="button"
            tabIndex={0}
            aria-pressed={isActive}
            aria-label={`Card ${i + 1} of ${n}`}
            className="absolute left-1/2 cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-[rgba(var(--ink-rgb),0.35)]"
            initial={reduced ? false : { rotate: 0, opacity: 0 }}
            animate={{ rotate: baseAngle(i), opacity: 1 }}
            transition={{ ...spring, delay: reduced ? 0 : i * 0.05 }}
            onMouseEnter={() => setActive(i)}
            onFocus={() => setActive(i)}
            onBlur={() => setActive(null)}
            onClick={() => onSelect?.(i)}
            onKeyDown={(e) => {
              if (e.key === "Enter" || e.key === " ") {
                e.preventDefault();
                onSelect?.(i);
              }
            }}
            style={{
              width,
              height,
              bottom: dip,
              marginLeft: -width / 2,
              transformOrigin: origin,
              zIndex: isActive ? n + 1 : i,
            }}
          >
            {/* inner owns the gesture — straightens in place and lifts */}
            <motion.div
              animate={{
                rotate: isActive ? -baseAngle(i) * 0.65 : push,
                x: isActive ? 0 : push * 2.4,
                y: isActive ? -lift : 0,
              }}
              transition={spring}
              className="h-full w-full overflow-hidden rounded-xl border border-[var(--rule)] bg-[var(--background)] shadow-[0_20px_44px_-22px_rgba(0,0,0,0.5)]"
            >
              {item}
            </motion.div>
          </motion.div>
        );
      })}
    </div>
  );
}

export default CardFan;

```

## Demo

```tsx
"use client";

import React, { useEffect, useState } from "react";
import { CardFan } from "../mellow/card-fan";

const CARDS = [
  { numeral: "I", name: "The Studio", image: "https://picsum.photos/id/1084/380/400" },
  { numeral: "II", name: "The Voyage", image: "https://picsum.photos/id/1036/380/400" },
  { numeral: "III", name: "The Archive", image: "https://picsum.photos/id/1050/380/400" },
  { numeral: "IV", name: "The Harbour", image: "https://picsum.photos/id/1053/380/400" },
  { numeral: "V", name: "The Season", image: "https://picsum.photos/id/1044/380/400" },
];


/** 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 CardFanDemo() {
  const [drawn, setDrawn] = useState<number | null>(null);
  const narrow = useNarrow();

  return (
    <div className="flex flex-col items-center gap-3 px-3 pt-6 pb-4 sm:gap-5 sm:px-6 sm:pt-12 sm:pb-8">
      <CardFan
        width={narrow ? 100 : 164}
        height={narrow ? 140 : 232}
        arc={narrow ? 34 : 52}
        onSelect={setDrawn}
        items={CARDS.map((c) => (
          <div
            key={c.numeral}
            className="flex h-full w-full flex-col p-3 select-none"
          >
            <div className="flex items-baseline justify-between pb-2 [font-family:var(--font-serif)] text-sm text-[var(--ink)] italic">
              <span>{c.numeral}</span>
              <span className="[font-family:var(--font-mono)] text-[0.5rem] not-italic tracking-[0.16em] text-[rgba(var(--ink-rgb),0.4)] uppercase">
                Mellow
              </span>
            </div>
            <div className="relative min-h-0 flex-1 overflow-hidden rounded-lg bg-[rgba(var(--ink-rgb),0.06)]">
              <img
                src={c.image}
                alt={c.name}
                draggable={false}
                className="absolute inset-0 h-full w-full object-cover saturate-[0.85] contrast-[1.05]"
              />
            </div>
            <div className="pt-3 pb-1 text-center [font-family:var(--font-serif)] text-base text-[var(--ink)] italic">
              {c.name}
            </div>
          </div>
        ))}
      />
      <p className="[font-family:var(--font-mono)] text-[0.5625rem] tracking-[0.12em] text-[rgba(var(--ink-rgb),0.35)] uppercase">
        {drawn !== null
          ? `Drawn — ${CARDS[drawn].numeral} · ${CARDS[drawn].name}`
          : "Hover to draw a card · click to keep it"}
      </p>
    </div>
  );
}

```
