# Toss Deck (`toss-deck`)

> A card deck with weight — drag the top card past the threshold and it flies off with a spin, then slips back under the pile.

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

## AI prompt

Add a TossDeck component from the mellow library — a draggable card stack for testimonials or portfolios. The top card follows the cursor with a tilt; drag it past a third of its width (or flick it, or press an arrow key) and it flies off with a spin, then restacks at the back with springs. Pass `items` as an array of ReactNode card faces and size them with `width` / `height`. `peek` controls how many cards fan out behind, `counter` shows the 01 / 05 readout, `onChange` reports the index that lands on top. Fully keyboard accessible and respects prefers-reduced-motion.

## Install

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

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `ReactNode[]` | — | Card faces, front of deck first. |
| `width` | `number` | `320` | Card width in px. |
| `height` | `number` | `420` | Card height in px. |
| `peek` | `number` | `2` | Cards visible behind the top card. |
| `counter` | `boolean` | `true` | Show the 01 / 05 counter under the deck. |
| `onChange` | `(index: number) => void` | — | Called with the item index that lands on top after a toss. |
| `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,
  useMotionValue,
  useReducedMotion,
  useTransform,
  type PanInfo,
} from "motion/react";

export interface TossDeckProps {
  /** Card faces, front of deck first. */
  items: React.ReactNode[];
  /** Card size in px. */
  width?: number;
  height?: number;
  /** Cards visible behind the top card. */
  peek?: number;
  /** Show the 01 / 05 counter under the deck. */
  counter?: boolean;
  /** Called with the item index that lands on top after a toss. */
  onChange?: (index: number) => void;
  className?: string;
  style?: React.CSSProperties;
}

/**
 * A card deck with weight — drag the top card past the threshold (or press
 * an arrow key) and it flies off with a spin, then slips back under the pile.
 */
export function TossDeck({
  items,
  width = 320,
  height = 420,
  peek = 2,
  counter = true,
  onChange,
  className,
  style,
}: TossDeckProps) {
  const [order, setOrder] = useState(() => items.map((_, i) => i));
  const [fly, setFly] = useState<{ key: number; dir: 1 | -1 } | null>(null);
  const reduced = useReducedMotion();
  const n = items.length;

  const toss = (dir: 1 | -1) => {
    if (fly || n < 2) return;
    setFly({ key: order[0], dir });
  };

  const completeToss = () => {
    const next = [...order.slice(1), order[0]];
    setOrder(next);
    setFly(null);
    onChange?.(next[0]);
  };

  return (
    <div
      className={["flex flex-col items-center gap-5", className]
        .filter(Boolean)
        .join(" ")}
      style={style}
    >
      <div
        role="group"
        aria-roledescription="card deck"
        aria-label={`Card ${order[0] + 1} of ${n} — drag the card or use arrow keys`}
        tabIndex={0}
        onKeyDown={(e) => {
          if (e.key === "ArrowLeft") {
            e.preventDefault();
            toss(-1);
          }
          if (e.key === "ArrowRight") {
            e.preventDefault();
            toss(1);
          }
        }}
        className="relative outline-none focus-visible:ring-2 focus-visible:ring-[rgba(var(--ink-rgb),0.3)]"
        style={{ width, height: height + peek * 10 }}
      >
        {order.map((itemIdx, pos) => (
          <DeckCard
            key={itemIdx}
            pos={pos}
            n={n}
            peek={peek}
            width={width}
            height={height}
            flying={fly?.key === itemIdx ? fly.dir : 0}
            isTop={pos === 0 && !fly}
            onToss={toss}
            onFlown={completeToss}
            reduced={!!reduced}
          >
            {items[itemIdx]}
          </DeckCard>
        ))}
      </div>
      {counter && (
        <span className="[font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-[rgba(var(--ink-rgb),0.4)] uppercase tabular-nums">
          {String(order[0] + 1).padStart(2, "0")} /{" "}
          {String(n).padStart(2, "0")}
        </span>
      )}
    </div>
  );
}

function DeckCard({
  children,
  pos,
  n,
  peek,
  width,
  height,
  flying,
  isTop,
  onToss,
  onFlown,
  reduced,
}: {
  children: React.ReactNode;
  pos: number;
  n: number;
  peek: number;
  width: number;
  height: number;
  flying: 0 | 1 | -1;
  isTop: boolean;
  onToss: (dir: 1 | -1) => void;
  onFlown: () => void;
  reduced: boolean;
}) {
  const x = useMotionValue(0);
  const dragRotate = useTransform(
    x,
    [-width * 0.5, width * 0.5],
    [-5, 5],
  );

  // Cards deeper than `peek` park exactly under the last visible slot instead
  // of fading out, so a tossed card never vanishes — it just tucks under.
  const depth = Math.min(pos, peek);
  const fan = depth === 0 ? 0 : (depth % 2 === 1 ? 1 : -1) * depth * 1.8;
  const stackX = depth === 0 ? 0 : (depth % 2 === 1 ? -1 : 1) * depth * 6;
  const stackY = depth * 10 + (depth % 2 === 1 ? 3 : 0);
  const slotSpring = reduced
    ? ({ duration: 0 } as const)
    : ({ type: "spring", stiffness: 360, damping: 34, mass: 0.8 } as const);

  const handleDragEnd = (_: unknown, info: PanInfo) => {
    const past =
      Math.abs(info.offset.x) > width * 0.24 ||
      Math.abs(info.velocity.x) > 500;
    if (!past) return;
    const dir =
      Math.abs(info.velocity.x) > 500
        ? Math.sign(info.velocity.x)
        : Math.sign(info.offset.x);
    onToss(dir >= 0 ? 1 : -1);
  };

  return (
    <motion.div
      animate={{
        scale: 1 - depth * 0.025,
        x: stackX,
        y: stackY,
        rotate: fan,
      }}
      transition={slotSpring}
      className="absolute inset-x-0 top-0"
      style={{ height, zIndex: flying !== 0 ? n + 1 : n - pos }}
    >
      <motion.div
        drag={isTop && !reduced ? "x" : false}
        dragConstraints={{ left: -width * 0.52, right: width * 0.52 }}
        dragElastic={0.12}
        dragMomentum={false}
        dragSnapToOrigin
        onDragEnd={isTop ? handleDragEnd : undefined}
        animate={
          flying !== 0
            ? { x: flying * (width * 0.92), y: -10 }
            : { x: 0, y: 0 }
        }
        transition={
          flying !== 0
            ? {
                duration: reduced ? 0 : 0.26,
                ease: [0.3, 0.8, 0.4, 1],
              }
            : slotSpring
        }
        onAnimationComplete={() => {
          if (flying !== 0) onFlown();
        }}
        style={{ x, rotate: dragRotate }}
        className={[
          "relative h-full w-full overflow-hidden rounded-[1.25rem] border border-[var(--rule)] bg-[var(--background)] shadow-[0_24px_50px_-24px_color-mix(in_srgb,light-dark(var(--ink),var(--background))_42%,transparent)]",
          isTop ? "cursor-grab active:cursor-grabbing" : "",
        ]
          .filter(Boolean)
          .join(" ")}
      >
        <div
          aria-hidden="true"
          className="pointer-events-none absolute inset-0 bg-[linear-gradient(145deg,rgba(var(--background-rgb),0)_55%,rgba(var(--ink-rgb),0.045))]"
        />
        {children}
      </motion.div>
    </motion.div>
  );
}

export default TossDeck;

```

