Card Fan
Visual Preview
Studies in interaction01PlayA little room to play.
Studies in interaction02FormFind the balance.
Studies in interaction03RhythmBreak the pattern.
Studies in interaction04SpaceLess, but better.
Studies in interaction05MotionNothing stands still.
Hover to spread.
Source
Copy the component and styles into your project.
"use client";
import { useState, type CSSProperties, type ReactNode } from "react";
import styles from "./card-fan.module.css";
export type CardFanVariant = "original" | "left" | "right";
export type CardFanItem = {
id: string;
/** Decorative content only. Put navigation on a containing link. */
content: ReactNode;
};
export type CardFanProps = {
/** Front card first; remaining cards are ordered from front to back. */
items: readonly CardFanItem[];
label?: string;
variant?: CardFanVariant;
/** Space between expanded cards, as a percentage of card width. */
spread?: number;
/** Space between resting cards, as a percentage of card width. */
gap?: number;
duration?: number;
/** Hold open independently of hover, e.g. from a touch/keyboard control. */
expanded?: boolean;
className?: string;
};
/** A decorative fan with fixed layering, group hover, and reduced motion. */
export function CardFan({
items,
label = "A fan of cards",
variant = "right",
spread = 24,
gap = 3.5,
duration = 650,
expanded = false,
className,
}: CardFanProps) {
const [hovered, setHovered] = useState(false);
const safeSpread = Math.max(0, Math.min(60, spread));
const safeGap = Math.max(0, Math.min(safeSpread, gap));
const lastIndex = Math.max(items.length - 1, 1);
const radius = Math.max(Math.ceil((items.length - 1) / 2), 1);
const style = {
"--fan-spread": `${safeSpread}%`,
"--fan-gap": `${safeGap}%`,
"--fan-duration": `${Math.max(0, duration)}ms`,
"--fan-sign": variant === "left" ? -1 : 1,
// Reserve the expanded footprint in both states, including rotated corners.
"--fan-footprint": 1 + (items.length > 1 ? 4 * safeSpread / 100 : 0)
+ (variant === "original" ? 0.6 : 0),
} as CSSProperties;
if (!items.length) return null;
return (
<div
className={[styles.fan, className].filter(Boolean).join(" ")}
style={style}
data-variant={variant}
data-expanded={expanded || hovered}
data-single={items.length === 1}
role="img"
aria-label={label}
onPointerEnter={(event) => {
if (event.pointerType === "mouse") setHovered(true);
}}
onPointerLeave={() => setHovered(false)}
>
<div className={styles.stage} aria-hidden="true">
<div className={styles.deck}>
{items.map((item, index) => {
const position = index / lastIndex * 4;
// The first card stays central in the original angled arrangement.
const fanIndex = index === 0 ? 0
: index <= radius ? index - radius - 1 : index - radius;
const anglePosition = fanIndex / radius * 2;
const cardStyle = {
"--fan-position": position,
"--fan-to-back": items.length === 1 ? 0 : 4 - position,
"--fan-angle-position": anglePosition,
"--fan-distance": Math.abs(anglePosition),
"--fan-drop": anglePosition * anglePosition,
"--fan-order": variant === "original"
? 1000 - Math.round(Math.abs(anglePosition) * 100) : items.length - index,
} as CSSProperties;
return (
<div key={item.id} className={styles.slot} style={cardStyle}>
<div className={styles.card}>{item.content}</div>
</div>
);
})}
</div>
</div>
</div>
);
}