Table
Semantic tabular layout for data-dense views — user lists, invoices, reports, admin panels. Cells are slots: drop any letbe-ds component (badge, avatar, button, input, switch, chip) inside a .lb-table__cell. Every th and td carries that class — it is the styling contract, and bare cells render as an unstyled native table. Three sizes, alignment per cell, opt-in zebra striping, sticky header, row selection with select-all + indeterminate state, and sortable columns that dispatch a lb-table-sort event.
a11y (built in): real <table> semantics throughout — <th scope="col"> on every header, .lb-visually-hidden names on the checkbox and actions columns, aria-label on icon-only row controls with their glyphs aria-hidden. Sortable headers are a <button data-lb-sort> inside the <th>: LB.Table cycles aria-sort none → ascending → descending, resets the other columns, and swaps the indicator icon per state so direction is never carried by opacity alone. Select-all drives the native indeterminate property, row expansion toggles aria-expanded on a real button, column-resize handles are role="separator" + tabindex="0" with ←/→ resize and double-click reset (the WCAG 2.5.7 non-drag path), and paged or lazily-appended bodies keep aria-rowcount/aria-rowindex plus a polite live region for the result count.
Usage: Put data-lb-table on <table>. Selection checkboxes use data-lb-table-select-all (in <thead>) and data-lb-table-select (one per body row). Sortable headers use <button data-lb-sort="key">. Add data-lb-sort-auto on the table for built-in DOM sorting; otherwise listen for lb-table-sort and sort on your side. Rows added or removed at runtime (pagination, filtering) just work — selection and sorting are delegated; call el._lbTable.refresh() after swapping rows to sync pre-checked state and new sortable headers, and destroy() to fully detach.
Full demo
Canonical data-view arrangement: the .lb-toolbar sits above the table — density control at rest, and the whole bar flips to bulk actions (.lb-toolbar--selecting) when rows are selected, wired via the lb-table-select event. Sortable columns (Name, Amount) re-order in place (data-lb-sort-auto).
| Role | Status | Row actions | |||
|---|---|---|---|---|---|
|
Avery Green
avery.green@letbe.design
|
Designer | Active | $1,240 | ||
|
Noor Ibrahim
noor.i@letbe.design
|
Engineer | Invited | — | ||
|
Kai Tanaka
kai.t@letbe.design
|
Product | Active | $2,400 | ||
|
Luca Rossi
luca.r@letbe.design
|
— | Inactive | $520 | ||
|
Priya Shah
priya.s@letbe.design
|
Operations | Active | $3,150 |
Search, filters + pagination
The client-side data-view flow: a debounced search plus a Filters popover narrow the row set, applied filters render as removable pills in a .lb-filter-bar (the × is the removal idiom, same as Multi-Select pills), pagination slices the result, and refresh() re-syncs selection after every render. Partial-DOM a11y per APG: aria-rowcount on the table, aria-rowindex per row, and the result count is a polite live region. In real apps, swap the filter/slice step for a server query — the chrome stays identical.
| Name | Role | Status |
|---|
Status with icons
Badges accept a .lb-badge__icon slot (16px at medium, 12px at small). Useful when a row needs an immediately-legible status — money flow, delivery state, approval — at a glance. Example below: a transactions view with direction + status columns.
| Merchant | Date | Direction | Status | Amount |
|---|---|---|---|---|
| Card payout | Apr 18, 2026 | Received | Confirmed | +$2,450.00 |
| Figma subscription | Apr 17, 2026 | Sent | Pending | −$45.00 |
| Expired card charge | Apr 16, 2026 | Sent | Failed | −$12.99 |
| April salary | Apr 15, 2026 | Received | Confirmed | +$8,400.00 |
Missing cell values
When data loaded successfully but a specific field is null/empty, use <span class="lb-empty-value" aria-label="No data">—</span> — renders a muted em-dash so the row doesn't look broken or still loading. Different from skeleton (data hasn't arrived yet) and empty state row (nothing in the whole table). The em-dash is the conventional marker for a field that has no value.
| Name | Role | Last active | Deals closed | |
|---|---|---|---|---|
| Avery Green | avery@letbe.design | Designer | 2h ago | 12 |
| Noor Ibrahim | — | Engineer | Yesterday | — |
| Kai Tanaka | kai@letbe.design | — | — | 7 |
Sizes
Three row densities. Default is medium. Use .lb-table--sm for dense dashboards and .lb-table--lg for light settings views.
The data-view pattern: a Segmented control swaps the size class and remembers the choice. Wire lb-segmented-change → toggle --sm/--lg; persist per view.
| Name | Role | Amount |
|---|---|---|
| Avery Green | Designer | $1,240 |
| Noor Ibrahim | Engineer | $980 |
| Kai Tanaka | Product | $2,400 |
| Name | Role | Amount |
|---|---|---|
| Avery Green | Designer | $1,240 |
| Noor Ibrahim | Engineer | $980 |
| Kai Tanaka | Product | $2,400 |
| Name | Role | Amount |
|---|---|---|
| Avery Green | Designer | $1,240 |
| Noor Ibrahim | Engineer | $980 |
| Kai Tanaka | Product | $2,400 |
| Name | Role | Amount |
|---|---|---|
| Avery Green | Designer | $1,240 |
| Noor Ibrahim | Engineer | $980 |
| Kai Tanaka | Product | $2,400 |
Alignment
Default is --start (text). Use --center for icons/indicators, --end for numbers or dates (tabular-nums is applied so digits line up).
| Start | Center | End (numbers) |
|---|---|---|
| Title text | OK | 1,240.50 |
| Another item | Warn | 980.00 |
| Third row | Fail | 2,400.75 |
Cell content — slots
Cells accept any component. Below: avatar + text stack, icon + text, badge, chip, switch, input, link. Wrap grouped content with .lb-table__stack (vertical) or .lb-table__inline (horizontal) helpers. Secondary text uses .lb-table__caption.
| Avatar + stack | Badge | Chip | Switch | Input | Link |
|---|---|---|---|---|---|
|
Avery Green
Designer
|
Active | View profile | |||
|
Noor Ibrahim
Engineer
|
Pending | Details |
Row states
Hover highlight (on .lb-table--hoverable), persistent selection (.lb-table__row--selected, auto-applied when the row checkbox is checked), and keyboard focus outline (for row-click patterns).
| State | Description | |
|---|---|---|
| Default | Hover me to see the highlight. | |
| Selected | Row background picks up --lb-action-bg-selected when its checkbox is ticked. |
|
| Keyboard focus | Tab to this row — outlines with the focus ring. Use for clickable rows. |
Zebra striping (opt-in)
Add .lb-table--striped for alternating rows. Off by default — cleaner dashboards look tidier without it; long financial tables read better with it.
| Name | Role | Amount |
|---|---|---|
| Avery Green | Designer | $1,240 |
| Noor Ibrahim | Engineer | $980 |
| Kai Tanaka | Product | $2,400 |
| Luca Rossi | QA | $520 |
| Priya Shah | Operations | $3,150 |
Sticky header
Split the table into two — one for the header, one for the body — so the vertical scrollbar lives only along the rows area, never alongside the pinned <thead>. Both tables get .lb-table--sticky (which applies table-layout: fixed so columns align by default). For unequal column widths, add matching <colgroup> to both. This demo also opts into column resize: data-lb-table-resize on the wrap keeps both split tables' colgroups in sync — drag a header edge, use ←/→ on the focused handle, or double-click the edge to reset (the WCAG 2.5.7 non-drag path). And it demonstrates chunked lazy-append: 200 rows load 30 at a time as you near the scroller's end (aria-rowcount announces the real total; async consumers append skeleton rows while fetching). That's the DS-side scale boundary — for 100k-row sets bring a windowed virtualizer (TanStack Virtual, Clusterize) per the pluggable-engines rule.
| Name | Role | Status | Amount |
|---|
Row expansion + side peek
Two ways to reveal a record's depth: expansion — a data-lb-table-expand button toggles the adjacent .lb-table__detail-row in place (aria-expanded, chevron rotates, detail rows travel with their parent through sorting; not compatible with --striped) — and side peek — the row's end action opens a right lb-sheet with the record while the table stays in context. Dispatches lb-table-expand.
| Expand | Role | Amount | Details | |
|---|---|---|---|---|
| Avery Green | Designer | $1,240 | ||
| Avery Green — joined Jan 12, 2025 · Rotterdam · 312 sessions. Owns the letbe-ds component gallery and the Figma library bindings. | ||||
| Noor Ibrahim | Engineer | $980 | ||
| Noor Ibrahim — joined Mar 3, 2025 · Stockholm · 1,204 sessions. Maintains the token build pipeline and the theme editor. | ||||
| Kai Tanaka | Product | $2,400 | ||
| Kai Tanaka — joined Jun 21, 2025 · Osaka · 86 sessions. Runs the Stage-5 template program roadmap. | ||||
Pinned first column
Wide tables scroll horizontally inside the wrap while .lb-table--pin-first keeps the identity column sticky at the left edge (P&P: as important for horizontal scroll as the fixed header is for vertical). Base background is opaque, row states (hover, selected) still paint through, and a hairline marks the pin boundary. Never collapse a table to display: block for small screens — this is the sanctioned responsive strategy.
| Member | Role | Team | Location | Joined | Last active | Sessions | Amount |
|---|---|---|---|---|---|---|---|
| Avery Green | Designer | Core | Rotterdam | Jan 12, 2025 | Today | 312 | $1,240 |
| Noor Ibrahim | Engineer | Platform | Stockholm | Mar 3, 2025 | Yesterday | 1,204 | $980 |
| Kai Tanaka | Product | Growth | Osaka | Jun 21, 2025 | 2 days ago | 86 | $2,400 |
Loading (skeleton)
Set aria-busy="true" on <tbody> and fill cells with .lb-skeleton .lb-skeleton--text. Mirror the final column structure so the layout doesn't jump when data arrives.
| Name | Role | Status | Amount |
|---|---|---|---|
Empty state
A single row spanning all columns via colspan. Include a hint for what to do next.
| Name | Role | Status | Amount |
|---|---|---|---|
No members yetInvite a teammate to get started. |
|||