<!-- https://getkonjo.com/design/studio/tables · source: docs/design/studio/tables.md -->

# Tables

Studio's first-class layout. Rows an owner scans, sorts, filters and acts on in bulk.

> Part of the [Konjo design language](../konjo-design-language.md). The laws in the spine
> apply here unless this chapter contradicts them. Studio-specific — see
> [the dialect](dialect.md).

A table is not a list with lines. The mobile [list row](../patterns/index-list.md) is designed
for one thumb and one thing at a time; a table is designed for a pointer, a wide screen, and a
person comparing forty records against each other. **Tables are Studio-only.** A table in the
mobile app is a list that has not been designed yet.

## When a table

- The person is **comparing across rows** — who has not paid, which classes are under-attended.
- The data is genuinely **columnar** and every row has the same fields.
- There are **more than ~10 rows**, or bulk action is the point.

Otherwise use cards. Six records with four fields each is not a table, it is six cards, and the
table's chrome costs more than it returns.

## Anatomy

```
MEMBERS                                           eyebrow, text.secondary
Students                                          display, page title    [ Add member ]
Everyone training at Blue Heron.                  body, text.secondary
┌────────────────────────────────────────────────────────────────────────┐
│ [ ⌕ Search students…    ]                            Export CSV         │  toolbar, inside
│ ( All ) ( Brown belt ×) ( Past due )                 132 students       │  chips, then hairline
│ ═══════════════════════════════════════════════════════════════════════ │
│ ☐  NAME ▲          RANK        LAST SEEN     STATUS                     │  header: eyebrow
│ ─────────────────────────────────────────────────────────────────────── │
│ ☐  Ana Torres      ● Brown     Aug 21        Waiver missing             │  rows 46 min
│ ☐  Marcus Webb     ● Green     Today                                    │
│ ─────────────────────────────────────────────────────────────────────── │
│ Show 25 more   Show all 132                                             │  reveal, in-panel
└────────────────────────────────────────────────────────────────────────┘
```

- **White card on the page**, hairline rows, no vertical rules. Vertical lines make a table
  look like a spreadsheet and add nothing a column gap does not.
- **Search, filter chips and the table share one panel** — one surface around the whole
  apparatus, not a floating toolbar above a separate card. Filters that sit outside the panel
  read as page furniture; a person cannot tell whether they filter this table or the page.
  Toolbar padding is `12` vertical / `16` horizontal; the chip row closes with a hairline; the
  table starts flush against it with no surface of its own.
- **What makes it a panel is the tone, not the border.** White `surface.card` on the tinted
  `surface.page`, per [L3](../konjo-design-language.md). This chapter said "one bordered
  panel" and that reads as a contradiction of L3's *"not this: a 1px border around white on
  white"* — it isn't, but only because the page is tinted. `border.hairline` on the page is
  **1.09:1**: an optical edge that crisps the corner radius, never the thing doing the
  separating. If you find yourself reaching for a heavier border to make a panel read, the
  panel is on the wrong surface.
- **The page's own actions stay outside the panel**, on the title row — Add member, Import.
  Those act on the roster, not on this view of it. Actions that act on *what you are looking
  at* (Export CSV, column chooser) belong in the toolbar.
