Source Fan Out
A tilted deck of reference cards fans apart to show what an answer was grounded in.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Source Fan Out
*
* The references an answer was grounded in, kept as a tidy stack until
* asked for. On expand the deck fans apart into a readable column; on
* collapse it gathers back into one card's worth of space.
*
* Self-contained: depends only on `react` and `motion`. Surfaces are
* mixed from the inherited text color, so it reads correctly on a light
* page and on a dark one.
* Works with zero props; tune via `variant`, `sources`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type FanSource = {
/** Stable key. */
id: string;
/** Where it came from — shown small, above the title. */
origin: string;
/** One-line title of the referenced document. */
title: string;
/** The passage the answer leaned on. */
excerpt: string;
};
export type SourceFanOutProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** References, in the order they should be read. */
sources?: FanSource[];
/** Whether the deck starts open. */
defaultOpen?: boolean;
/** Accent for the counter and the active card edge. */
accent?: string;
/** Fires whenever the deck opens or closes. */
onOpenChange?: (open: boolean) => void;
};
type VariantConfig = {
/** px each card behind the top one peeks out when stacked. */
peek: number;
/** Degrees of tilt on the deepest card in the stack. */
tilt: number;
/** Gap between cards once fanned out. */
gap: number;
/** Seconds between one card leaving the stack and the next. */
stagger: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: only the cards move, and their spring sits above a 0.8
// damping ratio — a reference card that wobbles past its slot reads as
// unreliable, which is the last thing a citation should read as. The
// text inside never scales; it is held at zero opacity while a card is
// tilted and fades in once the card is square again.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a deck — the cards sit almost flush and simply separate.
subtle: {
peek: 3,
tilt: 0,
gap: 6,
stagger: 0.014,
spring: { type: "spring", stiffness: 610, damping: 48 },
},
// A visible stack that fans. ζ ≈ 0.89 — the all-purpose setting.
default: {
peek: 7,
tilt: 1.4,
gap: 8,
stagger: 0.04,
spring: { type: "spring", stiffness: 500, damping: 40 },
},
// More tilt and a longer cascade, for an answer where the sources are
// the point rather than a footnote.
playful: {
peek: 11,
tilt: 3.5,
gap: 12,
stagger: 0.075,
spring: { type: "spring", stiffness: 370, damping: 33 },
},
};
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* correctly toned in either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const DEFAULT_SOURCES: FanSource[] = [
{
id: "handbook",
origin: "Operations handbook · §4",
title: "Standard delivery windows by region",
excerpt:
"Domestic orders placed before 14:00 local time ship the same working day.",
},
{
id: "changelog",
origin: "Release notes · March",
title: "Carrier rates refreshed for spring",
excerpt:
"Express pricing moved to a per-kilogram band; the flat surcharge was retired.",
},
{
id: "ticket",
origin: "Support thread · 8,412",
title: "What customers are told about delays",
excerpt:
"Agents quote the carrier estimate plus one day for anything crossing a border.",
},
];
const CARD_HEIGHT = 84;
export default function SourceFanOut({
variant = "default",
sources = DEFAULT_SOURCES,
defaultOpen = false,
accent = "#5B5BD6",
onOpenChange,
}: SourceFanOutProps) {
const [open, setOpen] = useState(defaultOpen);
const reduceMotion = useReducedMotion();
const [ring, setRing] = useState(false);
const cfg = VARIANTS[variant];
const count = sources.length;
const stackedHeight = CARD_HEIGHT + cfg.peek * Math.max(0, count - 1);
const fannedHeight = count * CARD_HEIGHT + cfg.gap * Math.max(0, count - 1);
const toggle = () => {
const next = !open;
setOpen(next);
onOpenChange?.(next);
};
return (
<div style={{ width: 320 }}>
<button
type="button"
onClick={toggle}
aria-expanded={open}
onFocus={(event) => setRing(event.currentTarget.matches(":focus-visible"))}
onBlur={() => setRing(false)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 7,
padding: "6px 11px 6px 9px",
fontFamily: "inherit",
fontSize: 12,
fontWeight: 600,
color: "inherit",
background: tone(6),
border: `1px solid ${tone(13)}`,
borderRadius: 999,
cursor: "pointer",
// Always-visible keyboard affordance; the deck itself is a
// reveal, not a focus indicator.
boxShadow: ring ? `0 0 0 3px ${tone(22)}` : "none",
outline: "none",
}}
>
<span
aria-hidden
style={{
display: "grid",
placeItems: "center",
minWidth: 17,
height: 17,
padding: "0 4px",
borderRadius: 999,
fontSize: 10.5,
fontWeight: 700,
color: "#fff",
background: accent,
}}
>
{count}
</span>
{open ? "Hide sources" : "Sources for this answer"}
<motion.span
aria-hidden
animate={{ rotate: open ? 180 : 0 }}
transition={reduceMotion ? { duration: 0 } : cfg.spring}
style={{ display: "inline-flex", opacity: 0.55 }}
>
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<path
d="M2.5 4.5 6 8l3.5-3.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</motion.span>
</button>
{/* The deck occupies one card's footprint when closed, so opening
it pushes the page by exactly the difference and nothing else
on the answer moves sideways. */}
<motion.div
initial={false}
animate={{ height: open ? fannedHeight : stackedHeight }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] }
}
style={{ position: "relative", marginTop: 12 }}
>
{sources.map((source, index) => {
// Depth counts backwards from the card on top: the front one
// sits square and legible, and only the ones behind it tilt.
const depth = index;
const stackedY = index * cfg.peek;
const fannedY = index * (CARD_HEIGHT + cfg.gap);
const delay = reduceMotion ? 0 : index * cfg.stagger;
return (
<motion.article
key={source.id}
aria-hidden={!open && index !== 0}
initial={false}
animate={{
y: open ? fannedY : stackedY,
x: open ? 0 : depth * 1.5,
rotate: open ? 0 : -depth * cfg.tilt,
opacity: 1,
}}
transition={
reduceMotion ? { duration: 0 } : { ...cfg.spring, delay }
}
style={{
position: "absolute",
top: 0,
left: 0,
right: 0,
height: CARD_HEIGHT,
boxSizing: "border-box",
padding: "10px 12px",
borderRadius: 12,
border: `1px solid ${index === 0 ? tone(16) : tone(12)}`,
// Cards behind the top one must hide what they overlap,
// so the stack needs a page-opaque surface rather than a
// translucent tone. The CSS system colors follow the
// reader's light or dark setting without this file
// hard-coding either.
background: "Canvas",
color: "CanvasText",
boxShadow: open
? `0 1px 2px ${tone(8)}`
: `0 4px 14px rgba(0,0,0,0.10)`,
overflow: "hidden",
zIndex: count - index,
}}
>
{/* Only the front card carries readable text while stacked;
the ones behind are edges. Text fades in after its card
is square again, so nothing is ever read at an angle. */}
<motion.div
initial={false}
animate={{ opacity: open || index === 0 ? 1 : 0 }}
transition={{
duration: reduceMotion ? 0 : 0.18,
delay: open ? delay + 0.08 : 0,
ease: "easeOut",
}}
style={{ display: "flex", gap: 9 }}
>
<span
aria-hidden
style={{
flex: "0 0 auto",
display: "grid",
placeItems: "center",
width: 22,
height: 22,
marginTop: 1,
borderRadius: 7,
background: tone(8),
border: `1px solid ${tone(12)}`,
}}
>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none">
<path
d="M4 2.6h5.1L12.4 6v7.4a1.2 1.2 0 0 1-1.2 1.2H4a1.2 1.2 0 0 1-1.2-1.2V3.8A1.2 1.2 0 0 1 4 2.6Z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
opacity="0.55"
/>
<path
d="M5.4 8.6h5M5.4 11h3.4"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
opacity="0.35"
/>
</svg>
</span>
<span style={{ minWidth: 0 }}>
<span
style={{
display: "block",
fontSize: 10.5,
fontWeight: 650,
letterSpacing: 0.2,
color: accent,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{source.origin}
</span>
<span
style={{
display: "block",
marginTop: 1,
fontSize: 12.5,
fontWeight: 600,
lineHeight: 1.3,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{source.title}
</span>
<span
style={{
display: "block",
marginTop: 3,
fontSize: 11,
lineHeight: 1.35,
opacity: 0.55,
overflow: "hidden",
}}
>
{source.excerpt}
</span>
</span>
</motion.div>
</motion.article>
);
})}
</motion.div>
</div>
);
}About this pattern
Evidence kept out of the way until it is wanted. Closed, the references sit as one card's worth of space with the others peeking behind it at a slight tilt; opening deals them into a readable column, each card a beat behind the one above. Only the front card carries legible text while the deck is stacked — the rest fade their contents in once they are square again, so nothing is ever read at an angle. The cards are page-opaque, because a stack whose lower layers show through is not a stack.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
Answers carry a collapsed source set that expands into readable cards.
Related patterns
- AI Result RevealThe result card rises into place while its confidence value counts up to the final number.
- Model Switch MorphChoosing another model glides the selection across and eases the badge into that model's name and accent.
- Summary CondenseThe dropped lines fade out from the bottom up, and only then does the card close the height they held.