Context cards
NewThe chunks a retrieval brought back, a card each, with the file each one came from.
context-cards/context-cards.tsxAll chunks
32Cold-chain certification must be verified before a new dairy can be added to the reorder workflow.
Dairy Onboarding SOP.pdfQ4 velocity table: pistachio +18%, vanilla +6%, rocky road -11%; retire flavors below 40 scoops weekly.
Sales Velocity Export.csv
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
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
countfor 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.
You can add this component with the shadcn CLI or by hand:
Into components/context-cards of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/context-cards.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: 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
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} /> );}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
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.
- 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.
| Prop | Type | Default | Description |
|---|---|---|---|
chunks | Chunk[] | 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 | chunks.length | How many chunks the retrieval found in all, for the heading’s count. |
heading | string | "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 | 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. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// 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/* 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. *//** 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. */'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>;'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;- 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.