Skip to content

src/data - 12 files

Data

The shapes a product actually shows data in. Every example on this page is the live component rather than a picture of one: scroll the table, sort it, filter it into its empty state, take a tag off, press a filter chip. The markup under each one is read out of the example itself, so what you copy is what you just operated.

The markup panels below are read out of the live examples by site.js. With scripting off the examples still render, the panels stay empty, and the table still scrolls: sorting, filtering and tag removal are the only three things on this page that need script.

Table

Its own scroll container, sticky header, optional sticky first column, two densities, zebra and hover, and an opt-in stacked layout. The scroll container is a separate block because a scrollable region has to be focusable and named: a keyboard-only reader cannot reach content that only a pointer can scroll.

Scrolling, with the header and the first column pinned

Seven columns and eight rows inside a box capped at 14rem. On a phone this scrolls in both directions and the page itself does not move. Drag it sideways, or put focus in it with the keyboard and use the arrow keys: the column headings and the region names stay on screen while the figures move under them.

src/data/table.css, adds 3,195 B gzip
Revenue by region, current quarter
Region Revenue Accounts Growth Churn Owner Updated
EMEA 12,400 318 4.1% 1.8% Operations 2 hours ago
APAC 9,120 204 7.6% 2.2% Operations 2 hours ago
Americas 18,340 511 3.4% 1.1% Field yesterday
LATAM 4,780 142 9.2% 3.0% Field yesterday
Nordics 3,510 96 2.8% 0.9% Partners 3 days ago
DACH 7,905 233 5.5% 1.4% Partners 3 days ago
Benelux 2,640 71 1.9% 2.6% Partners last week
MENA 1,880 54 12.7% 4.4% Field last week
Total 60,575 1,629 5.4% 2.2% - -
Show codeHide code

scope="col" and scope="row" are not optional: without them a screen reader cannot announce which column a cell belongs to, and no amount of CSS can supply that. A sticky header needs a bounded block size on the wrapper, which --bs-table-scroll-max-block sets here and bs-table-scroll--capped sets at 70dvh. There is no CSS that makes a horizontally scrolling table stick its header to the viewport: once one axis is auto the other can never stay visible outside the box, which is why the header sticks inside this wrapper and not to the window.

Both densities

The same figures at both settings, side by side, so the difference is something you can see rather than a row in a table of modifiers.

--compact and --comfortable
Compact
Region Revenue
EMEA12,400
APAC9,120
Americas18,340
Comfortable
Region Revenue
EMEA12,400
APAC9,120
Americas18,340
Show codeHide code

Sorting

CSS can paint a sorted table and cannot sort one, so this is the one example on the page where a column header does something. Press a header, or reach it with the keyboard and press Enter: the rows reorder and aria-sort on that header changes, which is what a screen reader reads when it lands on the column.

src/behaviour/table-sort.js, optional
Sortable on every column
EMEA 12,400 318 2 hours ago
APAC 9,120 204 2 hours ago
Americas 18,340 511 yesterday
Nordics 3,510 96 3 days ago
MENA 1,880 54 last week
Show codeHide code

The header holds a real button, so activation, focus and the keyboard belong to the platform rather than to a script. Only a header carrying aria-sort is sortable, which is how a table sorts on two of its six columns. "2 hours ago" is prose and not a date, so the Updated cells carry data-bs-sort-value with the value the string stands for. Direction is never signalled by colour alone: it lives in the attribute a screen reader announces.

Stacked, instead of scrolled

The other answer for a narrow screen: below 48em each row becomes a block and each cell states its own column name. Narrow this window, or read this page on a phone, and the table below is already stacked.

--stack, opt-in
Revenue by region
Region Revenue Owner
EMEA 12,400 Operations
APAC 9,120 Operations
Show codeHide code

