For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
0
Sponsor

Cover Fan

A fan of cover cards for an empty state: full-bleed art, the title sitting on it, and enough of every card left showing that the whole set can be read in one look. Hover lifts a card out of the fan; click starts it.

Pick a cover to start

"use client";

import { useState } from "react";

Installation

$ pnpm dlx shadcn@latest add https://motif-ui.vercel.app/r/cover-fan.json

Usage

import { CoverFan } from "@/components/ui/cover-fan";
 
const PRESETS = [
  {
    id: "unbound-bloom",
    src: "/covers/unbound-bloom.webp",
    title: "Unbound Bloom",
  },
  { id: "refraction", src: "/covers/refraction.webp", title: "Refraction" },
  // …
];
 
export function EmptyState() {
  return (
    <CoverFan items={PRESETS} onSelect={(preset) => startSession(preset.id)} />
  );
}

items is { id, title, src, alt? } per cover. title is the card's label and its accessible name; alt is only for art that carries meaning a title does not, so most covers leave it out. onSelect is required — a fan of covers that cannot be picked is a screenshot.

Anatomy

The card is the cover. There is no body, no padding and no button inside it: full-bleed art, the title on top of it bottom-left, and one line of type that truncates rather than wrapping. Anything more turns a fan of images back into a list of rows.

Four numbers place the hand, all as shares of the card:

ShareReference card (200 × 267)What it does
0.76 pitch152pxLeaves a quarter of every cover showing
drift 0.067 / 0.127 / 0.015 / 0.05218 / 34 / 4 / 14pxUneven, on purpose — middles sit lower than the ends
2.5° tilt±3.75° at four cardsTurns with distance from the middle, so the fan opens
0.067 lift18pxHow far a card rises out of the fan, growing 6%

The drift wraps for fans longer than four. A repeating rhythm beats one deep arc there — and beats numbers that keep growing with the count until a card is pushed out of the box.

The pitch is the load-bearing number. Anything much under 0.76 and the covers stop being readable, which defeats the point of choosing between them; anything much over it and the fan is a row.

Sizing

There is no size prop. The fan is a container query context and every dimension inside it — card width, pitch, drift, radius, title size — is a share of the container's width, so the container decides the size and the whole thing scales together:

<CoverFan className="max-w-xl" items={PRESETS} onSelect={pick} />

The fan needs as many cards across as it holds, plus a gutter for the tilted corners: four covers want about 3.5 card widths. The container reserves that automatically, so the usual move is a max-w-* on the component or on the column that holds it.

Pick a cover to start

The title has an 11px floor. Below roughly a 150px card the reference ratio stops being a label and starts being a caption squeezed into a corner.

States

StateRenders
RestThe fan, left to right, each card overlapping the one before it
LiftedOn hover or focus: up 0.067 of its height, tilt straightened to 0, 6% larger, to the front of the fan
Keyboard focusThe same lift, plus a focus ring — the lift alone is not a focus indicator

Hover is a lift rather than a scale on its own because a card that only grows stays inside the fan and loses which card the pointer is on. Straightening the tilt is what makes the lifted card read as taken out of the hand.

One index drives both hover and focus, so two cards can never be out of the fan at once. A lifted card is always in front of its neighbours, which is also why hover raises z-index instead of relying on scale alone.

Taps work: on touch the lift arrives with the tap and the selection follows it. Motion is CSS-only and drops to none under prefers-reduced-motion; the lift still happens, it just stops being animated.

In an empty state

Three to five covers is the range. Fewer is not a fan — one or two cards read as a carousel with nothing in it — and more than five makes every cover too small to tell apart at the pitch that keeps them readable. Past that, the fan is the wrong shape and a grid is the honest one.

The fan does not animate itself. An empty state that shuffles its own options turns the one moment where the user is deciding into decoration, and it moves the target while the pointer is on its way to it.

Component source

cover-fan.tsx
"use client";

import { useState } from "react";

import { cn } from "@/lib/utils";

/** One cover in the fan. */
export interface CoverFanItem {
  /** Describes the cover for assistive tech. Leave it out when the art is decorative —
   *  the title already names the card. */
  alt?: string;
  id: string;
  /** Cover art. Rendered `object-cover` into a 3:4 box, so any aspect ratio works. */
  src: string;
  /** Overlaid on the cover, bottom-left, one line. Longer titles are cut with an
   *  ellipsis rather than wrapped: the cover is the card, and a second line of type
   *  eats the thing being chosen. */
  title: string;
}

export interface CoverFanProps {
  className?: string;
  items: CoverFanItem[];
  /** Accessible name for the fan. */
  label?: string;
  onSelect: (item: CoverFanItem, index: number) => void;
}

/* -- The hand -----------------------------------------------------------------
 * A fan of covers is laid, not computed. The pitch leaves a quarter of every cover
 * showing — otherwise the fan is only a stack, and the covers are the whole content.
 * The drift is uneven on purpose: middles sitting lower than the ends is what reads as
 * placed by hand instead of as a card row with a rotation applied. The tilt turns with
 * the distance from the middle, so the fan opens as it spreads.
 *
 * Every number here is a share of the 200 × 267 reference card — where the fan was
 * drawn — and the card itself is a share of the container. Nothing in the component is
 * a fixed pixel size, so one width on the container scales the whole thing: the covers,
 * the pitch, the drift, the type.
 * --------------------------------------------------------------------------- */

