Sparklines in a List
A sparkline beside each row shows a month of traffic for every service at once. The cost is 48 charts on one screen, each with a canvas. This recipe wires a Data Table of 24 services with two Bar Chart sparklines per row. Each sparkline is a static chart (mode="static"): sized for a row, named for the row, paired with a compact total, and carrying none of the chrome, the Tab stop, or the readout a full chart has.
The failure modes it removes are the ones a first draft ships: a 16:9 chart as wide as its column, 48 Tab stops in a table, 48 charts that all announce "Requests", axes and gridlines in a 24px row, and a .map() in the cell renderer that re-ingests every chart on the page on every render.
Tab through the table: no sparkline is a Tab stop. A screen reader that reaches a cell hears the chart's name and what it holds, and reads the total beside it.
Why sparklines in a list
- A chart Root mounts dormant.
BarChart.Rootrenders its named plot and description from the first frame and holds no canvas and no engine until it comes within one viewport of the visible area; the charts on screen activate first, a few milliseconds at a time, so the page never stalls. A list of 100 rows pays for the rows a reader can see. Three viewports away a chart releases its canvas memory, and it repaints on the way back. - A sparkline is a static chart.
mode="static"names the plotrole="img"and drops the interaction overlay: no Tab stop, no hover band, no tooltip, no keyboard point cursor, no data table, no live region. Noninteractive is not decorative: the bars still tell a reader when activity happened and how it varied, so each chart keeps a name and a description, and the exact values live in the total beside it. Forty-eight Tab stops in a table is a focus order no reader wants, and a readout only a pointer can reach fails WCAG 1.4.13. - No chart is a Tab stop, so no reveal plays. A static chart defaults
animatetofalse. A list of sparklines that each draw in as the reader scrolls is motion with no information in it. - The description is always there. The plot's
aria-describedbypoints at a screen-reader-only sentence the Root renders from the first frame: the chart kind, the category count, the first and last category, and the series label: "Bar chart with 30 categories from Sep 9, 2026 to Oct 8, 2026. Series: Requests." Each chart's name tells it apart from the other 47: the row and the measure,Requests for edge-router, last 30 days. - A sparkline is a chart with the chrome removed. No axes, no grid, no legend. The row is 24px tall, and the total beside the chart carries the magnitude.
What this covers
Every datapoint comes from a seeded generator inside the demo, so the list is the same on every load. Five rows have a shape worth a look: edge-router is steady and high, search-indexer is spiky, status-page is flat and low, pdf-export is paused with no traffic, and ml-scoring started two weeks ago, so its first sixteen bars are zero. The route is also available as plain markdown at /recipes/sparklines-in-a-list.md, so humans and LLM tools can fetch the same recipe without scraping the rendered page.
Anatomy
Implementation
The rules below keep the code consistent with the intent above.
- Compose a sparkline as a static Root and one series.
BarChart.Roottakes the row's series asdata, the x key,mode="static", anaria-label, and aclassName. OneBarChart.Barnames the series withlabel, which the description reads. Nothing else: a single-series chart renders no legend, a 24px row has no room for axes, and a static chart has no readout for aTooltippart to format. - Size it with
className="aspect-auto h-6 w-28". The Root renders a<div>sizedaspect-videoat full width by default, so a bare Root in a table cell is a 16:9 chart as wide as the column.aspect-autodrops the ratio,h-6sets the height, andw-28sets the width. The plot fills the box, and each bar fills its share of the width. - Name each chart for the row and the measure.
aria-label={`Requests for ${service.name}, last 30 days`}. The name is what a screen reader announces for the image. Put the window in it, because the column header is not part of the chart's name. One shared label makes 48 identical images. - Put the total in the same cell, as text.
new Intl.NumberFormat("en-US", { notation: "compact" })renders167Kand1.7M. The number is what a reader compares down the column; the sparkline is its shape. Sum it once when the data lands, as the demo does when it builds its rows. Give the spantabular-nums, amin-w, andtext-right, so the charts line up down the column. - Keep the
dataarray stable. The Root ingests and repaints whenever thedataidentity changes. A.map()or a.slice()inside the cell renderer makes a new array on every render, so one parent render re-ingests 48 charts. Pass the array the row already holds, or memoize the transform on the response. - Leave
animateat its default. A static chart plays no enter reveal unless you passanimate. Forty-eight bars that draw in as the reader scrolls say nothing the still bars do not. - Put the exact values in text. A static chart has no hover readout and no data table, so the cell carries them: the total beside the chart, and a detail page for the day-by-day numbers. A reader who needs to inspect points gets an interactive chart there.
- Pin the column widths. A width on every
DataTable.Headerbut the first, and[&_table]:table-fixedonDataTable.Root, hold a sparkline column atw-44down the whole list as the names vary. The List Page Loading States recipe explains why. - Name the avatars. Initials from
Avatar.Fallback'snamearearia-hidden, because they usually abbreviate a name beside them. In a bare stack nothing else names the member, so eachAvatar.Rootcarriesrole="img"andaria-label={owner.name}. - Pair a long list with
content-visibility: auto. The chart's own gate skips paint and canvas memory for rows off screen. It can't skip the style and layout of their DOM. For a list of cards that nothing virtualizes, setcontent-visibility: autowith acontain-intrinsic-sizeon each card, so the browser skips both for cards far off screen, and the chart paints when the card renders. A<tr>can't take the size containment this relies on, so a table with hundreds of rows paginates or virtualizes instead.
Anti-patterns
The left column is what not to do. The right column is the fix.