LAB/ Tanishk Interface Lab
AgenticstableReactCSS grid transitions

Expandable Evidence Source

A citation that unfolds from one line into an inspectable source card.

Live preview

How is progressive disclosure described in the interface literature?

Answer

3 sources

Most descriptions of progressive disclosure keep the primary surface legible and defer rarely used options to a second step, with sensible defaults standing in until someone asks for more. The same accounts usually insist on a visible way back to the full set, so the deferred options never read as removed.

Sources

Progressive disclosure defers advanced or rarely used features to a secondary screen, making applications easier to learn and less error-prone. The pattern assumes the primary view can stand on its own, with defaults that are sensible before any configuration happens.

Location
Ch. 4, p. 71
Date
2020
Publisher
O'Reilly Media
Confidence86%

Across a semester of first-run observations, participants rarely opened the advanced panel; those who did typically arrived after a failed first attempt, which suggests the primary path was quietly doing the work of the secondary one.

Location
§3.2
Date
2021
Publisher
Field Methods Workshop
Confidence61%

One answer argues for keeping the advanced panel but dropping its link from the empty state, so the full set of options stays one deliberate step away instead of two screens deep.

Date
2023
Publisher
UI Craft forum
Confidence28%
StatusExpandedConfidence meterThree sources

Click a source line to unfold it

01What it does

Compact when you are skimming, complete when you are checking. One line carries the claim’s source; unfolding grows the excerpt, the location, a confidence/status reading and the way out to the original. Built for AI answers, research tools and education products where “trust me” is not an acceptable citation.

In this previewThree sources with different strengths — a book, a study, a forum answer — so the status and confidence readings have something to say. Unfold any line.

02Install

Registry install

Works with the shadcn CLI in any project that has a components.json. The component lands in components/lab/evidence-source/ with its stylesheet beside it.

npx shadcn@latest add <lab-origin>/r/evidence-source.json

No npm packages are installed — the component is self-contained. Prefer manual copying? Every file is below under Source.

  • components/lab/evidence-source/evidence-source.tsxcomponent
  • components/lab/evidence-source/evidence-source.cssstyles

Machine-readable item: /r/evidence-source.json ↗

03Usage

evidence-source.usage.tsx
import { EvidenceSource } from "@/components/lab/evidence-source/evidence-source";

export function Citation() {
return (
<EvidenceSource
source={{
id: "s1",
title: "Designing Interfaces, 3rd ed.",
publisher: "O'Reilly",
excerpt:
"Progressive disclosure defers advanced or rarely used features to a secondary screen…",
location: "Ch. 4, p. 71",
status: "verified",
confidence: 0.86,
href: "https://example.com/book",
}}
/>
);
}

04Props

Props accepted by Expandable Evidence Source
PropTypeDefaultNotes
sourceEvidenceSourceData—{ id, title, publisher?, excerpt, location?, status?, confidence?, date?, href? }
defaultOpenbooleanfalseStart unfolded.
open / onOpenChangeboolean / (open: boolean) => void—Controlled expansion.
status"verified" | "probable" | "unverified""verified"How well the source supports the claim.
confidencenumber—0–1; renders as a small meter when provided.
onOpenSource(source) => void—Fired by the “Open source” action so hosts can route it.

05Accessibility

  • The summary line is a single button with aria-expanded; the panel is a labelled region.
  • Status is written out (“Verified”) as well as coloured, and the confidence meter has a text value.
  • Expansion animates grid-template-rows, which collapses to an instant state change under reduced motion.
  • Long excerpts wrap and clamp gracefully; the open-source action is a real link when href is provided.

06Source

2 files, 495 lines. Nothing private, no configuration required.

components/lab/evidence-source/evidence-source.tsx— press enter to show or hide the source
tsx
"use client";

import { useId, useState } from "react";
import "./evidence-source.css";

export type EvidenceStatus = "verified" | "probable" | "unverified";

export type EvidenceSourceData = {
id: string;
title: string;
publisher?: string;
excerpt: string;
location?: string;
status?: EvidenceStatus;
confidence?: number;
date?: string;
href?: string;
};

export type EvidenceSourceProps = {
source: EvidenceSourceData;
/** Start unfolded. */
defaultOpen?: boolean;
/** Controlled expansion. */
open?: boolean;
onOpenChange?: (open: boolean) => void;
/** Overrides source.status when provided. */
status?: EvidenceStatus;
/** 0–1; renders as a small meter when provided. */
confidence?: number;
/** Fired by the “Open source” action so hosts can route it. */
onOpenSource?: (source: EvidenceSourceData) => void;
className?: string;
};