It is opt-in because display: block on table elements removes the implicit table semantics in every engine, so the roles have to be written back into the markup and every cell has to carry its own data-label. Announcement of generated content varies between screen readers, so where guaranteed parity matters, keep the scrolling table above: its semantics are intact at every width.

Card

A card is a container, not a control. The interactive variant must not put a click handler on the card element, because a div with a click handler is invisible to the keyboard and to assistive technology. Put a real link inside and let it cover the card: tab to the first card below and the ring is drawn around the whole panel.

src/data/card.css, adds 2,037 B gzip
  • Interactive

    Updated an hour ago

    The whole panel is the target. The anchor carries the name and the focus.

  • Compact

    Same rhythm, one step less padding, for a dense grid.

  • Spacious

    One step more, for a panel that carries a page.

  • Plain

    No fill, no edge, no shadow. The same padding and rhythm.

  • Flush

    Inline padding removed, so media meets the edge.

Show codeHide code

bs-card__link stretches its hit area over the whole card, so the pointer target is the card while the accessible name, the role and the focus behaviour all come from the anchor. The focus ring is drawn around the card rather than the text, since the text is only part of the target. The image is decoration beside a title that already says what the card is, so its alt is empty, and its dimensions are in the markup: without them the grid reflows the moment it loads.

Stat

A single figure with its label, and an optional change indicator. The label and the value are one unit, which a description list says and a pair of divs does not.

src/data/stat.css, adds 1,219 B gzip
Monthly active
18,204
up 12.4% vs last month
Churn
1.8%
down 0.3pp vs last month
Open tickets
37
Revenue
60,575
Accounts, label first
1,629
Show codeHide code

Direction must not be carried by colour alone. The arrow carries it visually, and because the arrow is aria-hidden the direction has to reach a screen reader in the text, which is why the examples above read "up 12.4%" rather than a bare percentage in green. If the figure updates while the page is open, put aria-live="polite" on bs-stat__value.

List

Lists used as data: divided rows, inline runs, and a plain list with no markers. Removing the marker removes the list semantics in Safari and VoiceOver, so every variant that hides markers has to declare the role it just lost.

src/data/list.css, adds 1,013 B gzip
  • EMEA 12,400
  • APAC 9,120
  • Americas 18,340

Interactive and compact, around real links

Inline

  • Operations
  • Field
  • Partners
Show codeHide code

The role is required on --plain, --divided and --inline. A bare bs-list keeps its markers and needs no role. --interactive styles the row around a real control; it does not create one, which is why the middle example holds anchors and its row highlight answers to :focus-within as well as to hover. Tab into it to see the row light up.

Description list

Key and value pairs: the shape most product detail panels actually want, and the one most of them build out of divs. Direct dt and dd children, no wrappers, because the two-column variant places children by source order.

src/data/description-list.css, adds 1,061 B gzip
Region
EMEA
Owner
Operations
Contacts
Ada Lovelace
Grace Hopper

Stacked, the default

Status
Live
Updated
2 hours ago
Show codeHide code

One term may be followed by several descriptions, which is how HTML expresses a key with several values. Do not repeat the term to fake it: a screen reader announces the repeat as a second term.

Badge

A status pill: small, filled, not interactive. A badge carries state, so the state has to be in the text and not only in the fill. Colour alone fails SC 1.4.1 and is invisible to anyone with a colour vision deficiency.

src/data/badge.css, adds 1,457 B gzip
Neutral Accent Live Failed Draft Queued
Show codeHide code

A count badge attached to something else needs its own label, or a screen reader reads a bare number with no idea what it counts. If the value changes while the page is open, add aria-live="polite" so the change is announced rather than silently repainted. --pin is absolutely positioned, so its host needs position: relative and enough room not to be overlapped by its own count.

Tag

A badge states a status the system owns. A tag states a value the reader applied and can usually take back. Take one back: press a remove control below. The chip goes, focus lands on the next one instead of on the top of the document, and the line underneath says what left.

