Metric panel
Dashboard card that names its source, fails without blanking, and collapses on a phone
npx shadcn@latest add https://ethankd.com/r/metric-panel.jsonSample metric
One sentence on what the reader should take away.
Last 28 days
1,284
Sample data
Failed, with a retained pull
Source unavailable, showing the last successful pull from 5h ago. The upstream request timed out.
1,284
Sample data
Failed, with nothing to fall back on
Source unavailable. No successful pull has been recorded yet.
"use client";
import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ChevronDown } from "lucide-react";
/**
* The card a dashboard figure lives in, with the states a figure actually has.
*
* Most "card" components render a title and children. The work here is in the
* three things a card holding a measurement has to do that a generic card does
* not:
*
* **It names its source, always.** The footer is not optional and not a prop you
* can forget — a figure that does not say where it came from is a claim nobody
* can check.
*
* **It fails without blanking.** When the source is unavailable the panel says
* so, and if a previous pull was retained it keeps those values visible
* underneath at reduced opacity. Blanking the body throws away the only
* information the reader still had; showing stale numbers undimmed invites them
* to be read as current. The banner names their age and the dimming makes them
* look like what they are. The stale body is NOT hidden from assistive
* technology — dimming is a sighted cue, and hiding it would leave those users
* with less information rather than clearer information.
*
* **It collapses on a phone and not on a desktop.** A tab of eight panels is
* six screens of scrolling to reach the one figure you came for. `index` is
* already the panel's position in the visible order, so index 0 — the panel the
* view exists to answer — defaults open and the rest default closed, with no new
* prop and no new metadata. Deterministic on both server and client, so there is
* no hydration mismatch and no shift after paint. Collapsed bodies stay in the
* DOM behind `hidden lg:block` rather than unmounting, which keeps every panel
* present at `lg` and keeps collapsed content findable by in-page search.
*/
type PanelProps = {
title: string;
/** Where the number came from. Rendered on every panel, without exception. */
source: string;
/**
* What the metric measures: what it counts and what it leaves out. Behind an
* info affordance because it is read once and then known. Anything needed to
* read the figure correctly belongs in `note` or `period` instead.
*/
definition?: string;
/** The window this panel covers, stated before the chart rather than after it. */
period?: string;
/** Position in the visible order. Index 0 defaults open below `lg`. */
index: number;
/** The one sentence a reader should take away. */
note?: string;
/** Set when this panel's source failed, so it says why instead of showing zero. */
error?: string;
/**
* How old the values behind a failed panel are, e.g. "5h ago". Present only
* when a previous pull was retained; without it a failed panel shows the
* error alone.
*/
stale?: string;
/** Right-aligned control or badge in the header. */
action?: React.ReactNode;
/** Column span on a 6-column desktop grid. */
span?: 2 | 3 | 4 | 6;
children: React.ReactNode;
};
// Tailwind needs the full class name present in source to emit it, so these
// cannot be built by interpolation.
const SPAN = {
2: "lg:col-span-2",
3: "lg:col-span-3",
4: "lg:col-span-4",
6: "lg:col-span-6",
} as const;
export function MetricPanel({
title,
source,
definition,
period,
index,
note,
error,
stale,
action,
span = 3,
children,
}: PanelProps) {
const [open, setOpen] = useState(index === 0);
const bodyId = useId();
return (
// overflow-hidden is structural, not cosmetic: charts size themselves to a
// minimum regardless of their wrapper, and without it one gauge pushes the
// whole page wider than a phone viewport.
//
// min-h-56 keeps a sparse card from collapsing to a sliver BESIDE a dense
// one, which is a grid problem and therefore an `lg` problem. Holding 224px
// open on a collapsed phone panel to show a title is the opposite of the point.
<section
data-panel
className={`col-span-1 flex min-w-0 flex-col overflow-hidden rounded-iq-panel border border-iq-hairline bg-iq-panel p-4 sm:p-6 lg:min-h-56 ${
open ? "min-h-56" : ""
} ${SPAN[span]}`}
>
<header className={`flex items-start justify-between gap-4 lg:mb-5 ${open ? "mb-5" : "mb-0"}`}>
<div className="min-w-0">
<h3 className="flex items-center gap-1.5 text-[15px] font-medium tracking-tight text-iq-ink">
{title}
{definition ? <InfoTip label={title}>{definition}</InfoTip> : null}
</h3>
{note ? (
<p
className={`mt-1.5 max-w-prose text-iq-micro leading-relaxed text-iq-ink-muted lg:block ${
open ? "" : "hidden"
}`}
>
{note}
</p>
) : null}
</div>
{/* The window belongs here, before the figure. Two cards side by side
read as the same period unless each says otherwise above its own
numbers. */}
<div className="flex shrink-0 items-center gap-2">
{period ? <Pill>{period}</Pill> : null}
{action}
{/* Below lg only: at lg every panel is open and a control that cannot
change anything would be a tab stop that does nothing. */}
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-controls={bodyId}
aria-label={open ? `Collapse ${title}` : `Expand ${title}`}
className="-mr-2 grid size-11 shrink-0 place-items-center rounded-xl text-iq-ink-muted transition-colors duration-200 ease-out hover:text-iq-ink lg:hidden"
>
<ChevronDown
aria-hidden="true"
className={`size-4 transition-transform duration-200 ease-out ${open ? "rotate-180" : ""}`}
/>
</button>
</div>
</header>
<div id={bodyId} data-panel-body className={`min-w-0 flex-1 ${open ? "" : "hidden lg:block"}`}>
{error ? (
<div data-panel-error>
<p className="rounded-xl bg-iq-neg/12 p-3 text-iq-micro text-iq-neg">
{stale
? `Source unavailable, showing the last successful pull from ${stale}. ${error}`
: `Source unavailable. ${error}`}
</p>
{stale ? <div className="mt-4 opacity-40 grayscale">{children}</div> : null}
</div>
) : (
children
)}
</div>
{/* Stays visible even when the body is collapsed. A collapsed card is
still a panel making a claim about where its figure comes from. */}
<footer
data-source
className={`text-iq-micro text-iq-ink-muted lg:mt-5 ${open ? "mt-5" : "mt-3"}`}
>
{source}
</footer>
</section>
);
}
/** Pill badge for panel headers. */
export function Pill({ children }: { children: React.ReactNode }) {
return (
<span className="rounded-full bg-iq-panel-raised px-2.5 py-1 text-iq-micro font-medium text-iq-ink-muted">
{children}
</span>
);
}
/**
* The "what is this metric" affordance beside a panel headline.
*
* Scope, deliberately narrow: this carries the DEFINITION of a metric, not
* anything needed to read the number correctly. Units, the time window and
* exclusions stay in the visible layout, because a reader who could misread the
* figure without them is not being offered detail on request, they are being
* denied part of the value.
*
* WCAG 2.2 SC 1.4.13 governs hover-revealed content, and it is why this is a
* real button rather than a `title` attribute. The content is dismissible with
* Escape, hoverable (the pointer can travel onto it without it vanishing, so it
* can be read and selected), and persistent until dismissed. A native tooltip
* satisfies none of the three and cannot be reached by keyboard at all.
*
* It renders through a portal because the panel sets `overflow-hidden`, which is
* structural rather than cosmetic. A tooltip positioned inside that card is
* clipped at the card edge, so any panel whose info button sits near an edge
* shows a truncated definition. Fixed positioning against the trigger escapes
* the clip entirely, and the left edge is clamped into the viewport so a tooltip
* near either side stays fully readable.
*/
export function InfoTip({ label, children }: { label: string; children: string }) {
const [open, setOpen] = useState(false);
const [box, setBox] = useState<{ top: number; left: number } | null>(null);
const id = useId();
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLDivElement>(null);
// Hover close is delayed so the pointer can cross the gap between trigger and
// panel without the content vanishing mid-journey.
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const WIDTH = 256;
const MARGIN = 12;
const place = useCallback(() => {
const t = trigger.current?.getBoundingClientRect();
if (!t) return;
// clientWidth rather than innerWidth: innerWidth includes the scrollbar
// gutter, which would push the panel under it.
const vw = document.documentElement.clientWidth;
const ideal = t.left + t.width / 2 - WIDTH / 2;
setBox({
top: t.bottom + 8,
left: Math.min(Math.max(MARGIN, ideal), vw - WIDTH - MARGIN),
});
}, []);
useLayoutEffect(() => {
if (open) place();
}, [open, place]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") setOpen(false);
}
function onDown(e: MouseEvent) {
const node = e.target as Node;
if (!trigger.current?.contains(node) && !panel.current?.contains(node)) setOpen(false);
}
// Anchored to the trigger, so it follows the trigger when the page moves.
// Capture phase, because the scroll may happen in a nested scroller.
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onDown);
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onDown);
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
};
}, [open, place]);
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
function show() {
if (timer.current) clearTimeout(timer.current);
setOpen(true);
}
function hideSoon() {
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setOpen(false), 240);
}
return (
<>
<button
ref={trigger}
type="button"
data-infotip
aria-expanded={open}
aria-controls={open ? id : undefined}
onClick={() => setOpen((o) => !o)}
onMouseEnter={show}
onMouseLeave={hideSoon}
onFocus={show}
onBlur={hideSoon}
// The painted marker stays 16px; the TOUCH target is 44 via a
// transparent pseudo-element. WCAG 2.2 SC 2.5.8 measures the target,
// not the ink, so the design does not have to grow for the finger to
// land on it. 16 + 2*14 = 44.
className="relative grid size-4 shrink-0 place-items-center rounded-full border border-iq-hairline text-[10px] leading-none text-iq-ink-muted transition-colors duration-200 before:absolute before:-inset-3.5 before:content-[''] hover:border-iq-hairline-strong hover:text-iq-ink"
>
<span aria-hidden="true">i</span>
<span className="sr-only">What {label} measures</span>
</button>
{open && box
? createPortal(
<div
ref={panel}
id={id}
role="note"
onMouseEnter={show}
onMouseLeave={hideSoon}
style={{ top: box.top, left: box.left, width: WIDTH }}
// The entrance is a native @starting-style (Tailwind's `starting:`
// variant), not a mounted-but-closed state flag and a rAF. The panel
// is authored in its final state; the browser supplies the frame
// before it, which is exactly what the flag was faking.
className="fixed z-50 origin-top scale-100 rounded-iq-core border border-iq-hairline bg-iq-panel-raised p-3 text-iq-micro font-normal leading-relaxed text-iq-ink-muted opacity-100 transition-[opacity,transform] duration-150 ease-out starting:translate-y-1 starting:scale-[0.98] starting:opacity-0 motion-reduce:transition-none"
>
{children}
</div>,
document.body,
)
: null}
</>
);
}
Playground
Sample metric
One sentence on what the reader should take away.
Last 28 days
1,284
Sample data