# Context cards

> The chunks a retrieval brought back, a card each, with the file each one came from.

What a model was given to read, laid out so a person can check it. A heading counts the chunks found; each card names its chunk, says how long it is and shows its text, and a chip at its foot names the source, with a small badge for the kind of file. The chip opens the source when it has a link. On screen the list comes in once, card after card, then a pointer rests on each source in turn.

A React component by Bruno Delorence: https://brunodelorence.com/components/context-cards. MIT License, Copyright (c) 2026 Bruno Delorence.

Category: AI.

## Anatomy

1. **Stage**: A soft grey card with a 1 px hairline; the list sits in its middle, 380 px wide at most.
2. **Heading**: The list’s name, 13 px semibold, set by `heading`.
3. **Count**: How many chunks the retrieval found in all, which may be more than the cards shown. Set by `count`.
4. **Chunk card**: One per chunk, in rank order: white with a long soft shadow by day, a short close one at night.
5. **Bar**: A lines glyph, the chunk’s title (cut with an ellipsis when long) and its length in characters, over a hairline.
6. **Excerpt**: The chunk’s text, or the part of it worth showing, in the second ink.
7. **Source chip**: A pill with the kind’s badge, the file’s name and an arrow. A link with `href`, a button with `onOpenSource`, else a label.
8. **Kind badge**: Three letters on a 14 px square: PDF red, CSV green, DOC blue, WEB grey.

## When to use

- Showing what a retrieval brought back before, or beside, the answer built on it.
- Letting a reader check an AI answer against its sources: each chunk names its file and opens it.
- A few top-ranked chunks (two to five) with a `count` for how many were found in all.
- Debugging or tuning retrieval, where each chunk’s length in characters matters.

## When not to use

- Citing sources inside the answer’s text. Use inline citation chips next to the sentence they back.
- Dozens of results to browse or filter. Use a search results list or a table.
- Files the person attached themselves. Use attachment chips in the composer.
- A plain list of links with no excerpt. A list of links reads faster than cards.

## Installation

Install it with the shadcn CLI, into components/context-cards of any React project:

```sh
npx shadcn@latest add https://brunodelorence.com/r/context-cards.json
```

Or copy all the source files below into one folder of your project. Either way it needs React 19.2 and a bundler with CSS Modules (Next.js, Vite).

It has a dark colour scheme. Its colours are CSS light-dark() pairs, kept inside @supports so that builds which rewrite light-dark() for older browsers leave them alone: it follows your page’s color-scheme (light unless the page sets one) or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.

## Usage

```tsx
import { ContextCards } from './context-cards';

export function Sources({ hits, total }: { hits: Chunk[]; total: number }) {
  return (
    <ContextCards
      chunks={hits}
      count={total}
      onOpenSource={(chunk) => openPreview(chunk.source)}
      autoplay={false}
    />
  );
}
```

## States

- **Default**: Two chunks of 32. Without autoplay the entrance (cards rise 100 ms apart) is skipped.
- **Chip hovered**: The chip’s background lifts a shade, over 300 ms: quiet on purpose. The demo pointer shows the same on its own.
- **Every kind of source**: Four chunks, one per kind: the badge’s colour tells PDF, CSV, DOC and web pages apart.
- **Long title and file**: The title and the file name are cut with an ellipsis; the length never wraps.
- **One chunk**: A list of your own chunks counts them, unless `count` says how many were found.
- **Dark**: `colorScheme="dark"`: every colour is a light-dark() token, and the cards trade the long shadow for a close one.

## How it works

- The entrance is CSS alone, started by one attribute on the root once the list is first on screen: the heading fades in over 400 ms, the cards rise 8 px as they fade in (400 ms, 100 ms apart, on a fast-out curve), and the source chips pop from 95% scale over 300 ms, 80 ms apart.
- Until then the list waits unseen; without autoplay, or under reduced motion, it is simply there and nothing plays.
- A card is a bar (a glyph, the title, truncated with an ellipsis, and the length in characters, in tabular figures), the text, and the source chip. Its edge and lift are one box-shadow: a hairline and a long six-layer soft shadow by day, a brighter hairline and a short close drop at night.
- The source chip is a link when the source has an href (opening a new tab), a button when onOpenSource is set, and a plain label otherwise. Its background warms on hover over 300 ms; a long name truncates rather than widen the card.
- The chip sits on a 21 px line, as inline text would, so the card’s foot keeps the same rhythm whatever the chip is.
- While on screen and untouched, a demo pointer visits each source chip in turn, standing in for :hover with a data attribute; any input of the reader’s hands it over.
- Every colour is a light-dark() token on the root. Shadows that differ between the schemes are written once with all their layers, the layers a scheme does not use being transparent.

