Three numbers return to the page: total records, days in range, items in view. They could be three components. They are one, used three times, and the difference between those two designs is the subject of this lesson. Each card also carries a sparkline behind it and a skeleton in front of it before data arrives. Checkpoint: ui-04.

One element, three instances

The tempting shape is <records-card>, <days-card>, <items-card> — three files that differ in one expression each. The version that survives contact with a fourth metric is a parameter:

<stat-card label="Total Records" metric="records"
    tip="COUNT(*) of rows matching the current filters. The sparkline tracks rows per day."></stat-card>
<stat-card label="Date Range" metric="days"
    tip="Length of the selected date range from the slider."></stat-card>
<stat-card label="Items in View" metric="items"
    tip="Distinct items in the current view. The sparkline tracks distinct items per day."></stat-card>
@property({ type: String }) label = '';
@property({ type: String }) metric: 'records' | 'days' | 'items' = 'records';
@property({ type: String }) tip = '';

@property declares a reactive property backed by an attribute: HTML sets it, Lit re-renders on change. The union type on metric means a typo is a compile error in the TypeScript that reads it, and the component selects which computed to read from the store based on it.

Adding a fourth metric is now one line of HTML and one branch — not a new file, a new import, and a new set of styles that drift from the other three by Thursday.

Semantics before styling

A label and a value is a description list, and using the right element is free:

html`
  <dl>
    <dt class="stat-label">${this.label}${this.tip
        ? html` <info-tip .text=${this.tip}></info-tip>`
        : nothing}</dt>
    <dd><data value=${raw}>${formatted}</data></dd>
  </dl>
`

<dl>/<dt>/<dd> says these are name-value pairs. <data value> carries the machine-readable number alongside the human-readable one, so 20,000 displays with its separator while 20000 stays available to anything parsing the page.

Two Lit details worth naming. nothing is the sentinel for “render no node” — returning '' or null leaves an empty text node, which is usually harmless and occasionally the reason a CSS sibling selector misses. And .text=${...} is a property binding rather than an attribute: the dot means the value is assigned to the element’s JavaScript property directly, which is how objects and arrays cross into a component without being stringified.

Loading is a computed, not a flag

The skeleton state reads the store:

import { initialLoading, stats, timeSeries } from '../state/explorer.js';

initialLoading is one expression — queryResult.get() === null — and the whole loading UI hangs off it. Nothing sets a flag when a query starts, and nothing clears it when a query ends, so the two can never disagree.

The skeleton itself is a shimmering block sized to the text it replaces, from skeletonStyles. Sizing it to the content is the point: a skeleton smaller than its text causes a layout shift the moment data lands, which is the jank the skeleton was supposed to prevent.

A chart with no chart library

The sparkline behind each card is an inline SVG built from the daily series:

const points = data.map((d, i) =>
    `${(i / (data.length - 1)) * 100},${100 - ((d[key] - min) / range) * 100}`
).join(' ');
return svg`<polyline points=${points} vector-effect="non-scaling-stroke" />`;

A viewBox of 0 0 100 100, preserveAspectRatio="none", and one polyline. The coordinates are percentages, so the SVG stretches to whatever width the card happens to be and the line still lands correctly — no measuring, no resize observer, no re-render on layout change.

vector-effect="non-scaling-stroke" is what makes that work visually: without it, stretching the viewBox stretches the stroke too, and a 1px line becomes a fat smear horizontally and a hairline vertically.

Three cards, three sparklines, no charting dependency. D3 arrives in lesson 7 for the charts that genuinely need scales and axes — a hundred-and-eighty-point line inside a card is not one of them, and reaching for a library here would add weight for nothing.

Build it

File Action What goes in it
src/components/info-tip.ts write The ⓘ button and its popover — stat-card imports it, so it lands now
src/components/stat-card.ts write The parameterized card: properties, metric selection, <dl> markup, sparkline, skeleton
index.html modify The stats section with three stat-card elements and a visually hidden heading
src/index.ts modify Import the component
src/style.css modify .stats, .stat-cards grid, .section-desc, .visually-hidden

Done when: three cards render skeletons on first paint and then read 20,000 records, 180 days, and 8 items, each with a sparkline SVG behind it.

Answer key: ui-03...ui-04.

Challenge: add a fourth metric

Add metric="avg" showing the average amount per row. You will need a computed in the store and one branch in the component — and no new file, no new stylesheet, and no new registration. Then try to convince yourself the three-separate-components version would have been less work; the exercise is worth it precisely because the answer is obvious once you have the parameter in place.

Next lesson: a dropdown that deletes more code than it adds.