Card Fan
A stack of cards that fans open on hover. The card you point at comes forward, turns level and rises, and the others darken. For a collection and a peek at what is inside it.
Installation
$
npx shadcn@latest add https://ui.mattdowney.com/r/card-fan.jsonUsage
import { CardFan } from "@/components/motion/card-fan"Examples
Default
Three covers fanned sideways. Point at one and it comes to the front. Each card is a link through render; without it a card is a button. variant="image" gives a photo one edge, a hairline inside it.
One link
The whole tile is the link and the covers are decoration. Mark the link data-card-fan-trigger and pass decorative: hovering anywhere on it, or tabbing to it, fans the covers, and no cover lifts or takes a click.
API Reference
| Prop | Type | Default | Description |
|---|---|---|---|
| slots | CardFanSlot[] | three sideways cards | One entry per card, back to front. Each sets its resting pose (cx, cy, rot), how far it moves when the fan opens (dx, dy, drot), its content, its label, and an optional render. |
| label | string | "Cards" | Accessible name for the whole fan. |
| variant | "default" | "image" | "default" | image when every card is a picture. The card drops its outside ring for a 1px hairline inside its edge and keeps its shadow, so a photo never carries two lines. |
| decorative | boolean | false | The covers decorate a larger link. They take no pointer, focus or name, and the fan opens when the nearest data-card-fan-trigger ancestor is hovered or has keyboard focus. |
| renderCardFanSlot | ReactElement | - | Renders the card as another element, most often a link: <Link href="/posts/one" />. |
| labelCardFanSlot | string | - | Accessible name for one card. Pass a real one, like the title of the page it opens. |
slotsCardFanSlot[] = three sideways cardsOne entry per card, back to front. Each sets its resting pose (cx, cy, rot), how far it moves when the fan opens (dx, dy, drot), its content, its label, and an optional render. |
| labelstring = "Cards"Accessible name for the whole fan. |
variant"default" | "image" = "default"image when every card is a picture. The card drops its outside ring for a 1px hairline inside its edge and keeps its shadow, so a photo never carries two lines. |
decorativeboolean = falseThe covers decorate a larger link. They take no pointer, focus or name, and the fan opens when the nearest data-card-fan-trigger ancestor is hovered or has keyboard focus. |
renderCardFanSlotReactElementRenders the card as another element, most often a link: <Link href="/posts/one" />. |
| labelCardFanSlotstringAccessible name for one card. Pass a real one, like the title of the page it opens. |
"use client"
import type { CSSProperties, ReactElement, ReactNode } from "react";
import { useRender } from "@base-ui/react/use-render";
import "./card-fan.css"
export type CardFanSlot = {
/** Resting pose */
cx: string; cy: string; rot: string;
/** Fan deltas applied on container hover */
dx: string; dy: string; drot: string;
content?: ReactNode;
/** Accessible name for the card. Pass a real label. */
label?: string;
/** Renders the card as something else, most often a link to the thing the
* card stands for: `render: <Link href="/posts/one" />`. Without it the card
* is a button, for a fan whose cards act in place. */
render?: ReactElement;
};
/** Three portrait cards fanned sideways in the default 180px box: left,
* right, then the front card last so it sits on top. */
const DEFAULT_SLOTS: CardFanSlot[] = [
{ label: "Card 1", cx: "18px", cy: "26px", rot: "-9deg", dx: "-18px", dy: "-4px", drot: "-5deg" },
{ label: "Card 2", cx: "66px", cy: "26px", rot: "9deg", dx: "18px", dy: "-4px", drot: "5deg" },
{ label: "Card 3", cx: "42px", cy: "22px", rot: "0deg", dx: "0px", dy: "-10px", drot: "0deg" },
];
function CardFanCard({
slot,
index,
decorative,
}: {
slot: CardFanSlot;
index: number;
decorative: boolean;
}) {
return useRender({
// A decorative card is a plain box: the link or button is the trigger
// around the fan, so a card of its own would be a second target.
render: decorative ? <span /> : (slot.render ?? <button type="button" />),
props: {
className: "motion-card-fan-card",
"aria-label": decorative ? undefined : slot.label,
style: {
"--cx": slot.cx, "--cy": slot.cy, "--rot": slot.rot,
"--dx": slot.dx, "--dy": slot.dy, "--drot": slot.drot,
// The stacking order rides a variable, never an inline z-index: an
// inline value outranks the stylesheet, so a hovered or focused card
// could never come to the front.
"--z": index,
} as CSSProperties,
children: slot.content,
},
});
}
// @use-when a stack of cards that fans out on hover: templates, a collection,
// stacked previews of what is inside something. Give each card a `render` link
// when it stands for a page of its own; when the whole tile is the one link,
// mark it `data-card-fan-trigger` and pass `decorative`.
export function CardFan({
slots = DEFAULT_SLOTS,
label = "Cards",
variant = "default",
decorative = false,
}: {
slots?: CardFanSlot[];
label?: string;
/** `image` when every card is a picture: the card trades its outside ring
* for a hairline inside the edge, so a photo never carries two lines. */
variant?: "default" | "image";
/** The covers are decoration for a larger link. They take no pointer, focus
* or name, and the fan opens from the nearest `data-card-fan-trigger`
* ancestor (hover, or keyboard focus) instead of from its own box. */
decorative?: boolean;
}) {
return (
<div
className="motion-card-fan"
data-variant={variant}
data-decorative={decorative || undefined}
role={decorative ? undefined : "group"}
aria-label={decorative ? undefined : label}
aria-hidden={decorative || undefined}
>
{slots.map((s, i) => (
<CardFanCard key={i} slot={s} index={i} decorative={decorative} />
))}
</div>
);
}/* Card fan: a motion recipe from UI.MD. Settings first, then rules.
The settings sit on :where(:root), which has no weight, so any :root block of
yours overrides them whatever order the stylesheets load in. */
:where(:root) {
/* A calm gallery deck: the card you point at comes forward, turns most of
the way level and rises; the others darken. On the kit's large motion
role, with no overshoot, because these cards are usually photographs and
a spring past the target reads as a jolt on an image. */
--card-fan-open-dur: var(--transition-duration-large);
--card-fan-open-ease: var(--ease-large);
--card-fan-close-dur: var(--transition-duration-large);
--card-fan-close-ease: var(--ease-large);
--card-fan-scale-dur: var(--transition-duration-large);
--card-fan-scale-ease: var(--ease-large);
--card-fan-scale: 1.08;
--card-fan-spread: 1;
--card-fan-rotation: 1;
/* Box and card size. The slot poses are offsets inside the box, so scale
them with it when you resize. */
--card-fan-size: 180px;
--card-fan-card-width: 96px;
--card-fan-card-height: 128px;
/* The card under the pointer. straighten runs 0 to 1: how far it turns back
toward level. lift raises it. dim darkens the OTHER cards, 0 to 1, so the
eye lands on the one you are pointing at. */
--card-fan-focus-straighten: 0.75;
--card-fan-focus-lift: 8px;
--card-fan-dim: 0.45;
}
.motion-card-fan {
position: relative;
width: var(--card-fan-size);
height: var(--card-fan-size);
flex-shrink: 0;
}
/* Each card is absolutely positioned; the stacked pose comes from its
inline --cx/--cy/--rot, the fanned pose adds the inline deltas scaled
by the shared multipliers. Base timing = close (collapse back). */
.motion-card-fan-card {
appearance: none;
border: 0;
padding: 0;
position: absolute;
top: 0;
left: 0;
display: block;
overflow: hidden; /* an image as content takes the card's corners */
width: var(--card-fan-card-width);
height: var(--card-fan-card-height);
border-radius: var(--radius-lg);
background: var(--card);
color: var(--card-foreground);
box-shadow: var(--shadow-ring-md); /* the ring is the edge; stacked cards stay distinct in both modes */
cursor: pointer;
-webkit-tap-highlight-color: transparent;
z-index: var(--z, 0);
translate: var(--cx) var(--cy);
rotate: var(--rot);
scale: 1;
transition:
translate var(--card-fan-close-dur) var(--card-fan-close-ease),
rotate var(--card-fan-close-dur) var(--card-fan-close-ease),
scale var(--card-fan-scale-dur) var(--card-fan-scale-ease);
will-change: translate, rotate, scale;
}
/* Hover affordances only on devices that actually support hover
(on touch, :hover can stick after a tap). */
@media (hover: hover) {
.motion-card-fan:hover .motion-card-fan-card,
[data-card-fan-trigger]:hover .motion-card-fan[data-decorative] .motion-card-fan-card {
translate:
calc(var(--cx) + var(--dx))
calc(var(--cy) + var(--dy) * var(--card-fan-spread));
rotate: calc(var(--rot) + var(--drot) * var(--card-fan-rotation));
transition:
translate var(--card-fan-open-dur) var(--card-fan-open-ease),
rotate var(--card-fan-open-dur) var(--card-fan-open-ease),
scale var(--card-fan-scale-dur) var(--card-fan-scale-ease);
}
.motion-card-fan-card:hover {
scale: var(--card-fan-scale);
z-index: 30;
}
/* Same weight as the container rule above and later in the file, so the
hovered card's own pose wins over the fan pose every card takes. */
.motion-card-fan .motion-card-fan-card:hover {
translate:
calc(var(--cx) + var(--dx))
calc(var(--cy) + var(--dy) * var(--card-fan-spread) - var(--card-fan-focus-lift));
rotate: calc((var(--rot) + var(--drot) * var(--card-fan-rotation)) * (1 - var(--card-fan-focus-straighten)));
}
.motion-card-fan:has(.motion-card-fan-card:hover) .motion-card-fan-card:not(:hover)::after {
opacity: var(--card-fan-dim);
}
}
/* The dimming film. A fixed black in both themes: it darkens an image, the
way a media scrim does, rather than tinting toward the page. */
.motion-card-fan-card::after {
content: "";
position: absolute;
inset: 0;
background: black;
opacity: 0;
pointer-events: none;
transition: opacity var(--card-fan-close-dur) var(--card-fan-close-ease);
}
.motion-card-fan-card:focus-visible {
outline: 2px solid var(--ring-focus, var(--ring));
outline-offset: 2px;
z-index: 30;
}
/* Decorative covers: the trigger around the fan is the one link, so the
cards take no pointer of their own and none lifts, dims or reads as its own
target. Keyboard focus on the trigger fans them too, on any device, since
focus is not the sticky state hover is on touch. */
.motion-card-fan[data-decorative] {
pointer-events: none;
}
.motion-card-fan[data-decorative] .motion-card-fan-card {
cursor: inherit;
}
[data-card-fan-trigger]:is(:focus-visible, :has(:focus-visible)) .motion-card-fan[data-decorative] .motion-card-fan-card {
translate:
calc(var(--cx) + var(--dx))
calc(var(--cy) + var(--dy) * var(--card-fan-spread));
rotate: calc(var(--rot) + var(--drot) * var(--card-fan-rotation));
transition:
translate var(--card-fan-open-dur) var(--card-fan-open-ease),
rotate var(--card-fan-open-dur) var(--card-fan-open-ease),
scale var(--card-fan-scale-dur) var(--card-fan-scale-ease);
}
/* Image cards: one edge. A picture takes the media hairline inside its edge
(pure black or white at 10%, never a tint), so the outside ring goes and
only the drop shadow stays. Both together paint a doubled line. The
hairline is a layer above the image, because an inset shadow on the card
itself paints under its content. */
.motion-card-fan[data-variant="image"] .motion-card-fan-card {
box-shadow: var(--shadow-md);
}
.motion-card-fan[data-variant="image"] .motion-card-fan-card::before {
content: "";
position: absolute;
inset: 0;
z-index: 1;
border-radius: inherit;
box-shadow: inset 0 0 0 1px oklch(0 0 0 / 10%);
pointer-events: none;
}
:where(.dark) .motion-card-fan[data-variant="image"] .motion-card-fan-card::before {
box-shadow: inset 0 0 0 1px oklch(1 0 0 / 10%);
}
/* Reduced motion zeroes the durations on the fan itself rather than setting
`transition: none` on the cards: the open rules above outrank a plain card
selector, so a none there lost and the fan still animated. The cards still
take the fanned pose, just without the travel. */
@media (prefers-reduced-motion: reduce) {
.motion-card-fan {
--card-fan-open-dur: 0s;
--card-fan-close-dur: 0s;
--card-fan-scale-dur: 0s;
}
}