## API reference

- **chunks** (`Chunk[]`, default `a sample retrieval`): The retrieved chunks, a card each, in the order they rank: { id, title, characters, text, source: { name, kind: pdf | csv | doc | web, href? } }.
- **count** (`number`, default `chunks.length`): How many chunks the retrieval found in all, for the heading’s count.
- **heading** (`string`, default `"All chunks"`): The list’s heading.
- **defaultHover** (`string`): The id of a chunk whose source chip opens drawn as hovered. Setting it turns the entrance and the demo off.
- **onOpenSource** (`(chunk: Chunk) => void`): Called when a source chip is pressed. It makes the chips buttons, unless a source has an href, which makes its chip a link.
- **autoplay** (`boolean`, default `true`): While on screen, the list comes in once, then a demo pointer visits the source chips. Off, the list is simply there.
- **colorScheme** (`'light' | 'dark' | 'system'`): Light or dark colours, or the reader’s; without it, the page’s color-scheme.
- **className** (`string`): Added to the root.

## Accessibility

- The list is a labelled section with a real heading, and the cards an ordered list, in rank order.
- The count is read as “32 chunks”, not a bare number.
- A source chip is a real link or button only when it does something; a link says it opens a new tab. Both show a focus ring for the keyboard.
- The glyphs, the arrow and the file-kind badge are decorative and hidden from assistive technology; the file name carries the meaning.
- Under reduced motion nothing animates and there is no demo.

## Source

### context-cards/context-cards.tsx

