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
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 back3 * 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. Reduced12 * 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 <div65 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 <span77 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.
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.


