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.