```tsx
// Copyright (c) 2026 Bruno Delorence. MIT License.

'use client';

import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { CHUNKS, type Chunk, type SourceKind } from './data';
import { DemoPointer, runDemo, type Demo } from './demo-pointer';
import { useAutoplay } from './use-autoplay';
import styles from './context-cards.module.css';

export type { Chunk, ChunkSource, SourceKind } from './data';

/*
 * Context cards: the chunks of knowledge a retrieval brought back, a card each, with where they
 * came from. A heading counts the chunks; each card has a bar (a glyph, the chunk's title and its
 * length in characters), the chunk's text, and a chip for its source: a small coloured badge for
 * the kind of file, the file's name and an arrow.
 *
 * - The source chip is a link when the source has an href, a button when onOpenSource is set, and
 *   a plain label otherwise. Its background warms on hover, over 300 ms.
 * - The list comes in once, as it first shows: the heading fades in (400 ms), the cards rise 8 px
 *   as they fade in, 100 ms apart (400 ms each), and the chips pop from 95% scale, 80 ms apart
 *   (300 ms). All of it is CSS, started by one attribute on the root.
 * - While on screen and untouched, a demo pointer then visits the source chips in turn; any input
 *   of the reader's hands it over.
 * - Light or dark: every colour is a light-dark() token in the CSS, so the scheme is the page's
 *   color-scheme, or the one the colorScheme prop sets.
 */

/** How many chunks the sample retrieval found in all: the two shown are its first. */
const SAMPLE_COUNT = 32;
/** The cards come in CARD_STAGGER ms apart, their chips CHIP_STAGGER ms apart. */
const CARD_STAGGER = 100;
const CHIP_STAGGER = 80;

/** The badge each kind of source shows. */
const BADGE: Record<SourceKind, string> = { pdf: 'PDF', csv: 'CSV', doc: 'DOC', web: 'WEB' };

export function ContextCards({
  chunks = CHUNKS,
  count = chunks === CHUNKS ? SAMPLE_COUNT : chunks.length,
  heading = 'All chunks',
  onOpenSource,
  autoplay: autoplayProp = true,
  defaultHover,
  colorScheme,
  className = '',
}: {
  /** The retrieved chunks, a card each, in the order they rank. */
  chunks?: Chunk[];
  /** How many chunks the retrieval found in all, for the heading's count. */
  count?: number;
  /** The list's heading. */
  heading?: string;
  /** Called when a source chip is pressed; it makes the chips buttons (unless they have an href). */
  onOpenSource?: (chunk: Chunk) => void;
  /** While on screen, the list comes in once, then a demo pointer visits the source chips. */
  autoplay?: boolean;
  /** The id of a chunk whose source chip opens drawn as hovered; setting it turns the demo off. */
  defaultHover?: string;
  /** Light or dark colours, or 'system' for the reader's; without it, the page's color-scheme. */
  colorScheme?: 'light' | 'dark' | 'system';
  className?: string;
}) {
  const autoplay = autoplayProp && defaultHover === undefined;
  const { ref: root, playing, take } = useAutoplay<HTMLElement>(6000);
  const pointer = useRef<HTMLSpanElement>(null);

  // 'wait' until the list first shows, then 'in' (the entrance plays); without autoplay, or under
  // reduced motion, it is simply there ('still').
  const [stage, setStage] = useState<'wait' | 'in' | 'still'>(autoplay ? 'wait' : 'still');
  useEffect(() => {
    if (stage !== 'wait') return;
    if (!autoplay || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
      setStage('still');
      return;
    }
    if (playing) setStage('in');
  }, [autoplay, playing, stage]);

  // The demo, once the list is in: the pointer rests on each source chip in turn.
  const demo = autoplay && playing && stage === 'in';
  useEffect(() => {
    const element = root.current;
    const arrow = pointer.current;
    if (!demo || !element || !arrow) return;
    let hovered: HTMLElement | null = null;
    /** Stands in for :hover, as the demo's pointer is not the reader's. */
    const hover = (target: HTMLElement | null) => {
      hovered?.removeAttribute('data-hover');
      hovered = target;
      hovered?.setAttribute('data-hover', '');
    };
    const visit = async (
      { moveTo }: Demo,
      target: HTMLElement | null,
      at?: { x: number; y: number },
    ) => {
      hover(null);
      await moveTo(target, at);
      hover(target);
    };
    const stop = runDemo(element, arrow, async (steps) => {
      const { wait } = steps;
      // The entrance first: the last chip is in after about 600 ms.
      await wait(1600);
      for (;;) {
        const chips = [...element.querySelectorAll<HTMLElement>('[data-source]')];
        for (const chip of chips) {
          await visit(steps, chip, { x: 0.42, y: 0.55 });
          await wait(1500);
        }
        await visit(steps, element, { x: 0.9, y: 0.88 });
        hover(null);
        await wait(2600);
      }
    });
    return () => {
      stop();
      hover(null);
    };
  }, [demo, root]);

  return (
    <section
      ref={root}
      className={`${styles.root} ${className}`}
      data-color-scheme={colorScheme}
      data-stage={stage}
      data-part="root"
      aria-label={heading}
      onPointerDownCapture={take}
      onKeyDownCapture={take}
    >
      <div className={styles.frame}>
        <div className={styles.list}>
          <div className={styles.heading} data-part="heading">
            <h3 className={styles.title}>{heading}</h3>
            <span className={styles.count} data-part="count" aria-label={`${count} chunks`}>
              {count}
            </span>
          </div>
          <ol className={styles.cards}>
            {chunks.map((chunk, index) => (
              <Card
                key={chunk.id}
                chunk={chunk}
                index={index}
                hovered={chunk.id === defaultHover}
                onOpenSource={onOpenSource}
              />
            ))}
          </ol>
        </div>
      </div>
      <DemoPointer ref={pointer} />
    </section>
  );
}

/** One chunk: its bar, its text and its source. */
function Card({
  chunk,
  index,
  hovered,
  onOpenSource,
}: {
  chunk: Chunk;
  index: number;
  hovered?: boolean;
  onOpenSource?: (chunk: Chunk) => void;
}) {
  const { source } = chunk;
  const timing = {
    '--card-delay': `${index * CARD_STAGGER}ms`,
    '--chip-delay': `${index * CHIP_STAGGER}ms`,
  } as CSSProperties;
  const content = (
    <>
      <span className={styles.badge} data-part="badge" data-kind={source.kind} aria-hidden="true">
        {BADGE[source.kind]}
      </span>
      <span className={styles.name}>{source.name}</span>
      <svg className={styles.arrow} viewBox="0 0 10 10" aria-hidden="true">
        <path d="M2.9 7.1 7.1 2.9M2.9 2.9h4.2v4.2" />
      </svg>
    </>
  );
  return (
    <li className={styles.card} data-part="card" style={timing}>
      <div className={styles.bar} data-part="bar">
        <span className={styles.chunkTitle} data-part="title">
          <svg className={styles.lines} viewBox="0 0 11 11" aria-hidden="true">
            <path d="M1.8 2.8h7.4M1.8 5.5h7.4M1.8 8.2h4.6" />
          </svg>
          <span className={styles.truncate}>{chunk.title}</span>
        </span>
        <span className={styles.size} data-part="size">
          {chunk.characters.toLocaleString('en-US')} characters
        </span>
      </div>
      <p className={styles.text} data-part="text">
        {chunk.text}
      </p>
      <div className={styles.source}>
        {source.href ? (
          <a
            className={styles.chip}
            data-source=""
            data-part="chip"
            data-hover={hovered ? '' : undefined}
            href={source.href}
            target="_blank"
            rel="noreferrer"
            aria-label={`${source.name} (opens in a new tab)`}
            onClick={() => onOpenSource?.(chunk)}
          >
            {content}
          </a>
        ) : onOpenSource ? (
          <button
            type="button"
            className={styles.chip}
            data-source=""
            data-part="chip"
            data-hover={hovered ? '' : undefined}
            onClick={() => onOpenSource(chunk)}
          >
            {content}
          </button>
        ) : (
          <span
            className={styles.chip}
            data-source=""
            data-part="chip"
            data-hover={hovered ? '' : undefined}
          >
            {content}
          </span>
        )}
      </div>
    </li>
  );
}
```