const STATUS_LABEL: Record<EvidenceStatus, string> = {
verified: "Verified",
probable: "Probable",
unverified: "Unverified",
};

const clamp01 = (value: number) => Math.min(1, Math.max(0, value));

/**
* EXPANDABLE EVIDENCE SOURCE — a citation that unfolds in place.
*
* Collapsed it is one compact line: glyph, title, status word, chevron.
* Unfolded it grows into an inspectable card — excerpt, location, date,
* publisher, a confidence reading and the way out to the original.
* The expansion is a grid-template-rows transition, so it opens in
* flow without measuring anything; reduced motion makes it instant.
*/
export function EvidenceSource({
source,
defaultOpen = false,
open,
onOpenChange,
status,
confidence,
onOpenSource,
className,
}: EvidenceSourceProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const panelId = useId();

const isControlled = open !== undefined;
const isOpen = isControlled ? open : internalOpen;
const effectiveStatus = status ?? source.status ?? "verified";
const effectiveConfidence = confidence ?? source.confidence;

const toggle = () => {
const next = !isOpen;
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
};

const meta = [
source.location ? { label: "Location", value: source.location } : null,
source.date ? { label: "Date", value: source.date } : null,
source.publisher ? { label: "Publisher", value: source.publisher } : null,
].filter((item): item is { label: string; value: string } => item !== null);

const confidencePercent =
typeof effectiveConfidence === "number"
? Math.round(clamp01(effectiveConfidence) * 100)
: null;

return (
<div
className={`es-root${className ? ` ${className}` : ""}`}
data-open={isOpen ? "true" : "false"}
>
<button
type="button"
className="es-summary"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={toggle}
>
<span className="es-glyph" aria-hidden="true">
<svg
viewBox="0 0 16 16"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 1.75h5.25L12.5 5v9.25h-8.5z" />
<path d="M9.25 1.75V5h3.25" />
</svg>
</span>
<span className="es-title">{source.title}</span>
<span className="es-sep" aria-hidden="true">
·
</span>
<span className="es-status" data-status={effectiveStatus}>
<span className="es-status-dot" aria-hidden="true" />
{STATUS_LABEL[effectiveStatus]}
</span>
<span className="es-chevron" aria-hidden="true">
<svg
viewBox="0 0 12 12"
width="11"
height="11"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M2.5 4.5 6 8l3.5-3.5" />
</svg>
</span>
</button>

<div
id={panelId}
className="es-panel"
role="region"
aria-label={`Source details: ${source.title}`}
inert={!isOpen}
>
<div className="es-panel-inner">
<div className="es-body">
<p className="es-excerpt">{source.excerpt}</p>

{meta.length > 0 ? (
<dl className="es-meta">
{meta.map((item) => (
<div className="es-meta-row" key={item.label}>
<dt>{item.label}</dt>
<dd>{item.value}</dd>
</div>
))}
</dl>
) : null}

{confidencePercent !== null ? (
<div className="es-confidence">
<span className="es-confidence-label">Confidence</span>
<span className="es-meter" aria-hidden="true">
<span
className="es-meter-fill"
style={{ width: `${confidencePercent}%` }}
/>
</span>
<span className="es-confidence-value">
{confidencePercent}%
</span>
</div>
) : null}

<div className="es-open-row">
{source.href ? (
<a
className="es-open"
href={source.href}
target="_blank"
rel="noreferrer"
onClick={() => onOpenSource?.(source)}
>
Open source <span aria-hidden="true">↗</span>
<span className="es-sr-only sr-only">
{" "}
(opens in a new tab)
</span>
</a>
) : (
<button
type="button"
className="es-open"
onClick={() => onOpenSource?.(source)}
>
Open source <span aria-hidden="true">↗</span>
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}
components/lab/evidence-source/evidence-source.css— press enter to show or hide the source
css
/* ============================================================
EXPANDABLE EVIDENCE SOURCE — self-contained styles.
Scoped tokens with fallbacks; the unfolding is a
grid-template-rows transition with a visibility hand-off so a
collapsed panel is never in the tab order or the a11y tree.
============================================================ */

.es-root {
--es-ink: #141418;
--es-ink-soft: #3a3a42;
--es-muted: #5f5f68;
--es-faint: #8c8c94;
--es-surface: #ffffff;
--es-surface-soft: #f8f8f6;
--es-line: rgba(20, 20, 24, 0.12);
--es-line-soft: rgba(20, 20, 24, 0.07);
--es-accent: #4f27e0;
--es-accent-deep: #32119c;
--es-ok: #147a4e;
--es-warn: #8a5300;
--es-grey: #8c8c94;
--es-serif: Georgia, "Iowan Old Style", "Times New Roman", serif;
--es-mono: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
--es-ease: cubic-bezier(0.22, 1, 0.36, 1);

min-width: 0;
border: 1px solid var(--es-line-soft);
border-radius: 12px;
background: var(--es-surface);
color: var(--es-ink);
font-size: 0.875rem;
line-height: 1.5;
}

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

/* Visually hidden, in case the host has no utility class of its own. */
.es-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}

/* ---- summary row ---- */

.es-summary {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 10px 12px;
border: 0;
border-radius: 12px;
background: none;
font: inherit;
color: var(--es-ink);
text-align: left;
cursor: pointer;
}

.es-root[data-open="true"] .es-summary {
border-radius: 12px 12px 0 0;
}

.es-summary:hover {
background: var(--es-surface-soft);
}

.es-summary:focus-visible {
outline-offset: -2px;
}

.es-glyph {
display: inline-flex;
flex: none;
color: var(--es-faint);
}

.es-title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
font-size: 0.875rem;
font-weight: 500;
text-overflow: ellipsis;
white-space: nowrap;
}

.es-sep {
flex: none;
color: var(--es-faint);
}

.es-status {
display: inline-flex;
align-items: center;
gap: 5px;
flex: none;
font-size: 0.75rem;
font-weight: 500;
}

.es-status[data-status="verified"] {
color: var(--es-ok);
}

.es-status[data-status="probable"] {
color: var(--es-warn);
}

.es-status[data-status="unverified"] {
color: var(--es-grey);
}

.es-status-dot {
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}

.es-chevron {
display: inline-flex;
flex: none;
color: var(--es-faint);
transition: transform 240ms var(--es-ease);
}

.es-root[data-open="true"] .es-chevron {
transform: rotate(180deg);
}

/* ---- unfolding panel ---- */

.es-panel {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
visibility: hidden;
transition:
grid-template-rows 320ms var(--es-ease),
opacity 220ms ease,
visibility 0s linear 320ms;
}

.es-root[data-open="true"] .es-panel {
grid-template-rows: 1fr;
opacity: 1;
visibility: visible;
transition:
grid-template-rows 320ms var(--es-ease),
opacity 220ms ease 40ms,
visibility 0s;
}

.es-panel-inner {
min-height: 0;
overflow: hidden;
}

.es-body {
padding: 12px;
border-top: 1px solid var(--es-line-soft);
}

/* ---- excerpt (system serif, roomier leading — no new fonts) ---- */

.es-excerpt {
margin: 0;
font-family: var(--es-serif);
font-size: 0.9375rem;
font-style: normal;
line-height: 1.75;
color: var(--es-ink-soft);
overflow-wrap: anywhere;
}

/* ---- meta grid ---- */

.es-meta {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px 16px;
margin: 12px 0 0;
}

.es-meta-row {
min-width: 0;
}

.es-meta dt {
font-family: var(--es-mono);
font-size: 0.5625rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--es-faint);
}

