LAB/ Tanishk Interface Lab
SystemsexperimentalReactView Transitions APICSS

Morphing Metadata

A record that grows from compact summary into structured detail.

Live preview

Workspace

Q1 export bundle

3 records
All records/File · 248 MBvalues keep their place
  • archive.tar.gzFile · 248 MB
    Size248 MBModifiedMar 14, 2026OwnerR. Iyer
  • Maya OkonkwoPerson · Foundations
    RoleStaff designerTeamDesign systemsLocationLisbon
  • nightly-export #481Run · 4m 12s
    StatusPassedRecords18,204Duration4m 12s

Other records toggle independently — the list holds their own state.

RecordExpandedMorph

Toggle expanded and watch how the values move

01What it does

Compact and expanded are the same surface, not two widgets: the summary keys become detail rows, the numbers keep their position, and the transition is a morph rather than an accordion unfolding underneath. Where the browser supports the View Transitions API the swap is a true cross-fade of shared geometry; everywhere else it degrades to a clean, quick re-layout.

In this previewThree record shapes — a file, a person, a pipeline run. Toggle expanded and watch the summary values slide into the detail list instead of a panel opening below.

02Install

Registry install

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

npx shadcn@latest add <lab-origin>/r/morphing-metadata.json

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

  • components/lab/morphing-metadata/morphing-metadata.tsxcomponent
  • components/lab/morphing-metadata/morphing-metadata.cssstyles

Machine-readable item: /r/morphing-metadata.json ↗

03Usage

morphing-metadata.usage.tsx
const record = {
id: "run-418",
title: "nightly-export",
kind: "Run · 4m 12s",
summary: { status: "Passed", records: "18,204", duration: "4m 12s" },
detail: [
{ label: "Started", value: "02:00 UTC" },
{ label: "Trigger", value: "schedule" },
],
};

<MorphingMetadata record={record} />

04Props

Props accepted by Morphing Metadata
PropTypeDefaultNotes
recordMetadataRecord—{ id, title, kind, summary: { label → value }, detail: { label, value }[] }
expandedboolean—Controlled state; omit for the internal toggle.
defaultExpandedbooleanfalseInitial state in uncontrolled use.
transition"auto" | "view-transition" | "none""auto"auto uses the View Transitions API when available and CSS otherwise.
onExpandedChange(expanded: boolean) => void—Notifies the host of the state change.

05Accessibility

  • One button toggles the record; aria-expanded and a label that changes with state (“Show run details”).
  • Both states render the same values — expansion adds detail, it never replaces information.
  • View Transitions are skipped under prefers-reduced-motion, where the swap is instant.
  • The detail list is a real definition list, so screen readers get label/value pairs.

06Source

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

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

import { useId, useState, type CSSProperties } from "react";
import { flushSync } from "react-dom";
import "./morphing-metadata.css";

export type MetadataRecord = {
id: string;
title: string;
kind: string;
summary: Record<string, string>;
detail: { label: string; value: string }[];
};

export type MorphingMetadataProps = {
/** The record to render. */
record: MetadataRecord;
/** Controlled expansion; omit to let the record toggle itself. */
expanded?: boolean;
/** Initial state in uncontrolled use. */
defaultExpanded?: boolean;
/**
* `auto` morphs with the View Transitions API where it exists and
* settles with a CSS row animation otherwise. `view-transition` asks
* for the API only; `none` swaps states instantly.
*/
transition?: "auto" | "view-transition" | "none";
/** Notifies the host every time the record opens or closes. */
onExpandedChange?: (expanded: boolean) => void;
className?: string;
};

type Row = { key: string; label: string; value: string };

function slug(value: string): string {
const clean = value
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return clean || "x";
}

function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}

/**
* Merge detail over summary, case-insensitively by label: every summary
* pair keeps its place, but when a detail row shares its label the detail
* row replaces the pair instead of duplicating it; the remaining detail
* rows follow. Both states render the same values — expansion only adds.
*/
function buildRows(record: MetadataRecord): Row[] {
const detailByLabel = new Map<string, { label: string; value: string }>();
for (const row of record.detail) {
const key = row.label.trim().toLowerCase();
if (!detailByLabel.has(key)) detailByLabel.set(key, row);
}

const rows: Row[] = [];
const seen = new Set<string>();
for (const [label, value] of Object.entries(record.summary)) {
const key = label.trim().toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
const match = detailByLabel.get(key);
rows.push(
match
? { key, label: match.label, value: match.value }
: { key, label, value },
);
}
for (const row of record.detail) {
const key = row.label.trim().toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
rows.push({ key, label: row.label, value: row.value });
}
return rows;
}

/**
* A view-transition-name may appear only once in a document per capture,
* so names are computed from the labels present in the current state and
* duplicates (two labels slugifying alike) are skipped silently.
*/
function namesFor(labels: string[], base: string): (string | undefined)[] {
const seen = new Set<string>();
return labels.map((label) => {
const name = `${base}-${slug(label)}`;
if (seen.has(name)) return undefined;
seen.add(name);
return name;
});
}