### context-cards/context-cards.module.css

```css
/* Context cards (context-cards.tsx): a soft grey card, and in its middle a column 380 px wide at
   most: the heading with its count, then a card per chunk.

   Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes
   the color-scheme it inherits from the page (light unless the page says otherwise), or the one
   its colorScheme prop sets (data-color-scheme). Shadows that differ between the schemes are
   written once with every layer, the layers a scheme does not use being transparent.

   Motion. The entrance is CSS alone, started by data-stage="in" on the root: the heading fades
   in, the cards rise, the chips pop. Under reduced motion it never plays (data-stage="still"). */

.root {
  /* The card, and the ink in three strengths. */
  --canvas: oklch(96.1% 0.002 247.84);
  --ink: oklch(24.7% 0.006 258.361);
  --ink-2: oklch(50.6% 0.01 264.477);
  --ink-3: oklch(69.5% 0.009 264.505);
  /* Hairlines: the stage's edge, the count's and the rule under a card's bar. */
  --line: oklch(94.6% 0.003 264.542);
  /* The chunk cards; the count and the chips, sunk a little; a chip under the pointer. */
  --surface: oklch(100% 0 0);
  --inset: oklch(97.9% 0.002 247.839);
  --hover: oklch(97% 0.002 247.839);
  /* The badges of the kinds of source. */
  --red: oklch(62.1% 0.192 23.042);
  --green: oklch(60.3% 0.155 150.883);
  --blue: oklch(57.2% 0.18 258.5);
  --grey: oklch(55.1% 0.014 264.364);
  /* A card's edge and lift: a long soft shadow by day, a short close one at night. */
  --card-edge: oklch(94.6% 0.003 264.542);
  --card-1: #00000008;
  --card-2: #00000005;
  --card-3: #00000003;
  --card-night: transparent;
  /* A chip's edge and lift: a soft halo by day, a short drop at night. */
  --button-edge: oklch(91.2% 0.005 258.326);
  --button-halo: #0000000a;
  --button-drop: transparent;
  /* The same on both schemes: the focus ring, and the demo's pointer, which looks like the
     system's. */
  --focus: #387bff;
  --pointer: #1c1c1e;
  --pointer-edge: #fff;
  --pointer-shadow: rgb(0 0 0 / 0.25);
  --sans: var(--body, Inter, ui-sans-serif, system-ui, sans-serif);

  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  width: 100%;
  min-height: 323px;
  padding: 12px;
  overflow: hidden;
  overflow: clip;
  border-radius: 14px;
  background: var(--canvas);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 14px;
  font-feature-settings: 'cv11', 'ss01';
  line-height: 1.5;
  letter-spacing: -0.14px;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-tap-highlight-color: transparent;
}
@supports (color: light-dark(#fff, #000)) {
  .root {
    --canvas: light-dark(oklch(96.1% 0.002 247.84), oklch(23.1% 0.004 264.487));
    --ink: light-dark(oklch(24.7% 0.006 258.361), oklch(96.4% 0.002 247.839));
    --ink-2: light-dark(oklch(50.6% 0.01 264.477), oklch(73.1% 0.008 260.731));
    --ink-3: light-dark(oklch(69.5% 0.009 264.505), oklch(54.1% 0.01 264.484));
    --line: light-dark(oklch(94.6% 0.003 264.542), oklch(30.8% 0.006 258.354));
    --surface: light-dark(oklch(100% 0 0), oklch(26% 0.006 271.191));
    --inset: light-dark(oklch(97.9% 0.002 247.839), oklch(24.3% 0.004 264.492));
    --hover: light-dark(oklch(97% 0.002 247.839), oklch(28.9% 0.006 271.22));
    --red: light-dark(oklch(62.1% 0.192 23.042), oklch(66.6% 0.18 21.433));
    --green: light-dark(oklch(60.3% 0.155 150.883), oklch(70.5% 0.154 153.814));
    --blue: light-dark(oklch(57.2% 0.18 258.5), oklch(65.5% 0.16 258.5));
    --grey: light-dark(oklch(55.1% 0.014 264.364), oklch(55.1% 0.014 264.364));
    --card-edge: light-dark(oklch(94.6% 0.003 264.542), oklch(100% 0 0 / 0.11));
    --card-1: light-dark(#00000008, transparent);
    --card-2: light-dark(#00000005, transparent);
    --card-3: light-dark(#00000003, transparent);
    --card-night: light-dark(transparent, oklch(0% 0 0 / 0.2));
    --button-edge: light-dark(oklch(91.2% 0.005 258.326), oklch(100% 0 0 / 0.1));
    --button-halo: light-dark(#0000000a, transparent);
    --button-drop: light-dark(transparent, oklch(0% 0 0 / 0.3));
  }
}

/* The colorScheme prop. Without it, nothing is set: color-scheme is inherited from the page. */
.root[data-color-scheme='light'] {
  color-scheme: light;
}
.root[data-color-scheme='dark'] {
  color-scheme: dark;
}
.root[data-color-scheme='system'] {
  color-scheme: light dark;
}

.root :focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* The column: 480 px at most, and the list 380 px in its middle. */
.frame {
  width: 100%;
  max-width: 480px;
}
.list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  width: 100%;
  max-width: 380px;
  margin-inline: auto;
}

/* The heading, and the count in its sunk little box. */
.heading {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 2px;
}
.title {
  margin: 0;
  color: var(--ink);
  font-size: 13px;
  font-weight: 600;
  line-height: 19.5px;
}
.count {
  display: inline-flex;
  align-items: center;
  height: 20px;
  padding: 0 6px;
  border-radius: 6px;
  background: var(--inset);
  box-shadow: 0 0 0 1px var(--line);
  color: var(--ink-2);
  font-size: 11.5px;
  font-weight: 500;
  line-height: 17.25px;
  font-variant-numeric: tabular-nums;
}

/* The cards. */
.cards {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.card {
  overflow: hidden;
  border-radius: 10px;
  background: var(--surface);
  box-shadow:
    0 0 0 1px var(--card-edge),
    0 18px 47px var(--card-1),
    0 7.5px 19px var(--card-2),
    0 4px 10.5px var(--card-2),
    0 2.3px 5.8px var(--card-3),
    0 1.2px 3.1px var(--card-3),
    0 0.5px 1.3px var(--card-3),
    0 1px 2px var(--card-night),
    0 2px 6px var(--card-night);
}

/* A card's bar: the glyph and title, and the chunk's length on the right. */
.bar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  border-bottom: 1px solid var(--line);
}
.chunkTitle {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  color: var(--ink);
  font-size: 13px;
  font-weight: 500;
  line-height: 19.5px;
}
.lines {
  flex: none;
  width: 11px;
  height: 11px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.15;
  stroke-linecap: round;
}
.truncate {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.size {
  flex: none;
  margin-left: auto;
  color: var(--ink-3);
  font-size: 12px;
  line-height: 18px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.text {
  margin: 0;
  padding: 8px 12px 4px;
  color: var(--ink-2);
  font-size: 12.5px;
  line-height: 1.625;
  text-wrap: pretty;
}

/* The source: a pill with the kind's badge, the name and an arrow. It sits on a 21 px line, as
   inline text would. */
.source {
  min-width: 0;
  padding: 0 12px 12px;
  font-size: 14px;
  line-height: 21px;
}
.chip {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  box-sizing: border-box;
  max-width: 100%;
  height: 24px;
  margin: 0;
  padding: 0 8px;
  border: 0;
  border-radius: 999px;
  background-color: var(--inset);
  box-shadow:
    0 0 0 1px var(--button-edge),
    0 0 4px var(--button-halo),
    0 1px 2px var(--button-drop);
  color: var(--ink-2);
  font: inherit;
  font-size: 12px;
  font-weight: 500;
  line-height: 18px;
  letter-spacing: inherit;
  text-decoration: none;
  vertical-align: baseline;
  transition:
    opacity 0.3s cubic-bezier(0.23, 1, 0.32, 1),
    transform 0.3s cubic-bezier(0.23, 1, 0.32, 1),
    background-color 0.3s cubic-bezier(0.23, 1, 0.32, 1);
  transition-delay: var(--chip-delay, 0ms);
}
button.chip,
a.chip {
  cursor: pointer;
}
.badge {
  display: flex;
  flex: none;
  align-items: center;
  justify-content: center;
  width: 14px;
  height: 14px;
  border-radius: 4px;
  background: var(--grey);
  color: #fff;
  font-size: 7px;
  font-weight: 700;
  line-height: 10.5px;
  letter-spacing: -0.14px;
}
.badge[data-kind='pdf'] {
  background: var(--red);
}
.badge[data-kind='csv'] {
  background: var(--green);
}
.badge[data-kind='doc'] {
  background: var(--blue);
}
.name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.arrow {
  flex: none;
  width: 9px;
  height: 9px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.05;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Hovers: the reader's pointer, where it can hover, or the demo's ([data-hover]). */
@media (hover: hover) {
  .chip:hover {
    background-color: var(--hover);
  }
}
.chip[data-hover] {
  background-color: var(--hover);
}

/* The entrance. Before the list first shows, it waits unseen; then the heading fades in, the
   cards rise 8 px as they fade in, 100 ms apart, and the chips pop from 95%, 80 ms apart. */
.root[data-stage='wait'] .heading,
.root[data-stage='wait'] .card {
  opacity: 0;
}
.root[data-stage='wait'] .chip {
  opacity: 0;
  transform: scale(0.95);
}
.root[data-stage='in'] .heading {
  animation: fade-in 0.4s ease-out both;
}
.root[data-stage='in'] .card {
  animation: rise 0.4s cubic-bezier(0.23, 1, 0.32, 1) var(--card-delay, 0ms) both;
}
@keyframes fade-in {
  from {
    opacity: 0;
  }
}
@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(8px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .root * {
    animation: none !important;
    transition: none !important;
  }
}
```

