Bruno DelorenceBruno
Appearance
Appearance

Choose your reading theme.

Contact

Context cards

New

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

context-cards/context-cards.tsx
Live · interactive

All chunks

32
  1. Vendor onboarding rule290 characters

    Cold-chain certification must be verified before a new dairy can be added to the reorder workflow.

    Dairy Onboarding SOP.pdf
  2. Seasonal demand row1,250 characters

    Q4 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 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.

You can add this component with the shadcn CLI or by hand:

Into components/context-cards of any React project, with no setup:

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

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

sources.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}    />  );}

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.
PropTypeDefaultDescription
chunksChunk[]a sample retrievalThe retrieved chunks, a card each, in the order they rank: { id, title, characters, text, source: { name, kind: pdf | csv | doc | web, href? } }.
countnumberchunks.lengthHow many chunks the retrieval found in all, for the heading’s count.
headingstring"All chunks"The list’s heading.
defaultHoverstring—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.
autoplaybooleantrueWhile 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.
classNamestring—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.

context-cards.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
context-cards.module.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. */
data.ts
/** 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. */
demo-pointer.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>;
use-autoplay.ts
'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.