function nameStyle(name: string | undefined): CSSProperties | undefined {
return name ? { viewTransitionName: name } : undefined;
}

function RecordGlyph() {
return (
<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" focusable="false">
<path
d="M3.6 1.9h5l3.8 3.8v8.1a.8.8 0 0 1-.8.8H3.6a.8.8 0 0 1-.8-.8V2.7a.8.8 0 0 1 .8-.8Z"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
/>
<path d="M8.6 2v3.7h3.7" fill="none" stroke="currentColor" strokeWidth="1.2" />
</svg>
);
}

/**
* MORPHING METADATA — one surface, two densities.
*
* Compact is a single row: glyph, title, kind and the summary pairs as
* chips. Expanded keeps that row in place and turns the same values into
* a definition list; values shared between summary and detail are named
* with the same `view-transition-name`, so a supporting browser morphs
* them from chip to row instead of opening a panel underneath. The
* View Transition is skipped under prefers-reduced-motion or without the
* API, where `auto` falls back to a short CSS settle on the new rows.
*/
export function MorphingMetadata({
record,
expanded,
defaultExpanded = false,
transition = "auto",
onExpandedChange,
className,
}: MorphingMetadataProps) {
const uid = useId();
const [internal, setInternal] = useState(defaultExpanded);
const [cssMorph, setCssMorph] = useState(false);

const isControlled = expanded !== undefined;
const isExpanded = isControlled ? expanded : internal;

const rows = buildRows(record);
const summaryEntries = Object.entries(record.summary);
const namesOn = transition !== "none";
const vtBase = `mm-${slug(record.id)}`;
const compactNames = namesOn
? namesFor(
summaryEntries.map(([label]) => label),
vtBase,
)
: [];
const detailNames = namesOn ? namesFor(rows.map((row) => row.label), vtBase) : [];

const bodyId = `${uid}-body`;

const commit = (next: boolean) => {
if (!isControlled) setInternal(next);
onExpandedChange?.(next);
};

const handleToggle = () => {
const next = !isExpanded;
if (transition !== "none") {
const reduced = prefersReducedMotion();
if (
!reduced &&
typeof document !== "undefined" &&
typeof document.startViewTransition === "function"
) {
let updated = false;
try {
/* flushSync keeps the React commit inside the capture window,
which is what makes the shared value names actually morph. */
document.startViewTransition(() => {
updated = true;
flushSync(() => commit(next));
});
return;
} catch {
if (updated) return;
}
}
if (transition === "auto" && !reduced) setCssMorph(true);
}
commit(next);
};

return (
<div
className={`mm-root${className ? ` ${className}` : ""}`}
data-expanded={isExpanded ? "true" : "false"}
data-transition={transition}
data-css-morph={cssMorph ? "true" : undefined}
style={namesOn ? { viewTransitionName: vtBase } : undefined}
>
<div className="mm-head">
<span className="mm-glyph" aria-hidden="true">
<RecordGlyph />
</span>

<span className="mm-heading">
<span className="mm-title">{record.title}</span>
<span className="mm-kind">{record.kind}</span>
</span>

{!isExpanded && (
<div className="mm-chips" id={bodyId}>
{summaryEntries.map(([label, value], index) => (
<span className="mm-chip" key={`${label}-${index}`}>
<span className="mm-chip-label">{label}</span>
<span className="mm-chip-value" style={nameStyle(compactNames[index])}>
{value}
</span>
</span>
))}
</div>
)}

<button
type="button"
className="mm-toggle"
aria-expanded={isExpanded}
aria-controls={bodyId}
aria-label={`${isExpanded ? "Hide" : "Show"} details for ${record.title}`}
onClick={handleToggle}
>
<span>{isExpanded ? "Hide details" : "Show details"}</span>
<svg className="mm-caret" viewBox="0 0 10 10" width="9" height="9" aria-hidden="true" focusable="false">
<path
d="M1.7 3.4 5 6.7l3.3-3.3"
fill="none"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
/>
</svg>
</button>
</div>

{isExpanded && (
<dl className="mm-detail" id={bodyId}>
{rows.map((row, index) => (
<div className="mm-row" key={row.key}>
<dt className="mm-term">{row.label}</dt>
<dd className="mm-value" style={nameStyle(detailNames[index])}>
{row.value}
</dd>
</div>
))}
</dl>
)}
</div>
);
}
components/lab/morphing-metadata/morphing-metadata.css— press enter to show or hide the source
css
/* ============================================================
MORPHING METADATA — self-contained styles.
Prefix .mm- · every token is scoped on .mm-root with a fallback,
so the component works in a codebase with no Lab stylesheet.
============================================================ */

