Skip to content

Fan Out

Fan out card spread for React: a pile of photos opens into an arc and gathers back, with staggered motion and hover lift.

  • Micro-interactions
  • Photography
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (CSS transforms). Photos are plain URLs you pass in; if one fails to load, a soft gradient stays in its place. The demo uses /demo/*.webp, swap in your own files.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/fan-out.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/micro-interactions/fan-out.tsx
1/**
2 * FanOut, a pile of photo cards that opens into an arc and gathers back
3 * into a pile. Cards leave one after another; hovering one lifts it.
4 *
5 * Props: images[], radius (px, default 220), arc (degrees, default 130),
6 * cardWidth (px, default 92), open / defaultOpen / onOpenChange,
7 * spreadLabel / gatherLabel (text of the built-in toggle), aria-label.
8 * CSS vars: --fd-fo-radius.
9 *
10 * Browser support: all evergreen browsers (CSS transforms only).
11 * Accessibility: the toggle is a real button with aria-expanded. Reduced
12 * motion: cards change place without the stagger.
13 */
14import { useState } from "react";
15
16const TONES = [
17 "linear-gradient(160deg,#f3d1b8,#d7959a)",
18 "linear-gradient(160deg,#c9d6f2,#8e9fd6)",
19 "linear-gradient(160deg,#d3e6cf,#8fb996)",
20 "linear-gradient(160deg,#f2e2b6,#d9b36b)",
21 "linear-gradient(160deg,#e3cdf0,#a98fd0)",
22];
23
24export interface FanOutProps {
25 images: string[];
26 radius?: number;
27 arc?: number;
28 cardWidth?: number;
29 open?: boolean;
30 defaultOpen?: boolean;
31 onOpenChange?: (open: boolean) => void;
32 spreadLabel?: string;
33 gatherLabel?: string;
34 className?: string;
35 "aria-label"?: string;
36}
37
38export function FanOut({
39 images,
40 radius = 220,
41 arc = 130,
42 cardWidth = 92,
43 open,
44 defaultOpen = false,
45 onOpenChange,
46 spreadLabel = "Spread",
47 gatherLabel = "Gather",
48 className,
49 "aria-label": label = "Photo fan",
50}: FanOutProps) {
51 const [inner, setInner] = useState(defaultOpen);
52 const isOpen = open ?? inner;
53 const toggle = () => {
54 if (open === undefined) setInner(!isOpen);
55 onOpenChange?.(!isOpen);
56 };
57 const h = Math.round(cardWidth * 1.34);
58 const n = images.length;
59 const rad = (d: number) => (d * Math.PI) / 180;
60
61 return (
62 <div className={`fd-fo${className ? ` ${className}` : ""}`} style={{ width: radius * 2 + cardWidth + 40 }}>
63 <style>{css}</style>
64 <div
65 className="fd-fo-stage"
66 role="group"
67 aria-label={label}
68 style={{ height: radius + h + 24 }}
69 >
70 {images.map((src, i) => {
71 const t = n > 1 ? i / (n - 1) : 0.5;
72 const a = isOpen ? -arc / 2 + t * arc : (i - (n - 1) / 2) * 2.2;
73 const x = isOpen ? Math.sin(rad(a)) * radius : (i - (n - 1) / 2) * 1.5;
74 const y = isOpen ? -Math.cos(rad(a)) * radius : (n - 1 - i) * -1.2;
75 return (
76 <span
77 key={i}
78 className="fd-fo-slot"
79 style={{
80 width: cardWidth,
81 height: h,
82 marginLeft: -cardWidth / 2,
83 transform: `translate3d(${x.toFixed(1)}px, ${y.toFixed(1)}px, 0) rotate(${a.toFixed(1)}deg)`,
84 transitionDelay: `${(isOpen ? i : n - 1 - i) * 36}ms`,
85 zIndex: i,
86 }}
87 >
88 <span className="fd-fo-card" style={{ background: TONES[i % TONES.length] }}>
89 <Img src={src} />
90 </span>
91 </span>
92 );
93 })}
94 </div>
95 <button type="button" className="fd-fo-toggle" aria-expanded={isOpen} onClick={toggle}>
96 {isOpen ? gatherLabel : spreadLabel}
97 </button>
98 </div>
99 );
100}
101
102function Img({ src }: { src: string }) {
103 const [ok, setOk] = useState(true);
104 if (!ok) return null;
105 return <img className="fd-fo-img" src={src} alt="" draggable={false} loading="lazy" decoding="async" onError={() => setOk(false)} />;
106}
107
108const FONT = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif`;
109const css = `
110.fd-fo,.fd-fo *,.fd-fo *::before,.fd-fo *::after{box-sizing:border-box}
111.fd-fo{--fd-fo-radius:14px;--fd-ease-out:cubic-bezier(0.22,1,0.36,1);display:flex;flex-direction:column;align-items:center;gap:28px;max-width:100%;margin:0;padding:0;font:400 14px/1.4 ${FONT};color:#111}
112.fd-fo-stage{position:relative;display:block;width:100%;margin:0;padding:0}
113.fd-fo-slot{position:absolute;left:50%;bottom:0;display:block;margin-top:0;padding:0;transform-origin:50% 100%;transition:transform 720ms var(--fd-ease-out)}
114.fd-fo-card{position:absolute;inset:0;display:block;margin:0;padding:0;border-radius:var(--fd-fo-radius);overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.12),0 10px 22px -8px rgba(0,0,0,.35);transition:transform 360ms var(--fd-ease-out)}
115.fd-fo-slot:hover .fd-fo-card{transform:translateY(-12px) scale(1.07)}
116.fd-fo-img{position:absolute;inset:0;display:block;width:100%;height:100%;margin:0;padding:0;border:0;object-fit:cover;pointer-events:none}
117.fd-fo-toggle{display:inline-flex;align-items:center;justify-content:center;margin:0;padding:11px 22px;border:0;border-radius:999px;background:#111;color:#fff;font:500 14px/1.2 ${FONT};letter-spacing:normal;cursor:pointer;appearance:none;-webkit-appearance:none;box-shadow:0 1px 2px rgba(0,0,0,.14),0 8px 18px -8px rgba(0,0,0,.4);transition:transform 200ms var(--fd-ease-out);-webkit-tap-highlight-color:transparent}
118.fd-fo-toggle:active{transform:scale(.96)}
119.fd-fo-toggle:focus-visible{outline:2px solid #1a73e8;outline-offset:3px}
120@media (prefers-reduced-motion:reduce){.fd-fo-slot,.fd-fo-card{transition:none!important}}
121@media (max-width:560px){.fd-fo-stage{transform:scale(.62);transform-origin:50% 100%;margin-bottom:-90px}}
122`;
123
124export function Demo() {
125 const names = ["eagle", "butterfly", "giraffe", "iguana", "meadow", "sunset", "ridge"];
126 return (
127 <div className="fd-fod">
128 <style>{demoCss}</style>
129 <FanOut images={names.map((n) => `/demo/${n}-p.webp`)} />
130 </div>
131 );
132}
133
134const demoCss = `
135.fd-fod,.fd-fod *,.fd-fod *::before,.fd-fod *::after{box-sizing:border-box}
136.fd-fod{display:flex;align-items:center;justify-content:center;width:100%;min-height:100vh;margin:0;padding:48px 20px;background:#ececec;font:400 14px/1.4 ${FONT};letter-spacing:normal;overflow:hidden}
137`;

More Micro-interactions. Same file contract.

Card Stack, micro interactions preview: Card stack for React: photo cards you throw to the back with a drag or a flick, tap to pull one forward

Card Stack

Card stack for React: photo cards you throw to the back with a drag or a flick, tap to pull one forward. No dependencies.

Photo Sleeves, micro interactions preview: Photo album sleeves for React: pictures peek out, lift on hover and fan open on click

Photo Sleeves

Photo album sleeves for React: pictures peek out, lift on hover and fan open on click. Frosted pocket, zero dependencies.

Tilt Card, micro interactions preview: 3D tilt card for React: leans toward the pointer with a soft sheen and a drifting photo

Tilt Card

3D tilt card for React: leans toward the pointer with a soft sheen and a drifting photo. Lightweight, no animation library.

Questions about Fan Out

Run npx shadcn@latest add https://freedesignmd.com/r/fan-out.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Fan out card spread for React: a pile of photos opens into an arc and gathers back, with staggered motion and hover lift. It is a free, MIT-licensed React micro-interactions component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers (CSS transforms). Photos are plain URLs you pass in; if one fails to load, a soft gradient stays in its place. The demo uses /demo/*.webp, swap in your own files.

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.