- **Rows are `46` minimum** — Studio is denser than mobile by design. Minimum, not fixed: see
  [density](metrics.md#density) for why the row yields rather than clips.
- **Header is an `eyebrow` register**, `text.secondary`. The budget counts *registers*, not
  instances, so a page eyebrow above the title and a table header are the same register and
  both are legal — the standard Studio page header (eyebrow, title, one-line sub) is not in
  conflict with a table below it. What the budget forbids is a *second* uppercase style: a caps
  `statement`, a `sectionLabel`, or capitals typed by hand.
- **The header sticks** on vertical scroll. A column you cannot name is a column you cannot
  read.
- **No zebra striping.** Hairlines already separate rows; stripes add a second separator and a
  second background, and the design has one background.
- **Dense grading grid — the one deviation.** Class Mode's curriculum grid
  (`OrganizerGrid`, students × techniques) is read along *both* axes: down a technique, and
  across a student's row through a dozen tappable cells. A column gap cannot hold the eye
  across the middle of a grid that wide, so that grid alone draws `border.hairline` vertical
  rules between columns and closes every fifth row (rows 5, 10, 15…, never the last) on a
  `border.input` rule, name cell included, so the line runs the full width. Stripes stay
  banned there too — the grouping rule does the job a stripe would, without a second
  background. A roster or list table is read one row at a time and gets neither.
- **Row hover** gets `surface.muted` on `surface.card` — 1.04:1, and the surface matters:
  the same token on `surface.page` is 1.07:1, so this rule only holds for a row inside a
  panel. Pointer input has hover; use it. It is the cheapest
  possible affordance and it tells a person which row their action will hit.
- **`surface.muted` is never the only signal of anything.** `surface.muted on surface.card` is
  **1.04:1** — the same arithmetic L3 cites when it kills the grey-card-on-white inversion. It
  works for *hover* because the pointer is already sitting on the row and is the real signal;
  the fill is confirmation, not information. It does **not** work alone for **selection**,
  which is a component state and owes 3:1 under SC 1.4.11. What carries selection is the
  **checkbox** — `border.control` at 3.53:1 on the card, with a real `checked`/`aria-checked`
  — and the row fill is the hint beside it. A "selected" row with no checkbox in it is a bug,
  not a style.

## Columns

- **The subject is the first column**, always, and it is the only bold one. A name should never
  be the third thing on a row.
- **Numbers are right-aligned and tabular**; text is left-aligned. Never centre a column.
- **Units go in the header, not in every cell** — `REVENUE ($)`, then `45.00`.
- **Five to seven columns.** Past that, a person is scrolling horizontally to compare things
  they wanted side by side; move the rest to the detail view.
- **The last column is status or the row action**, never data someone needs.
- **Status renders as a pill, and a pill is not a chip.** A chip is a control you click; a pill
  states a fact and does nothing. 24 tall, 8 horizontal, `radius.pill`, `caption`, the matching
  `feedback.*` fill and text, and **no border** — 3:1 is owed by a control boundary, and a pill
  is not tappable. It never has a hover state. Geometry in [Studio metrics](metrics.md).
- **Never truncate a name.** Give it the width; truncate a description instead. See
  [content format](../foundations/content-format.md).

## Sorting

- **One sort at a time**, indicated by a small caret in the header, on the sorted column only.
- **Sortable headers look tappable** — they get a hover state; unsortable ones do not.
- **The default sort answers the screen's question**: most recent for activity, soonest for
  what is upcoming, alphabetical only when nothing better exists.
- Sorting does not lose the selection.

## Selection and bulk action

- **Checkbox column first**, header checkbox selects the filtered page and says so. It is a
  16pt box whose click target is the row's full height, and when some but not all rows are
  checked it is **`aria-checked="mixed"`** — a plain checked box on a partial selection is
  lying about the state.
- **A selected row fills `surface.muted`** on `surface.card` and keeps it while hovered; hover on an unselected
  row is the same fill, so a row that is both looks selected, which it is. Never a coloured
  selection fill — the red role is not spent on a state a person can toggle by accident.
- **The bulk action bar replaces the filter row when anything is selected**, states the count
  — "3 students selected" — and offers at most three actions.
- **Bulk destructive actions confirm and name the count**: *Remove 3 students?* See
  [serious actions](../patterns/destructive.md).
- **Escape clears the selection.**

## Density and volume

- **Never render an unbounded list, and never infinite-scroll.** *(System-wide, not table-local:
  a board column, a feed and a card list obey this and both shapes below.)* Infinite scroll in a work
  tool loses a person's place and makes "how many are there" unanswerable. There are exactly
  **two** sanctioned shapes, and which one a table uses is decided by where the data comes
  from, not by taste:

  | | **Reveal** | **Cursor page** |
  |---|---|---|
  | Use when | the whole collection is already loaded and filtering must be instant — a roster, a team list | the collection is unbounded or too large to preload — signatures, payments, audit history |
  | First render | **25 rows** | **50 rows** |
  | Control | `Show 25 more` · `Show all 132` | `Next 50 →` · `← First page` |
  | Placement | inside the panel, below the last row | inside the panel, below the last row |
  | On filter change | resets to 25 | resets to the first page |
  | Round-trip | none — reveal is a render decision | one per page |

  Reveal is the default; reach for cursor paging only when preloading the collection is not
  defensible. Both keep the same control placement, so the two shapes never look like two
  different products.
- **"No pagination" in the [screen inventory](ia-and-inventory.md) means no round-trip per
  page** — it describes a Reveal table, not an endless one.
- **The count is always visible**: "132 students", and when filtered, "14 of 132". A Reveal
  table also states what is on screen when the two differ — "showing 25 of 132".
- **Column widths are stable** across pages. Content-driven widths that shift every page make a
  table impossible to scan.
- **Horizontal scroll is contained to the table**, never the page.
- **A region that scrolls sideways must say so**, and the cue must be driven by *actual
  overflow* — measured — not by a proxy for it. `overflow-x: auto` gives no affordance of its
  own: on a trackpad or a phone there is no visible scrollbar at rest, so a clipped column is
  indistinguishable from a column that does not exist.

  This is stated because a blind QA pass found three Studio surfaces answering it three
  different ways: the roster measures its own right-edge overflow and floats a `Scroll →` chip;
  the waiver matrix prints a hint when there are *more than three templates*, which is a proxy
  for width and fails at a narrow viewport with two; and the board and style pages print
  nothing. The proxy is the instructive failure — the grid still overflowed, and the hint stayed
  silent because the condition was counting the wrong thing.

  **The cue is a chip over the content, not a fade.** [L3](../konjo-design-language.md) bans
  gradients, which rules out the usual edge-fade; and a fade says "there is more" without
  saying which way. The chip carries a direction, is `caption` on `surface.tag`, sits over the
  rows rather than on the header line, is `aria-hidden` (a screen reader reaches the columns
  regardless), and disappears once the region is scrolled to that end.

## States

- **Loading:** the header and column structure render immediately; rows are skeletons at row
  height. Never a spinner where the table goes.
- **Empty collection:** replace the table with an [empty state](../patterns/states.md) — and
  drop the filter row, since there is nothing to filter.
- **Empty after filtering:** keep the table header and the filters, and say which filter is
  excluding everything. See [search and filter](../patterns/search-filter.md).
- **Error:** an inline block above the table with a retry. The columns stay.

## Accessibility

- Real semantic table markup: `<table>`, `<thead>`, `<th scope="col">`. A grid of `<div>`s is
  unnavigable by screen reader.
- `aria-sort` on the sorted header.
- Every row action has a label naming its row — "Remove Ana Torres", not "Remove".
- Full keyboard operation: tab reaches every control, the header sorts on Enter, Escape clears.
- Focus visible at 2px `border.focus`, which on a dense table is the only way a keyboard user
  keeps their place.

## Never

- A table on mobile.
- Vertical rules, or zebra stripes. (Vertical rules only in the dense grading grid, above.)
- Centred columns.
- A truncated name.
- Infinite scroll.
- Row actions with no row named in the label.
- More than one uppercase register on a page that already has a table header.