src/data/tag.css with src/behaviour/tag.js
  • EMEA
  • APAC
  • Priority
  • Quarterly
  • Plain

As a filter, where the whole tag is the control

Show codeHide code

The remove button needs a name that says what it removes: "Remove" on its own gives a screen reader user a list of identical buttons. A chip that disappears is silent, so the removal is written into a role="status" line, and focus is moved deliberately, because removing the element that holds focus otherwise drops a keyboard reader at the top of the document. When a tag is a filter that toggles it is a button carrying aria-pressed with no remove control, which is the second row above. Press one: the attribute is the state, and because tag.css ships no rule for it, the markup names the modifier that paints it in data-bs-pressed-class and the two move together. A state only assistive technology can read is half a control.

Avatar

A fixed-ratio identity image with an initials fallback, and an overlapping group with an optional overflow count. Initials are a visual shorthand, never a name, so they are hidden and the container is named.

src/data/avatar.css, adds 996 B gzip
Dorothy Vaughan
Show codeHide code

An avatar next to the person's name is decorative and takes an empty alt, or every row in a list is announced twice. An avatar standing alone is the only thing naming the person, so it carries the name. Always set width and height on the image so the row does not reflow when it loads.

Stacked

A group overlaps its children and is its own scroll container, so a run longer than the column scrolls inside its own box instead of making the page scroll sideways. The second group below is deliberately too long for a phone: drag it, or tab to it and use the arrow keys.

src/data/avatar-group.css, adds 1,085 B gzip
  • +12

Tight, and long enough to scroll on a phone

Show codeHide code

CSS cannot count children, so the truncating is yours: cut the run in markup and let the count carry the remainder. What the file guarantees instead is that the failure stays local. Anything that scrolls has to be reachable without a pointer, so a group that can overflow takes tabindex="0", and the name goes on aria-label rather than on a role that would replace role="list".

Keycap

For documenting a key a reader presses, not for code. A chord is several keys, so it is several elements: one keycap holding "Ctrl+K" tells assistive technology it is a single key with a plus sign in its name.

src/data/kbd.css, adds 784 B gzip

Press Ctrl + K to search, or ShiftEnter to send.

Smaller, for running text: Esc closes it.

Show codeHide code

Key names are capitalised in the markup rather than by text-transform, so a screen reader reads the same string a sighted reader sees.

Divider

Plain, labelled or vertical. The labelled section rules on this page are the same component. A labelled divider cannot be an hr, because that is a void element and cannot hold the label, so it is a div with the role restored.

src/data/divider.css, adds 997 B gzip


Draft
Edited today
Show codeHide code

The default colour is the decorative hairline. A divider that has to be perceivable as structure rather than read as a hint of one uses --strong, which clears the 3:1 non-text floor. The second rule above is that one.

Empty state

What a table, list or panel shows when it has nothing to show. The icon is decoration and is hidden; the title carries the meaning.

src/data/empty.css, adds 1,309 B gzip

No regions yet

Add a region and its figures will appear here.

Show codeHide code

The state a filter actually produces

The case worth building for is not the panel that was born empty, it is the one that emptied while the reader was looking at it. Type in the box below. As soon as nothing matches, the table and its scroll wrapper leave together and the empty state takes their place inside a live region, so the change is announced rather than only seen. Clear the box and the rows come back.

src/behaviour/table-filter.js, optional

4 rows

Regions
Region Revenue Owner
EMEA12,400Operations
APAC9,120Operations
Nordics3,510Partners
MENA1,880Field
Show codeHide code

Two things here are the consumer's rather than the framework's. The swap has to be announced, so the pair sits inside aria-live="polite". And the table's wrapper has to leave with the rows: left behind, it is a named, focusable, empty scrolling region whose caption points at nothing. Hiding uses the hidden attribute rather than a class, because src/core/reset.css makes it display: none !important and so takes the row out of the box tree and out of the accessibility tree in one move.