.es-meta dd {
margin: 2px 0 0;
font-size: 0.8125rem;
color: var(--es-ink-soft);
overflow-wrap: anywhere;
}

/* ---- confidence ---- */

.es-confidence {
display: flex;
align-items: center;
gap: 10px;
margin-top: 12px;
}

.es-confidence-label {
flex: none;
font-size: 0.75rem;
color: var(--es-muted);
}

.es-meter {
flex: 1 1 auto;
min-width: 48px;
height: 4px;
border-radius: 999px;
background: rgba(20, 20, 24, 0.08);
overflow: hidden;
}

.es-meter-fill {
display: block;
height: 100%;
border-radius: 999px;
background: var(--es-ink-soft);
}

.es-confidence-value {
flex: none;
font-family: var(--es-mono);
font-size: 0.6875rem;
color: var(--es-muted);
}

/* ---- open action ---- */

.es-open-row {
margin-top: 12px;
}

.es-open {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 0;
border: 0;
background: none;
font: inherit;
font-size: 0.8125rem;
font-weight: 500;
color: var(--es-accent-deep);
text-decoration: underline;
text-decoration-color: rgba(50, 17, 156, 0.35);
text-underline-offset: 3px;
cursor: pointer;
}

.es-open:hover {
text-decoration-color: currentColor;
}

@media (prefers-reduced-motion: reduce) {
.es-panel,
.es-root[data-open="true"] .es-panel,
.es-chevron {
transition: none;
}
}