All patterns

Source Fan Out

A tilted deck of reference cards fans apart to show what an answer was grounded in.

aielegantpremiuminteraction · finite · intermediate · ~0.5s
Interactive · click to play
Variant

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.

356 lines · react + motion only
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.

Citations under an answerRetrieved documents listEvidence panelReference drawer

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Supplier contract.docxQ3 planning notes
    Ask a follow-up
    AI assistant

    Answers carry a collapsed source set that expands into readable cards.

Related patterns