### context-cards/data.ts

```tsx
/** A source's kind: the coloured badge its chip shows (PDF in red, CSV in green, the rest grey). */
export type SourceKind = 'pdf' | 'csv' | 'doc' | 'web';

/** Where a chunk was retrieved from. */
export type ChunkSource = {
  /** The file name, or the page's title. */
  name: string;
  kind: SourceKind;
  /** Opens the source in a new tab; without it, the chip is a button when onOpenSource is set. */
  href?: string;
};

/** One retrieved chunk of knowledge. */
export type Chunk = {
  id: string;
  title: string;
  /** Its length, in characters. */
  characters: number;
  /** The chunk's text, or the excerpt of it the card shows. */
  text: string;
  source: ChunkSource;
};

/** The sample retrieval: two chunks that would answer a question from a small dessert shop. */
export const CHUNKS: Chunk[] = [
  {
    id: 'vendor-rule',
    title: 'Vendor onboarding rule',
    characters: 290,
    text: 'Cold-chain certification must be verified before a new dairy can be added to the reorder workflow.',
    source: { name: 'Dairy Onboarding SOP.pdf', kind: 'pdf' },
  },
  {
    id: 'seasonal-demand',
    title: 'Seasonal demand row',
    characters: 1250,
    text: 'Q4 velocity table: pistachio +18%, vanilla +6%, rocky road -11%; retire flavors below 40 scoops weekly.',
    source: { name: 'Sales Velocity Export.csv', kind: 'csv' },
  },
];
```