.mm-root {
--mm-ink: #141418;
--mm-muted: #5f5f68;
--mm-faint: #8c8c94;
--mm-line: rgba(20, 20, 24, 0.1);
--mm-line-soft: rgba(20, 20, 24, 0.06);
--mm-surface: #ffffff;
--mm-sunken: rgba(20, 20, 24, 0.045);
--mm-accent: #4f27e0;
--mm-ease: cubic-bezier(0.22, 1, 0.36, 1);
--mm-duration: 320ms;

display: block;
color: var(--mm-ink, #141418);
background: var(--mm-surface, #ffffff);
border: 1px solid var(--mm-line, rgba(20, 20, 24, 0.1));
border-radius: 12px;
font-size: 0.8125rem;
line-height: 1.5;
}

/* ---------- compact header: glyph · heading · chips · toggle ---------- */

.mm-head {
display: grid;
grid-template-columns: auto auto minmax(0, 1fr) auto;
grid-template-areas: "glyph heading chips toggle";
align-items: center;
column-gap: 10px;
row-gap: 7px;
padding: 10px 12px;
}

.mm-glyph {
grid-area: glyph;
display: inline-flex;
align-items: center;
color: var(--mm-faint, #8c8c94);
}

.mm-glyph svg {
display: block;
}

.mm-heading {
grid-area: heading;
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 1px 8px;
min-width: 0;
}

.mm-title {
font-size: 0.9rem;
font-weight: 500;
letter-spacing: -0.01em;
overflow-wrap: anywhere;
}

.mm-kind {
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
font-size: 0.625rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--mm-faint, #8c8c94);
}

/* ---------- compact: summary chips ---------- */

.mm-chips {
grid-area: chips;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-width: 0;
}

.mm-chip {
display: inline-flex;
align-items: baseline;
gap: 5px;
max-width: 100%;
padding: 3px 8px;
border-radius: 999px;
background: var(--mm-sunken, rgba(20, 20, 24, 0.045));
}

.mm-chip-label {
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
font-size: 0.59375rem;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--mm-muted, #5f5f68);
}

.mm-chip-value {
font-size: 0.75rem;
font-weight: 500;
overflow-wrap: anywhere;
}

/* ---------- expanded: the same values as a definition list ---------- */

.mm-detail {
margin: 0;
padding: 2px 12px 10px;
}

.mm-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 14px;
padding: 7px 0;
border-top: 1px solid var(--mm-line-soft, rgba(20, 20, 24, 0.06));
}

.mm-row:first-child {
border-top-color: var(--mm-line, rgba(20, 20, 24, 0.1));
}

.mm-term {
flex: 0 0 auto;
margin: 0;
font-family: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;
font-size: 0.59375rem;
letter-spacing: 0.09em;
text-transform: uppercase;
color: var(--mm-muted, #5f5f68);
padding-top: 2px;
}

.mm-value {
margin: 0;
min-width: 0;
text-align: right;
font-size: 0.8125rem;
font-weight: 500;
overflow-wrap: anywhere;
}

/* ---------- toggle ---------- */

.mm-toggle {
grid-area: toggle;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border: 1px solid var(--mm-line, rgba(20, 20, 24, 0.1));
border-radius: 999px;
background: transparent;
color: var(--mm-muted, #5f5f68);
font: inherit;
font-size: 0.6875rem;
font-weight: 500;
line-height: 1.3;
white-space: nowrap;
cursor: pointer;
transition:
background-color 150ms ease,
color 150ms ease,
border-color 150ms ease;
}

.mm-toggle:hover {
background: var(--mm-sunken, rgba(20, 20, 24, 0.045));
color: var(--mm-ink, #141418);
}

.mm-toggle:focus-visible {
outline: 2px solid var(--mm-accent, #4f27e0);
outline-offset: 2px;
}

.mm-caret {
flex: none;
transition: transform var(--mm-duration, 320ms) var(--mm-ease, cubic-bezier(0.22, 1, 0.36, 1));
}

.mm-root[data-expanded="true"] .mm-caret {
transform: rotate(180deg);
}

/* ---------- degradation: CSS settle when no View Transition API ---------- */

@media (prefers-reduced-motion: no-preference) {
.mm-root[data-transition="auto"][data-css-morph="true"] .mm-chip,
.mm-root[data-transition="auto"][data-css-morph="true"] .mm-row {
animation: mm-settle var(--mm-duration, 320ms)
var(--mm-ease, cubic-bezier(0.22, 1, 0.36, 1)) both;
}
}

@keyframes mm-settle {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}

/* ---------- narrow viewports ---------- */

@media (max-width: 30rem) {
.mm-head {
grid-template-columns: auto minmax(0, 1fr) auto;
grid-template-areas:
"glyph heading toggle"
"chips chips chips";
}
}

@media (prefers-reduced-motion: reduce) {
.mm-caret {
transition: none;
}
}