/** Pitch between two cards, as a share of the card width. */
const STEP = 0.76;

/** Vertical drift per slot, as a share of the card height. It wraps for fans longer
 *  than four, where a repeating rhythm beats one deep arc — and beats numbers that grow
 *  with the count until a card is pushed out of the box. */
const DRIFT = [0.067, 0.127, 0.015, 0.052] as const;

/** Tilt of the outermost card, in degrees. */
const TILT = 2.5;

/** How far a lifted card rises, as a share of the card height, and how much it grows. */
const LIFT = 0.067;
const SCALE = 1.06;

/** Box the fan draws in, as a share of the card width: the deepest card, plus the drift
 *  below it, plus the room a lifted card needs above the top edge. */
const BOX = 1.6;

/** Corner radius, title inset and title size, as shares of the card width. */
const RADIUS = 0.06;
const INSET = 0.08;
const TITLE = 0.075;

/** Title floor, in px. Below roughly a 150px card the reference ratio stops being a
 *  label and starts being a caption squeezed into a corner. */
const TITLE_FLOOR = 11;

/** Room a rotated card needs for its corners, as a share of the fan's own width. */
const TILT_GUTTER = 1.06;

/** How many cards wide the fan is, gutter included. The card is whatever is left of the
 *  container once the fan is divided into that many. */
const fanFactor = (count: number) => ((count - 1) * STEP + 1) * TILT_GUTTER;

/**
 * One transform string per card. The slot decides the offset, the drift and the tilt; a
 * lifted card only changes the parts it has to, so the fan still reads as the same fan
 * while one card is out of it. Percentages resolve against the card's own box, which is
 * why nothing here needs to know how big the card is.
 */
const transformOf = (index: number, count: number, lifted: boolean) => {
  const offset = index - (count - 1) / 2;
  const drift = DRIFT[index % DRIFT.length] ?? 0;
  const x = offset * STEP * 100;
  const y = (lifted ? drift - LIFT : drift) * 100;
  const rotate = lifted ? 0 : offset * TILT;

  return [
    `translateX(calc(-50% ${x < 0 ? "-" : "+"} ${Math.abs(x).toFixed(2)}%))`,
    `translateY(${y.toFixed(2)}%)`,
    `rotate(${rotate.toFixed(2)}deg)`,
    `scale(${lifted ? SCALE : 1})`,
  ].join(" ");
};

export const CoverFan = ({
  className,
  items,
  label = "Starting points",
  onSelect,
}: CoverFanProps) => {
  // One index, not a flag per card: two cards can never be lifted at once, so the fan
  // has exactly one card out of it whatever the pointer and the keyboard do in turn.
  const [lifted, setLifted] = useState<number | null>(null);

  const factor = fanFactor(items.length);
  /** A share of the card width as a share of the container: the fan is `factor` cards
   *  wide, so the card is `1 / factor` of whatever the container gives it. */
  const share = (ratio: number) =>
    `calc(100cqw * ${(ratio / factor).toFixed(4)})`;

  return (
    // The container is the fan's own width, which is the only measurement the geometry
    // needs — hence the wrapper div: an element cannot query itself.
    <div className={cn("@container w-full", className)}>
      <div
        aria-label={label}
        className="relative w-full"
        role="group"
        style={{ height: share(BOX) }}
      >
        {items.map((item, index) => (
          // The card is the cover: no padding, no chrome, only the title sitting on the
          // art. Everything the fan does is one transform, so the browser animates the
          // card instead of re-laying out the row.
          <button
            className={cn(
              "absolute top-0 left-1/2 aspect-[3/4] overflow-hidden bg-muted",
              "cursor-pointer transition-transform duration-[420ms] ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none",
              // Drop shadow and hairline in one declaration: a ring utility would fight
              // the shadow for the same property.
              "shadow-[0_26px_50px_-22px_rgb(0_0_0/0.55),inset_0_0_0_1px_rgb(255_255_255/0.08)]",
              "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"
            )}
            key={item.id}
            onBlur={() => setLifted(null)}
            onClick={() => onSelect(item, index)}
            onFocus={() => setLifted(index)}
            onPointerEnter={() => setLifted(index)}
            onPointerLeave={() =>
              setLifted((current) => (current === index ? null : current))
            }
            style={{
              borderRadius: share(RADIUS),
              transform: transformOf(index, items.length, lifted === index),
              width: share(1),
              // Lifted cards come to the front; at rest the fan reads left to right, so
              // each card overlaps the one before it.
              zIndex: lifted === index ? items.length + 1 : index + 1,
            }}
            type="button"
          >
            {/* eslint-disable-next-line @next/next/no-img-element */}
            <img
              alt={item.alt ?? ""}
              className="absolute inset-0 size-full object-cover"
              src={item.src}
            />
            {/* The scrim belongs to the component, not the cover: a fan has to stay
                legible over whatever art gets dropped into it. */}
            <span
              aria-hidden="true"
              className="absolute inset-x-0 bottom-0 aspect-[16/9] bg-linear-to-t from-black/75 via-black/30 to-transparent"
            />
            <span
              className="absolute truncate text-left font-bold text-white"
              style={{
                bottom: share(INSET * 0.8),
                fontSize: `max(${TITLE_FLOOR}px, ${share(TITLE)})`,
                left: share(INSET),
                right: share(INSET),
              }}
            >
              {item.title}
            </span>
          </button>
        ))}
      </div>
    </div>
  );
};