### context-cards/demo-pointer.tsx

```tsx
'use client';

import type { CSSProperties, Ref } from 'react';

/*
 * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, glide
 * the pointer onto a control, press it and call the component's own handler, as a click would.
 * Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress at once:
 * its promise rejects, which ends the script right there, and the pointer fades out.
 *
 * The pointer is an HTML box around a still drawing: it glides by an animation the compositor
 * plays, and the press scales a box that keeps a transform at rest, so nothing it does needs a
 * layout.
 */

export type Point = { x: number; y: number };

export type Demo = {
  /** Resolves after `ms`. */
  wait: (ms: number) => Promise<void>;
  /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */
  moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>;
  /** Presses and lets go; `action` runs as the pointer goes down. */
  press: (action: () => void) => Promise<void>;
};

/** What a stopped demo's pending step rejects with. */
class Stopped extends Error {}

/** Where the arrow's tip is, in its 22 × 22 box: the point the pointer acts on. */
const TIP: Point = { x: 3.3, y: 2.2 };

const place = ({ x, y }: Point) => `translate(${x - TIP.x}px, ${y - TIP.y}px)`;

/** Plays `script` with the pointer over `stage`; returns the function that stops it. */
export function runDemo(
  stage: HTMLElement,
  pointer: HTMLElement,
  script: (demo: Demo) => Promise<void>,
): () => void {
  let alive = true;
  let flight: Animation | null = null;
  const timers = new Set<number>();
  const pending = new Set<() => void>();
  // The pointer comes in from the lower right.
  let at: Point = { x: stage.offsetWidth * 0.86, y: stage.offsetHeight * 0.96 };

  /** Settles like `promise`, or rejects as soon as the demo stops. */
  const step = (promise: Promise<unknown>) =>
    new Promise<void>((resolve, reject) => {
      if (!alive) return reject(new Stopped());
      const cancel = () => reject(new Stopped());
      pending.add(cancel);
      promise.then(
        () => {
          pending.delete(cancel);
          if (alive) resolve();
        },
        () => {
          pending.delete(cancel);
          reject(new Stopped());
        },
      );
    });

  const wait = (ms: number) =>
    step(
      new Promise<void>((resolve) => {
        const timer = window.setTimeout(() => {
          timers.delete(timer);
          resolve();
        }, ms);
        timers.add(timer);
      }),
    );

  /** Glides to `to`, in the stage's own px. */
  const glide = (to: Point) => {
    const from = at;
    at = to;
    const dx = to.x - from.x;
    const dy = to.y - from.y;
    // A gentle curve, as a hand moves: the control point sits off the straight line.
    const bend = { x: (from.x + to.x) / 2 - dy * 0.16, y: (from.y + to.y) / 2 + dx * 0.16 };
    const keyframes: Keyframe[] = [];
    for (let i = 0; i <= 12; i += 1) {
      const t = i / 12;
      const u = 1 - t;
      keyframes.push({
        transform: place({
          x: u * u * from.x + 2 * u * t * bend.x + t * t * to.x,
          y: u * u * from.y + 2 * u * t * bend.y + t * t * to.y,
        }),
      });
    }
    flight?.cancel();
    // Where it rests once the flight is over.
    pointer.style.transform = place(to);
    flight = pointer.animate(keyframes, {
      duration: Math.min(950, Math.max(380, Math.hypot(dx, dy) * 1.6)),
      easing: 'cubic-bezier(0.45, 0, 0.2, 1)',
    });
    return step(flight.finished);
  };

  const moveTo: Demo['moveTo'] = (target, share = { x: 0.5, y: 0.5 }) => {
    if (!target) return step(Promise.resolve());
    const frame = stage.getBoundingClientRect();
    // Screen px to the stage's own, should the stage sit in a scaled container.
    const scale = frame.width / stage.offsetWidth || 1;
    const box = target.getBoundingClientRect();
    return glide({
      x: (box.left + box.width * share.x - frame.left) / scale,
      y: (box.top + box.height * share.y - frame.top) / scale,
    });
  };

  const press: Demo['press'] = async (action) => {
    // The glyph dips towards its tip.
    const tap = pointer.firstElementChild?.animate(
      [
        { transform: 'scale(1)' },
        { transform: 'scale(0.82)', offset: 0.35 },
        { transform: 'scale(1)' },
      ],
      { duration: 280, easing: 'ease-in-out' },
    );
    await wait(95);
    action();
    if (tap) await step(tap.finished);
  };

  pointer.style.transform = place(at);
  pointer.style.opacity = '1';
  script({ wait, moveTo, press }).catch((error: unknown) => {
    if (!(error instanceof Stopped)) throw error;
  });

  return () => {
    if (!alive) return;
    alive = false;
    timers.forEach((timer) => window.clearTimeout(timer));
    flight?.cancel();
    pending.forEach((cancel) => cancel());
    pointer.style.opacity = '0';
  };
}

/** The pointer's box: it moves, on a layer of its own. */
const POINTER: CSSProperties = {
  position: 'absolute',
  top: 0,
  left: 0,
  zIndex: 10,
  display: 'block',
  width: 22,
  height: 22,
  pointerEvents: 'none',
  opacity: 0,
  transition: 'opacity 0.3s ease',
  filter: 'drop-shadow(0 1px 1.5px var(--pointer-shadow))',
  willChange: 'transform',
};

/** Inside it, what dips towards the tip when the pointer presses. */
const PRESS: CSSProperties = {
  display: 'block',
  width: 22,
  height: 22,
  transform: 'scale(1)',
  transformOrigin: `${TIP.x}px ${TIP.y}px`,
};

const DRAWING: CSSProperties = { display: 'block', width: 22, height: 22, overflow: 'visible' };
/** The arrow's colours, like its shadow's, are the stage's tokens (context-cards.module.css). */
const ARROW: CSSProperties = { fill: 'var(--pointer)', stroke: 'var(--pointer-edge)' };

/** The arrow the demo moves around. It is decorative: the demo's effects show on the controls. */
export function DemoPointer({ ref }: { ref: Ref<HTMLSpanElement> }) {
  return (
    <span ref={ref} style={POINTER} aria-hidden="true">
      <span style={PRESS}>
        <svg viewBox="0 0 20 20" style={DRAWING}>
          <path
            d="M3 2v14.5l3.6-3.3 2.6 5.6 2.4-1.1-2.6-5.5h5.2z"
            style={ARROW}
            strokeWidth="1.3"
            strokeLinejoin="round"
          />
        </svg>
      </span>
    </span>
  );
}
```