## Demo

```tsx
"use client";

import React from "react";
import { TossDeck } from "../mellow/toss-deck";

const CARDS = [
  {
    title: "Study in balance",
    discipline: "Direction",
    caption: "Weight distributed evenly across the frame.",
    meta: "Plate I · Bergen",
    image: "https://picsum.photos/id/1048/640/432",
  },
  {
    title: "The quiet studio",
    discipline: "Studio",
    caption: "Morning light on an unhurried bench.",
    meta: "Plate II · Copenhagen",
    image: "https://picsum.photos/id/1060/640/432",
  },
  {
    title: "Measured movement",
    discipline: "Motion",
    caption: "Velocity held at the edge of stillness.",
    meta: "Plate III · Zürich",
    image: "https://picsum.photos/id/338/640/432",
  },
  {
    title: "Material language",
    discipline: "Identity",
    caption: "Grain, pressure, and the record of touch.",
    meta: "Plate IV · Milan",
    image: "https://picsum.photos/id/225/640/432",
  },
  {
    title: "A gathering of forms",
    discipline: "Culture",
    caption: "Objects arranged as a single sentence.",
    meta: "Plate V · Lisbon",
    image: "https://picsum.photos/id/537/640/432",
  },
];

export default function TossDeckDemo() {
  return (
    <div className="flex flex-col items-center gap-6 px-6 py-10">
      <TossDeck
        width={320}
        height={440}
        items={CARDS.map((c, index) => (
          <figure
            key={c.title}
            className="flex h-full select-none flex-col p-3"
          >
            <div className="relative min-h-0 flex-1 overflow-hidden rounded-[0.8rem] bg-[rgba(var(--ink-rgb),0.06)]">
              <img
                src={c.image}
                alt={c.title}
                draggable={false}
                className="absolute inset-0 h-full w-full scale-[1.04] object-cover saturate-[0.88] contrast-[1.04]"
              />
              <div className="absolute inset-0 bg-[linear-gradient(180deg,rgba(var(--background-rgb),0.02),rgba(var(--ink-rgb),0.28))]" />
              <div
                aria-hidden="true"
                className="absolute inset-x-0 top-0 h-14 bg-[linear-gradient(180deg,rgba(var(--background-rgb),0.78),transparent)]"
              />
              <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>{c.discipline}</span>
                <span>{String(index + 1).padStart(2, "0")}</span>
              </div>
            </div>

            <figcaption className="flex shrink-0 flex-col gap-2 px-2 pt-4 pb-1">
              <div className="[font-family:var(--font-serif)] text-[1.35rem] leading-[1.08] text-[var(--ink)] italic">
                {c.title}
              </div>
              <p className="m-0 [font-family:var(--font-sans)] text-[0.8125rem] leading-relaxed text-[rgba(var(--ink-rgb),0.58)]">
                {c.caption}
              </p>
              <div className="mt-1 border-t border-[var(--rule)] pt-3 [font-family:var(--font-mono)] text-[0.5rem] tracking-[0.14em] text-[rgba(var(--ink-rgb),0.42)] uppercase">
                {c.meta}
              </div>
            </figcaption>
          </figure>
        ))}
      />
      <p className="[font-family:var(--font-mono)] text-[0.5625rem] tracking-[0.12em] text-[rgba(var(--ink-rgb),0.35)] uppercase">
        Drag to toss · arrow keys to browse
      </p>
    </div>
  );
}

```