### context-cards/use-autoplay.ts

```tsx
'use client';

import { useCallback, useEffect, useRef, useState } from 'react';

/*
 * When a demo may play on its own: while part of it is on screen and the tab is visible, never
 * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it
 * takes back over `idle` ms after their last input.
 */
export function useAutoplay<T extends HTMLElement>(idle = 6000) {
  const ref = useRef<T>(null);
  const [visible, setVisible] = useState(false);
  const [reduced, setReduced] = useState(true);
  const [taken, setTaken] = useState(false);
  const timer = useRef(0);

  useEffect(() => {
    const element = ref.current;
    if (!element) return;
    let onScreen = false;
    const update = () => setVisible(onScreen && !document.hidden);
    const observer = new IntersectionObserver(
      ([entry]) => {
        onScreen = entry.isIntersecting;
        update();
      },
      { threshold: 0.3 },
    );
    observer.observe(element);
    document.addEventListener('visibilitychange', update);
    const motion = window.matchMedia('(prefers-reduced-motion: reduce)');
    const readMotion = () => setReduced(motion.matches);
    readMotion();
    motion.addEventListener('change', readMotion);
    return () => {
      observer.disconnect();
      document.removeEventListener('visibilitychange', update);
      motion.removeEventListener('change', readMotion);
    };
  }, []);

  const take = useCallback(() => {
    setTaken(true);
    window.clearTimeout(timer.current);
    timer.current = window.setTimeout(() => setTaken(false), idle);
  }, [idle]);
  useEffect(() => () => window.clearTimeout(timer.current), []);

  return { ref, take, playing: visible && !reduced && !taken };
}
```