Skip to content

Table

The composable table primitive. You author the rows; the table provides the CSS-Grid layout, sticky and frozen headers, column pinning, sort and selection state, striping, and loading / empty states.

Live preview Open in Storybook
Client Amount Status Acme Corp $1,200.00 Paid Globex $3,120.00 Pending Initech $480.00 Overdue
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table column-template="2fr 1fr 1fr" label="Invoices" striped>
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="inv-1">
        <md-table-cell head scope="row">Acme Corp</md-table-cell>
        <md-table-cell numeric>$1,200.00</md-table-cell>
        <md-table-cell>Paid</md-table-cell>
      </md-table-row>
      <md-table-row value="inv-2">
        <md-table-cell head scope="row">Globex</md-table-cell>
        <md-table-cell numeric>$3,120.00</md-table-cell>
        <md-table-cell>Pending</md-table-cell>
      </md-table-row>
      <md-table-row value="inv-3">
        <md-table-cell head scope="row">Initech</md-table-cell>
        <md-table-cell numeric>$480.00</md-table-cell>
        <md-table-cell>Overdue</md-table-cell>
      </md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>

Already installed? See the Installation guide for one-time package setup (core + tokens, fonts). Each tab below shows two patterns for using md-table in your project: Option A registers every AWC UI component at once (simplest), Option B imports only this component for tree-shake-friendly bundles.

<!-- ─── Option A: global registration (all components) ─── -->
<script type="module">
  import '@awc-ui/core/define';
</script>


<!-- ─── Option B: single import (tree-shake only md-table) ─── -->
<script type="module">
  import '@awc-ui/core/components/md-table';
</script>


<md-table></md-table>
  • A table whose rows you compose by hand: mixed content, embedded controls, bespoke cells, server-rendered markup.
  • You need column pinning or frozen headers.
SituationUse instead
A vertical list of recordsmd-list
Rich, self-contained itemsa md-card collection
Hierarchy / reporting linesmd-organization-chart
Comparing two valuesPlain text
Layout (the old <table> misuse)CSS Grid

This is the single most important thing to internalise. The shadow .md-table__grid owns the column tracks; each md-table-row is a real grid item that lays its cells on those shared tracks via grid-template-columns: subgrid. The rowgroups — md-table-head, md-table-body, md-table-foot — are display: contents, so their rows land directly on the parent’s tracks.

Two consequences follow, and both bite:

NeedSetting
Explicit column widthscolumn-template — a raw CSS grid-template-columns value (minmax(), fr, auto all allowed)
Just a column countcolumns — becomes repeat(<columns>, minmax(0, 1fr))
A floor before horizontal scrolling kicks inmin-width (or --md-table-min-width)

column-template wins over columns when both are set.

columns vs column-template Open in Storybook
Equal Thirds Each ABC Flexible 1fr 120px AB42
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined"><md-table columns="3" label="Derived from the columns prop">
  <md-table-head>
    <md-table-row rowgroup="head">
      <md-table-cell head scope="col">Equal</md-table-cell>
      <md-table-cell head scope="col">Thirds</md-table-cell>
      <md-table-cell head scope="col">Each</md-table-cell>
    </md-table-row>
  </md-table-head>
  <md-table-body>
    <md-table-row><md-table-cell>A</md-table-cell><md-table-cell>B</md-table-cell><md-table-cell>C</md-table-cell></md-table-row>
  </md-table-body>
</md-table></md-table-container>

<md-table-container variant="outlined" style="margin-block-start: 20px;"><md-table column-template="minmax(160px, 2fr) 1fr 120px" label="Explicit template">
  <md-table-head>
    <md-table-row rowgroup="head">
      <md-table-cell head scope="col">Flexible</md-table-cell>
      <md-table-cell head scope="col">1fr</md-table-cell>
      <md-table-cell head scope="col" numeric>120px</md-table-cell>
    </md-table-row>
  </md-table-head>
  <md-table-body>
    <md-table-row><md-table-cell>A</md-table-cell><md-table-cell>B</md-table-cell><md-table-cell numeric>42</md-table-cell></md-table-row>
  </md-table-body>
</md-table></md-table-container>

Rows being real boxes is what makes hover, stripes, dividers, sticky headers and focus rings work — and it means a wrong cell count skews one row rather than the whole table.

Two Storybook stories put the whole surface together at once: a full data table (toolbar with quick-filter and CSV export, selection, sorting, sticky header and column, pagination) and a fully customised one.

The whole anatomy — toolbar, sticky header, sort label, totals row and pagination
Name Amount Status Acme Corp $1,200 Globex $3,120 Initech $480 Umbrella $2,450 Total $7,250
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined" max-height="248px">
  <md-table-toolbar slot="top" headline="Invoices" supporting-text="Q1 2025"></md-table-toolbar>

  <md-table column-template="2fr 1fr 1fr" sticky-header striped label="Invoices">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">
          <md-table-sort-label column="name">Name</md-table-sort-label>
        </md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>

    <md-table-body>
      <md-table-row value="inv-1">
        <md-table-cell head scope="row">Acme Corp</md-table-cell>
        <md-table-cell numeric>$1,200</md-table-cell>
        <md-table-cell><md-chip label="Paid" color="success"></md-chip></md-table-cell>
      </md-table-row>
      <md-table-row value="inv-2">
        <md-table-cell head scope="row">Globex</md-table-cell>
        <md-table-cell numeric>$3,120</md-table-cell>
        <md-table-cell><md-chip label="Pending" color="warning"></md-chip></md-table-cell>
      </md-table-row>
      <md-table-row value="inv-3">
        <md-table-cell head scope="row">Initech</md-table-cell>
        <md-table-cell numeric>$480</md-table-cell>
        <md-table-cell><md-chip label="Overdue" color="error"></md-chip></md-table-cell>
      </md-table-row>
      <md-table-row value="inv-4">
        <md-table-cell head scope="row">Umbrella</md-table-cell>
        <md-table-cell numeric>$2,450</md-table-cell>
        <md-table-cell><md-chip label="Paid" color="success"></md-chip></md-table-cell>
      </md-table-row>
    </md-table-body>

    <md-table-foot>
      <md-table-row rowgroup="foot" selectable="false">
        <md-table-cell head scope="row">Total</md-table-cell>
        <md-table-cell numeric>$7,250</md-table-cell>
        <md-table-cell></md-table-cell>
      </md-table-row>
    </md-table-foot>
  </md-table>

  <md-table-pagination slot="bottom" count="4" rows-per-page="5"></md-table-pagination>
</md-table-container>
  • md-table-container is the surface and the scroll box. Its top / bottom slots sit outside the scroll region — that’s where the toolbar and pagination belong.
  • rowgroup="head|body|foot" goes on each row, not just the wrapper. The wrapper does not cascade it.
  • Header cells need head and scope together on md-table-cell — see Accessibility.

md-table-container carries the surface treatment. elevated (the default) floats the table on a shadow, outlined draws a hairline border instead, filled sits on a tonal surface, and flat removes both — including the background — so the table inherits whatever surface it sits on. Use flat only inside something you have already framed; on a bare page its rows have nothing to sit on.

The four container variants
variant="elevated" Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending variant="outlined" Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending variant="filled" Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending
variant="flat" Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: grid; gap: 20px;">
  <md-table-container variant="elevated">
    <md-table column-template="2fr 1fr 1fr" label="Elevated container">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">variant="elevated"</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="Outlined container">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">variant="outlined"</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
  <md-table-container variant="filled">
    <md-table column-template="2fr 1fr 1fr" label="Filled container">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">variant="filled"</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
  <!-- flat paints no surface of its own — it inherits the one it sits on.
  Framed here the way you would actually use it, inside a panel. -->
  <div style="padding: 16px; border-radius: 16px; background: var(--md-sys-color-surface);">
    <md-table-container variant="flat">
      <md-table column-template="2fr 1fr 1fr" label="Flat container">
        <md-table-head>
          <md-table-row rowgroup="head">
            <md-table-cell head scope="col">variant="flat"</md-table-cell>
            <md-table-cell head scope="col" numeric>Amount</md-table-cell>
            <md-table-cell head scope="col">Status</md-table-cell>
          </md-table-row>
        </md-table-head>
        <md-table-body>
          <md-table-row><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
          <md-table-row><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
        </md-table-body>
      </md-table>
    </md-table-container>
  </div>
</div>

vibrant on the container tints the whole table with the primary/tertiary container roles — a header band in primary, tinted stripes, and selection in tertiary. Use it for a table that is the centrepiece of its screen; keep the plain surface for tables that sit alongside other content.

Vibrant tonality — primary-tinted header over striped rows Open in Storybook
Name Role Team Salary Ada LovelaceMathematicianEngineering$120,000 Alan TuringCryptanalystSecurity$145,000 Grace HopperCompiler EngineerPlatform$132,000 Linus TorvaldsKernel EngineerInfrastructure$168,000 Margaret HamiltonSoftware LeadAvionics$175,000 Donald KnuthAuthor / ResearcherResearch$195,000
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="elevated" vibrant>
  <md-table-toolbar slot="top" headline="Employees" supporting-text="Vibrant tonality" auto-bind>
    <md-icon-button slot="actions" icon="filter_list" aria-label="Filter"></md-icon-button>
    <md-icon-button slot="selection-actions" icon="delete" aria-label="Delete"></md-icon-button>
  </md-table-toolbar>
  <md-table id="vib-table" selection="multiple" striped column-template="auto 1.5fr 1.2fr 1fr 1fr" label="Vibrant employees">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head padding="checkbox"><md-checkbox id="vib-all" aria-label="Select all rows"></md-checkbox></md-table-cell>
        <md-table-cell head scope="col">Name</md-table-cell>
        <md-table-cell head scope="col">Role</md-table-cell>
        <md-table-cell head scope="col">Team</md-table-cell>
        <md-table-cell head scope="col" numeric>Salary</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="v1"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Ada Lovelace"></md-checkbox></md-table-cell><md-table-cell head scope="row">Ada Lovelace</md-table-cell><md-table-cell>Mathematician</md-table-cell><md-table-cell>Engineering</md-table-cell><md-table-cell numeric>$120,000</md-table-cell></md-table-row>
      <md-table-row value="v2"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Alan Turing"></md-checkbox></md-table-cell><md-table-cell head scope="row">Alan Turing</md-table-cell><md-table-cell>Cryptanalyst</md-table-cell><md-table-cell>Security</md-table-cell><md-table-cell numeric>$145,000</md-table-cell></md-table-row>
      <md-table-row value="v3"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Grace Hopper"></md-checkbox></md-table-cell><md-table-cell head scope="row">Grace Hopper</md-table-cell><md-table-cell>Compiler Engineer</md-table-cell><md-table-cell>Platform</md-table-cell><md-table-cell numeric>$132,000</md-table-cell></md-table-row>
      <md-table-row value="v4"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Linus Torvalds"></md-checkbox></md-table-cell><md-table-cell head scope="row">Linus Torvalds</md-table-cell><md-table-cell>Kernel Engineer</md-table-cell><md-table-cell>Infrastructure</md-table-cell><md-table-cell numeric>$168,000</md-table-cell></md-table-row>
      <md-table-row value="v5"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Margaret Hamilton"></md-checkbox></md-table-cell><md-table-cell head scope="row">Margaret Hamilton</md-table-cell><md-table-cell>Software Lead</md-table-cell><md-table-cell>Avionics</md-table-cell><md-table-cell numeric>$175,000</md-table-cell></md-table-row>
      <md-table-row value="v6"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Donald Knuth"></md-checkbox></md-table-cell><md-table-cell head scope="row">Donald Knuth</md-table-cell><md-table-cell>Author / Researcher</md-table-cell><md-table-cell>Research</md-table-cell><md-table-cell numeric>$195,000</md-table-cell></md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>

Two different mechanisms, routinely confused:

PropWhat it doesPair with
sticky-headerThe header stays inside the scroll region and pins to its top (classic in-flow sticky)md-table-container[max-height]
frozen-headerThe header is lifted out of the vertical scroll area into its own grid (the header-grid part), so the scrollbar spans only the bodymd-table-container[max-height]
sticky-footerPins the foot row(s) to the bottom of the scroll containermd-table-foot

max-height on the container controls the size; frozen-header controls the architecture. Without a bounded container there is nothing to stick within — the page scrolls instead.

In frozen mode, scrollbar picks the vertical bar’s presentation: overlay (default) floats a thumb over the rows’ right edge and reserves no gutter; gutter reserves a stable strip and lets the native bar render in it.

The two modes differ in what scrolls. sticky-header keeps the head inside the scroll area and pins it there, so the container’s scrollbar spans the whole table — including alongside the header — and a half-scrolled row can show a sliver beneath the pinned head. frozen-header lifts the head out of the scroll area entirely: it spans the full width, never moves, and the scrollbar is confined to the body below it. Reach for frozen-header whenever the header must stay visually separate from the scrolling data.

sticky-header against frozen-header — scroll both bodies Open in Storybook

sticky-header

Name Score Ada Lovelace98 Grace Hopper96 Alan Turing95 Katherine Johnson94 Margaret Hamilton93 Barbara Liskov92

frozen-header

Name Score Ada Lovelace98 Grace Hopper96 Alan Turing95 Katherine Johnson94 Margaret Hamilton93 Barbara Liskov92
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: flex; gap: 24px; flex-wrap: wrap; align-items: flex-start;">
  <div style="flex: 1 1 320px; min-inline-size: 280px;">
    <p style="margin: 0 0 8px; font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);"><code>sticky-header</code></p>
    <md-table-container variant="outlined" max-height="212px">
      <md-table column-template="2fr 1fr" sticky-header label="Sticky header">
        <md-table-head>
          <md-table-row rowgroup="head">
            <md-table-cell head scope="col">Name</md-table-cell>
            <md-table-cell head scope="col" numeric>Score</md-table-cell>
          </md-table-row>
        </md-table-head>
        <md-table-body>
          <md-table-row><md-table-cell>Ada Lovelace</md-table-cell><md-table-cell numeric>98</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Grace Hopper</md-table-cell><md-table-cell numeric>96</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Alan Turing</md-table-cell><md-table-cell numeric>95</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Katherine Johnson</md-table-cell><md-table-cell numeric>94</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Margaret Hamilton</md-table-cell><md-table-cell numeric>93</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Barbara Liskov</md-table-cell><md-table-cell numeric>92</md-table-cell></md-table-row>
        </md-table-body>
      </md-table>
    </md-table-container>
  </div>
  <div style="flex: 1 1 320px; min-inline-size: 280px;">
    <p style="margin: 0 0 8px; font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);"><code>frozen-header</code></p>
    <md-table-container variant="outlined" max-height="212px">
      <md-table column-template="2fr 1fr" frozen-header label="Frozen header">
        <md-table-head>
          <md-table-row rowgroup="head">
            <md-table-cell head scope="col">Name</md-table-cell>
            <md-table-cell head scope="col" numeric>Score</md-table-cell>
          </md-table-row>
        </md-table-head>
        <md-table-body>
          <md-table-row><md-table-cell>Ada Lovelace</md-table-cell><md-table-cell numeric>98</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Grace Hopper</md-table-cell><md-table-cell numeric>96</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Alan Turing</md-table-cell><md-table-cell numeric>95</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Katherine Johnson</md-table-cell><md-table-cell numeric>94</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Margaret Hamilton</md-table-cell><md-table-cell numeric>93</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Barbara Liskov</md-table-cell><md-table-cell numeric>92</md-table-cell></md-table-row>
        </md-table-body>
      </md-table>
    </md-table-container>
  </div>
</div>
Scroll sideways — the first column stays put Open in Storybook
Client Q1 Q2 Q3 Q4 Total Acme Corp120180150210660 Globex90140170160560 Initech608095110345
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined" max-height="220px">
  <md-table column-template="200px 140px 140px 140px 140px 140px" label="Wide table" sticky-header min-width="900px">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col" sticky="start">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Q1</md-table-cell>
        <md-table-cell head scope="col" numeric>Q2</md-table-cell>
        <md-table-cell head scope="col" numeric>Q3</md-table-cell>
        <md-table-cell head scope="col" numeric>Q4</md-table-cell>
        <md-table-cell head scope="col" numeric>Total</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="s1"><md-table-cell head scope="row" sticky="start">Acme Corp</md-table-cell><md-table-cell numeric>120</md-table-cell><md-table-cell numeric>180</md-table-cell><md-table-cell numeric>150</md-table-cell><md-table-cell numeric>210</md-table-cell><md-table-cell numeric>660</md-table-cell></md-table-row>
      <md-table-row value="s2"><md-table-cell head scope="row" sticky="start">Globex</md-table-cell><md-table-cell numeric>90</md-table-cell><md-table-cell numeric>140</md-table-cell><md-table-cell numeric>170</md-table-cell><md-table-cell numeric>160</md-table-cell><md-table-cell numeric>560</md-table-cell></md-table-row>
      <md-table-row value="s3"><md-table-cell head scope="row" sticky="start">Initech</md-table-cell><md-table-cell numeric>60</md-table-cell><md-table-cell numeric>80</md-table-cell><md-table-cell numeric>95</md-table-cell><md-table-cell numeric>110</md-table-cell><md-table-cell numeric>345</md-table-cell></md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>

Without a sticky column the same table simply scrolls as one plane — min-width is what forces the overflow rather than letting the columns crush:

A table wider than its container Open in Storybook
Client Q1 Q2 Q3 Q4 Total Acme Corp120180150210660 Globex90140170160560
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table column-template="repeat(6, 160px)" label="Horizontal scroll" min-width="960px">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Q1</md-table-cell>
        <md-table-cell head scope="col" numeric>Q2</md-table-cell>
        <md-table-cell head scope="col" numeric>Q3</md-table-cell>
        <md-table-cell head scope="col" numeric>Q4</md-table-cell>
        <md-table-cell head scope="col" numeric>Total</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="h1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>120</md-table-cell><md-table-cell numeric>180</md-table-cell><md-table-cell numeric>150</md-table-cell><md-table-cell numeric>210</md-table-cell><md-table-cell numeric>660</md-table-cell></md-table-row>
      <md-table-row value="h2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>90</md-table-cell><md-table-cell numeric>140</md-table-cell><md-table-cell numeric>170</md-table-cell><md-table-cell numeric>160</md-table-cell><md-table-cell numeric>560</md-table-cell></md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>
scrollbar=overlay against scrollbar=gutter — scroll each body Open in Storybook

scrollbar="overlay"

Client Amount Acme Corp$1,200.00 Globex$3,120.00 Initech$860.00 Umbrella$2,450.00 Soylent$1,975.00 Hooli$4,300.00 Stark Industries$8,120.00 Cyberdyne$2,010.00

scrollbar="gutter"

Client Amount Acme Corp$1,200.00 Globex$3,120.00 Initech$860.00 Umbrella$2,450.00 Soylent$1,975.00 Hooli$4,300.00 Stark Industries$8,120.00 Cyberdyne$2,010.00
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: flex; gap: 24px; flex-wrap: wrap; align-items: flex-start;">
  <div style="flex: 1 1 340px; min-inline-size: 300px;">
    <p style="margin: 0 0 8px; font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);"><code>scrollbar="overlay"</code></p>
    <md-table-container variant="outlined" max-height="212px">
      <md-table frozen-header scrollbar="overlay" column-template="2fr 1fr" label="overlay scrollbar">
        <md-table-head>
          <md-table-row rowgroup="head">
            <md-table-cell head scope="col">Client</md-table-cell>
            <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          </md-table-row>
        </md-table-head>
        <md-table-body>
          <md-table-row><md-table-cell>Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Initech</md-table-cell><md-table-cell numeric>$860.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Umbrella</md-table-cell><md-table-cell numeric>$2,450.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Soylent</md-table-cell><md-table-cell numeric>$1,975.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Hooli</md-table-cell><md-table-cell numeric>$4,300.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Stark Industries</md-table-cell><md-table-cell numeric>$8,120.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Cyberdyne</md-table-cell><md-table-cell numeric>$2,010.00</md-table-cell></md-table-row>
        </md-table-body>
      </md-table>
    </md-table-container>
  </div>
  <div style="flex: 1 1 340px; min-inline-size: 300px;">
    <p style="margin: 0 0 8px; font: var(--md-sys-typescale-label-large); color: var(--md-sys-color-on-surface-variant);"><code>scrollbar="gutter"</code></p>
    <md-table-container variant="outlined" max-height="212px">
      <md-table frozen-header scrollbar="gutter" column-template="2fr 1fr" label="gutter scrollbar">
        <md-table-head>
          <md-table-row rowgroup="head">
            <md-table-cell head scope="col">Client</md-table-cell>
            <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          </md-table-row>
        </md-table-head>
        <md-table-body>
          <md-table-row><md-table-cell>Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Initech</md-table-cell><md-table-cell numeric>$860.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Umbrella</md-table-cell><md-table-cell numeric>$2,450.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Soylent</md-table-cell><md-table-cell numeric>$1,975.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Hooli</md-table-cell><md-table-cell numeric>$4,300.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Stark Industries</md-table-cell><md-table-cell numeric>$8,120.00</md-table-cell></md-table-row>
          <md-table-row><md-table-cell>Cyberdyne</md-table-cell><md-table-cell numeric>$2,010.00</md-table-cell></md-table-row>
        </md-table-body>
      </md-table>
    </md-table-container>
  </div>
</div>

Related stories: a sticky column, horizontal scroll, scrollbar modes.

Pinning is imperative: call pinColumn(index, 'start' | 'end' | 'none') with the column’s original 0-based index (its position before any pinning). The table re-sequences every row’s cells, maintains the sticky attributes, permutes the column template, and emits mdPinChange. Selection and expansion state survive — the elements just move. The move is a deliberate snap, not a FLIP.

pin-mode decides how multiple pins on one side behave:

pin-modeBehaviour
stack (default)All pinned columns stick at the same edge, so later ones slide over earlier ones — only the top one stays fully visible
staticEach gets a cumulative inset, so they all stay visible side-by-side (spreadsheet-style)

setColumnVisibility(index, visible) hides or shows a column: the cells stay in the DOM (state survives) and only their track leaves the template. The last visible column cannot be hidden. pin-icon sets a table-wide pin glyph; a cell’s own pin-icon wins over the cast.

const table = document.querySelector('md-table');
await table.pinColumn(0, 'start'); // freeze the first column
await table.setColumnVisibility(3, false); // hide the fourth column

Pinned headers carry a pin marker of their own — suppress it per cell with no-pin-indicator.

Pinned columns — scroll sideways, and switch the pin mode
pin-mode="stack" pin-mode="static"
Name Team Role Location Salary Actions Ada LovelaceEngineeringMathematicianLondon$120,000 Alan TuringSecurityCryptanalystManchester$145,000 Grace HopperPlatformCompiler EngineerNew York$132,000 Linus TorvaldsInfrastructureKernel EngineerPortland$168,000 Margaret HamiltonAvionicsSoftware LeadBoston$175,000
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: grid; gap: 12px; justify-items: start;">
  <div style="display: flex; gap: 8px;">
    <md-chip id="pin-stack" variant="filter" selected>pin-mode="stack"</md-chip>
    <md-chip id="pin-static" variant="filter">pin-mode="static"</md-chip>
  </div>
  <md-table-container variant="outlined" style="inline-size: 100%;">
    <md-table id="pin-table" pin-mode="stack" column-template="180px 160px 200px 160px 140px 120px" min-width="960px" label="Pinned columns">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Name</md-table-cell>
          <md-table-cell head scope="col">Team</md-table-cell>
          <md-table-cell head scope="col">Role</md-table-cell>
          <md-table-cell head scope="col">Location</md-table-cell>
          <md-table-cell head scope="col" numeric>Salary</md-table-cell>
          <md-table-cell head scope="col">Actions</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row><md-table-cell>Ada Lovelace</md-table-cell><md-table-cell>Engineering</md-table-cell><md-table-cell>Mathematician</md-table-cell><md-table-cell>London</md-table-cell><md-table-cell numeric>$120,000</md-table-cell><md-table-cell><md-icon-button icon="edit" aria-label="Edit Ada Lovelace" density="-2"></md-icon-button><md-icon-button icon="delete" aria-label="Delete Ada Lovelace" density="-2"></md-icon-button></md-table-cell></md-table-row>
        <md-table-row><md-table-cell>Alan Turing</md-table-cell><md-table-cell>Security</md-table-cell><md-table-cell>Cryptanalyst</md-table-cell><md-table-cell>Manchester</md-table-cell><md-table-cell numeric>$145,000</md-table-cell><md-table-cell><md-icon-button icon="edit" aria-label="Edit Alan Turing" density="-2"></md-icon-button><md-icon-button icon="delete" aria-label="Delete Alan Turing" density="-2"></md-icon-button></md-table-cell></md-table-row>
        <md-table-row><md-table-cell>Grace Hopper</md-table-cell><md-table-cell>Platform</md-table-cell><md-table-cell>Compiler Engineer</md-table-cell><md-table-cell>New York</md-table-cell><md-table-cell numeric>$132,000</md-table-cell><md-table-cell><md-icon-button icon="edit" aria-label="Edit Grace Hopper" density="-2"></md-icon-button><md-icon-button icon="delete" aria-label="Delete Grace Hopper" density="-2"></md-icon-button></md-table-cell></md-table-row>
        <md-table-row><md-table-cell>Linus Torvalds</md-table-cell><md-table-cell>Infrastructure</md-table-cell><md-table-cell>Kernel Engineer</md-table-cell><md-table-cell>Portland</md-table-cell><md-table-cell numeric>$168,000</md-table-cell><md-table-cell><md-icon-button icon="edit" aria-label="Edit Linus Torvalds" density="-2"></md-icon-button><md-icon-button icon="delete" aria-label="Delete Linus Torvalds" density="-2"></md-icon-button></md-table-cell></md-table-row>
        <md-table-row><md-table-cell>Margaret Hamilton</md-table-cell><md-table-cell>Avionics</md-table-cell><md-table-cell>Software Lead</md-table-cell><md-table-cell>Boston</md-table-cell><md-table-cell numeric>$175,000</md-table-cell><md-table-cell><md-icon-button icon="edit" aria-label="Edit Margaret Hamilton" density="-2"></md-icon-button><md-icon-button icon="delete" aria-label="Delete Margaret Hamilton" density="-2"></md-icon-button></md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
</div>

<script type="module">
  var table = document.getElementById('pin-table');
  // Pin Name + Team to the start and Actions to the end, so the middle columns
  // are what actually scrolls. Pin ONCE: pinColumn toggles, so re-running these
  // three calls would UNPIN the very columns they pinned. Switching pin-mode
  // does not need a re-pin — the table recomputes the offsets on its own.
  table.pinColumn(0, 'start');
  table.pinColumn(1, 'start');
  table.pinColumn(5, 'end');
  var stack = document.getElementById('pin-stack');
  var stat = document.getElementById('pin-static');
  var setMode = function (mode) {
    table.pinMode = mode;
    stack.selected = mode === 'stack';
    stat.selected = mode === 'static';
  };
  stack.addEventListener('mdSelect', function () { setMode('stack'); });
  stat.addEventListener('mdSelect', function () { setMode('static'); });
</script>

A md-table-sort-label inside a header cell renders the arrow (3-state: asc → desc → none) and asks the table to change sort; the table pushes the new state down to every sort label and emits mdSortChange.

Call animateNextChange() before you re-render the sorted rows — it snapshots the current row boxes and FLIP-animates whatever the next synchronous DOM mutation does to them. Re-render without it and the reorder animation is lost. (Sort and pagination driven through the table’s own events are bracketed automatically; custom filters, async refetches and any other out-of-band reorder need the explicit call. Async mutations settle as a clean no-op.)

motion="expressive" is the default; set motion="none" to opt out (e.g. when you drive your own FLIP wiring). prefers-reduced-motion always wins.

table.addEventListener('mdSortChange', (e) => {
table.animateNextChange(); // opt into the reorder animation
renderRows(sortData(e.detail)); // YOU sort
});

setSort(column, order) sets the state programmatically without a click.

Sortable columns — click a header to sort, click again to reverse Open in Storybook
Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Initech$860.00Paid Umbrella$2,450.00Draft
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table id="sortable" column-template="2fr 1fr 1fr" label="Sortable invoices">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col"><md-table-sort-label column="client">Client</md-table-sort-label></md-table-cell>
        <md-table-cell head scope="col" numeric><md-table-sort-label column="amount">Amount</md-table-sort-label></md-table-cell>
        <md-table-cell head scope="col"><md-table-sort-label column="status">Status</md-table-sort-label></md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body id="sortable-body">
      <md-table-row value="r1" data-idx="0"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="r2" data-idx="1"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      <md-table-row value="r3" data-idx="2"><md-table-cell head scope="row">Initech</md-table-cell><md-table-cell numeric>$860.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="r4" data-idx="3"><md-table-cell head scope="row">Umbrella</md-table-cell><md-table-cell numeric>$2,450.00</md-table-cell><md-table-cell>Draft</md-table-cell></md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>

<script type="module">
  var COLS = { client: 0, amount: 1, status: 2 };
  var body = document.getElementById('sortable-body');
  document.getElementById('sortable').addEventListener('mdSortChange', function (e) {
    var column = e.detail.column;
    var order = e.detail.order;
    var rows = Array.prototype.slice.call(body.querySelectorAll('md-table-row'));
    rows.sort(function (a, b) {
      // order 'none' is the third click — restore the authored order.
      if (order === 'none' || !(column in COLS)) return a.dataset.idx - b.dataset.idx;
      var i = COLS[column];
      var av = (a.children[i].textContent || '').trim();
      var bv = (b.children[i].textContent || '').trim();
      var cmp = column === 'amount'
        ? parseFloat(av.replace(/[^0-9.-]/g, '')) - parseFloat(bv.replace(/[^0-9.-]/g, ''))
        : av.localeCompare(bv);
      return order === 'desc' ? -cmp : cmp;
    });
    rows.forEach(function (r) { body.appendChild(r); });
  });
</script>

The sortable story wires the full round-trip — click a header, reorder your own array, feed it back.

selectionBehaviour
none (default)No selection
singleRadio-like — selecting a row deselects every other; selectAll() and the select-all checkbox are inert
multipleCheckbox-like — select-all checkbox plus selectAll() / deselectAll() / toggleSelectAll()

Give each selectable row a value — that is what comes back in the selection state. Mark totals rows selectable="false" so selectAll() skips them.

You author the checkboxes; the table handles them. Slot a md-checkbox into a padding="checkbox" cell in each row, and one more into the head row for the select-all box. From there the table takes over: it listens for their mdChange itself, selects the row (or runs select-all / deselect-all from the head box), and keeps that box’s indeterminate “some but not all” state in sync. Plain markup works with zero wiring. The handling is deliberately declarative — it applies the checkbox’s end state rather than toggling — so a handler of your own calling toggleSelectAll() on top of it converges to the same result rather than cancelling out; it is simply redundant. Call toggleSelectAll() when something other than the select-all checkbox drives select-all: a toolbar button, a menu item, a keyboard shortcut.

const { count, total, values, all, indeterminate } = await table.getSelection();

mdSelectionChange carries the same MdTableSelectionState shape on every change.

Multiple selection — row checkboxes, select-all, and the toolbar's selection mode Open in Storybook
Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Initech$860.00Paid
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table-toolbar slot="top" headline="Invoices" supporting-text="Select rows to act on them" auto-bind>
    <md-icon-button slot="actions" icon="filter_list" aria-label="Filter"></md-icon-button>
    <md-icon-button slot="selection-actions" icon="delete" aria-label="Delete"></md-icon-button>
    <md-icon-button slot="selection-actions" icon="archive" aria-label="Archive"></md-icon-button>
  </md-table-toolbar>
  <md-table id="selectable" column-template="auto 2fr 1fr 1fr" label="Selectable invoices" selection="multiple">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head padding="checkbox"><md-checkbox id="select-all" aria-label="Select all rows"></md-checkbox></md-table-cell>
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="r1"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Acme Corp"></md-checkbox></md-table-cell><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="r2"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Globex"></md-checkbox></md-table-cell><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      <md-table-row value="r3"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Initech"></md-checkbox></md-table-cell><md-table-cell head scope="row">Initech</md-table-cell><md-table-cell numeric>$860.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>

<script type="module">
  import '@awc-ui/core/define';

  const table = document.getElementById('selectable');

  // No checkbox wiring here on purpose. You AUTHOR the checkboxes (the table
  // does not generate them), but with selection="multiple" the table handles
  // them: a slotted md-checkbox in a body row selects that row, and one in the
  // head acts as the select-all box — selectAll / deselectAll, plus the
  // indeterminate ("some but not all") state. Only reach for toggleSelectAll()
  // when something OTHER than the select-all checkbox drives it (a menu item, a
  // keyboard shortcut).

  // auto-bind on the toolbar makes it follow this event into selection mode;
  // without it, read event.detail.values yourself.
  table.addEventListener('mdSelectionChange', (e) => {
    console.log('selected rows:', e.detail.values);
  });
</script>
Expandable rows — click a caret to reveal the row's expanded slot Open in Storybook
Client Amount Status Acme Corp $1,200.00 Paid
Three line items · paid by card ending 4242 on 4 May.
Globex $3,120.00 Pending
Awaiting PO approval — chased 2 days ago.
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table column-template="auto 2fr 1fr 1fr" label="Expandable invoices" keep-height="false">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head padding="checkbox"></md-table-cell>
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="e1" expandable>
        <md-table-cell padding="checkbox"><md-table-expand-toggle button-label="Expand details for Acme Corp"></md-table-expand-toggle></md-table-cell>
        <md-table-cell head scope="row">Acme Corp</md-table-cell>
        <md-table-cell numeric>$1,200.00</md-table-cell>
        <md-table-cell>Paid</md-table-cell>
        <div slot="expanded" style="padding: 12px 16px; color: var(--md-sys-color-on-surface-variant);">Three line items · paid by card ending 4242 on 4 May.</div>
      </md-table-row>
      <md-table-row value="e2" expandable>
        <md-table-cell padding="checkbox"><md-table-expand-toggle button-label="Expand details for Globex"></md-table-expand-toggle></md-table-cell>
        <md-table-cell head scope="row">Globex</md-table-cell>
        <md-table-cell numeric>$3,120.00</md-table-cell>
        <md-table-cell>Pending</md-table-cell>
        <div slot="expanded" style="padding: 12px 16px; color: var(--md-sys-color-on-surface-variant);">Awaiting PO approval — chased 2 days ago.</div>
      </md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>
Clickable rows — the whole row is one activation target Open in Storybook
Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Initech$860.00Paid

Click a row (or focus one and press Enter).

Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table column-template="2fr 1fr 1fr" label="Clickable invoices">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="Acme Corp" clickable><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="Globex" clickable><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      <md-table-row value="Initech" clickable><md-table-cell head scope="row">Initech</md-table-cell><md-table-cell numeric>$860.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>
<p id="click-out" role="status">Click a row (or focus one and press Enter).</p>

<script type="module">
  import '@awc-ui/core/define';

  // `clickable` gives the row role="button", a pointer cursor, a ripple and
  // Enter/Space activation — so one listener covers mouse and keyboard.
  // mdRowClick bubbles, so listening on the table catches every row.
  const out = document.getElementById('click-out');
  document.querySelector('md-table').addEventListener('mdRowClick', (e) => {
    out.textContent = `Opened invoice for ${e.detail.value}.`;
  });
</script>
Controls inside cells — edit in place Open in Storybook
Client Amount Status Acme Corp PaidPending Globex PaidPending
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table column-template="2fr 1fr 1fr" label="Editable cells">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="e1">
        <md-table-cell head scope="row">Acme Corp</md-table-cell>
        <md-table-cell numeric><md-text-field label="Amount" variant="outlined" value="1200.00" density="-2" style="inline-size: 130px;"></md-text-field></md-table-cell>
        <md-table-cell><md-select label="Status" variant="outlined" density="-2" value="paid" style="inline-size: 130px;"><md-select-option value="paid">Paid</md-select-option><md-select-option value="pending">Pending</md-select-option></md-select></md-table-cell>
      </md-table-row>
      <md-table-row value="e2">
        <md-table-cell head scope="row">Globex</md-table-cell>
        <md-table-cell numeric><md-text-field label="Amount" variant="outlined" value="3120.00" density="-2" style="inline-size: 130px;"></md-text-field></md-table-cell>
        <md-table-cell><md-select label="Status" variant="outlined" density="-2" value="pending" style="inline-size: 130px;"><md-select-option value="paid">Paid</md-select-option><md-select-option value="pending">Pending</md-select-option></md-select></md-table-cell>
      </md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>

In Storybook: row selection, clickable rows, editable cells.

PropEffect
loading + loading-mode="overlay" (default)Indeterminate progress line under the header plus a scrim that dims and disables the body; header and footer stay put
loading + loading-mode="skeleton"The body is replaced by loading-rows shimmering skeleton rows (default 4); header and footer untouched
emptyRenders the empty slot instead of rows
<md-table loading loading-mode="skeleton" loading-rows="6"></md-table>
<md-table empty>
<div slot="empty">No invoices yet.</div>
</md-table>

keep-height (default on) is a height ratchet: once rendered, the table never shrinks below that initial height, so paging, filtering to zero rows or an async refetch can’t make the page jump or flash a scrollbar. Set keep-height="false" for tables that are meant to resize — a density-toggle demo, for instance.

Skeleton loading, and a custom empty state Open in Storybook
Client Amount Status Client Amount Status
No invoices match this filter.
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: grid; gap: 20px;">
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="Loading, skeleton mode" loading loading-mode="skeleton" loading-rows="3">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body></md-table-body>
    </md-table>
  </md-table-container>
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="No results" empty>
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body></md-table-body>
      <div slot="empty" style="padding: 24px; text-align: center; color: var(--md-sys-color-on-surface-variant);">No invoices match this filter.</div>
    </md-table>
  </md-table-container>
</div>

loading-mode="overlay" is the other half of the pair. Use it once rows are on screen — the existing data stays readable under an indeterminate line instead of being replaced by a skeleton, so a refresh or a page change doesn’t blank the table:

Overlay loading — rows stay visible underneath
Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: grid; gap: 20px;">
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="Loading, overlay mode" loading loading-mode="overlay">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="r1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row value="r2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="Loading, overlay mode over striped rows" loading loading-mode="overlay" striped>
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="r1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row value="r2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
</div>

With no empty slot the table falls back to its own “No data” message. Slot your own when you can say something more useful — why it is empty, and what to do next:

Empty states — the built-in message, a no-results state, and a first-run state Open in Storybook
Name Role Salary Name Role Salary
search_off
No employees match your filters
Adjust your filters, or reset them.
Reset filters
Name Role Salary
group_add
No employees yet
Add your first team member to get started.
Add employee
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: grid; gap: 20px;">
  <md-table-container variant="outlined">
    <md-table column-template="1.5fr 1fr 1fr" label="Default empty message" empty>
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Name</md-table-cell>
          <md-table-cell head scope="col">Role</md-table-cell>
          <md-table-cell head scope="col" numeric>Salary</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body></md-table-body>
    </md-table>
  </md-table-container>
  <md-table-container variant="outlined">
    <md-table column-template="1.5fr 1fr 1fr" label="No results for the filter" empty>
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Name</md-table-cell>
          <md-table-cell head scope="col">Role</md-table-cell>
          <md-table-cell head scope="col" numeric>Salary</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body></md-table-body>
      <div slot="empty" style="display: grid; justify-items: center; gap: 12px; padding: 40px 24px; text-align: center;">
        <span class="material-symbols-outlined" style="font-size: 40px; color: var(--md-sys-color-on-surface-variant);">search_off</span>
        <div style="display: grid; gap: 4px;">
          <div style="font: var(--md-sys-typescale-title-medium); color: var(--md-sys-color-on-surface);">No employees match your filters</div>
          <div style="font: var(--md-sys-typescale-body-medium); color: var(--md-sys-color-on-surface-variant);">Adjust your filters, or reset them.</div>
        </div>
        <md-button variant="filled">Reset filters</md-button>
      </div>
    </md-table>
  </md-table-container>
  <md-table-container variant="outlined">
    <md-table column-template="1.5fr 1fr 1fr" label="First-run empty" empty>
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Name</md-table-cell>
          <md-table-cell head scope="col">Role</md-table-cell>
          <md-table-cell head scope="col" numeric>Salary</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body></md-table-body>
      <div slot="empty" style="display: grid; justify-items: center; gap: 12px; padding: 40px 24px; text-align: center;">
        <span class="material-symbols-outlined" style="font-size: 40px; color: var(--md-sys-color-on-surface-variant);">group_add</span>
        <div style="display: grid; gap: 4px;">
          <div style="font: var(--md-sys-typescale-title-medium); color: var(--md-sys-color-on-surface);">No employees yet</div>
          <div style="font: var(--md-sys-typescale-body-medium); color: var(--md-sys-color-on-surface-variant);">Add your first team member to get started.</div>
        </div>
        <md-button variant="filled">Add employee</md-button>
      </div>
    </md-table>
  </md-table-container>
</div>

The empty state is its own flag — empty swaps the body for the empty slot without touching the header, so column widths stay put while a filter is cleared:

Each state has its own story: loading and empty.

Slot your own into loader when the default linear bar isn’t the right affordance — a circular md-progress-indicator, a branded mark, a skeleton.

A circular indicator replacing the default linear bar — and, with it, the scrim
Client Amount Acme 1,200
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table column-template="2fr 1fr" label="Slotted loader" loading loading-mode="overlay">
    <md-progress-indicator slot="loader" variant="circular" indeterminate size="32" label="Loading invoices"></md-progress-indicator>
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row>
        <md-table-cell>Acme</md-table-cell>
        <md-table-cell numeric>1,200</md-table-cell>
      </md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>

The two are not exclusive: the semantic sizes are themselves scale-aware, so density="compact" inside a data-density="-2" region condenses further. A numeric value drives --md-sys-density-scale on the host exactly as a global data-density ancestor would, and takes the standard row-height base.

The three semantic sizes — comfortable, standard, compact Open in Storybook
Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: grid; gap: 20px;">
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="Comfortable" density="comfortable">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="r1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row value="r2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="Standard" density="standard">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="r1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row value="r2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="Compact" density="compact">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="r1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row value="r2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
</div>

The numeric rungs are the other vocabulary — the same -4 … 0 scale every other component takes, and the same signal a global data-density ancestor sets. Row height, cell padding and type all step down together:

All five numeric rungs — 0 down to -4
Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display: grid; gap: 20px;">
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="Rung 0" density="0">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="r1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row value="r2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="Rung -1" density="-1">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="r1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row value="r2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="Rung -2" density="-2">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="r1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row value="r2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="Rung -3" density="-3">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="r1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row value="r2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" label="Rung -4" density="-4">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
          <md-table-cell head scope="col">Status</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="r1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
        <md-table-row value="r2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
</div>

The named presets are compact and comfortable; expressive motion shows the change animating.

md-table-foot holds aggregate rows, a cell can span tracks with colspan, and a two-tier header groups columns above their leaves.

A totals row in md-table-foot Open in Storybook
Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Total $4,320.00
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table column-template="2fr 1fr 1fr" label="Invoices with totals">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="inv-1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="inv-2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
    </md-table-body>
    <md-table-foot>
      <md-table-row rowgroup="foot" selectable="false">
        <md-table-cell head scope="row">Total</md-table-cell>
        <md-table-cell numeric>$4,320.00</md-table-cell>
        <md-table-cell></md-table-cell>
      </md-table-row>
    </md-table-foot>
  </md-table>
</md-table-container>
A spanning header cell grouping two columns Open in Storybook
2025 Change Region H1 H2 % EMEA412498+21% AMER388402+4%
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table column-template="2fr 1fr 1fr 1fr" label="Grouped columns">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col"></md-table-cell>
        <md-table-cell head scope="col" colspan="2" style="text-align: center;">2025</md-table-cell>
        <md-table-cell head scope="col">Change</md-table-cell>
      </md-table-row>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Region</md-table-cell>
        <md-table-cell head scope="col" numeric>H1</md-table-cell>
        <md-table-cell head scope="col" numeric>H2</md-table-cell>
        <md-table-cell head scope="col" numeric>%</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="emea"><md-table-cell head scope="row">EMEA</md-table-cell><md-table-cell numeric>412</md-table-cell><md-table-cell numeric>498</md-table-cell><md-table-cell numeric>+21%</md-table-cell></md-table-row>
      <md-table-row value="amer"><md-table-cell head scope="row">AMER</md-table-cell><md-table-cell numeric>388</md-table-cell><md-table-cell numeric>402</md-table-cell><md-table-cell numeric>+4%</md-table-cell></md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>
A body cell spanning all three columns Open in Storybook
Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Remaining 39 invoices are archived.
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table column-template="2fr 1fr 1fr" label="Spanning body cells">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="r1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="r2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      <md-table-row value="note" selectable="false">
        <md-table-cell colspan="3" style="color: var(--md-sys-color-on-surface-variant); font-style: italic;">Remaining 39 invoices are archived.</md-table-cell>
      </md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>

The spanning story covers colspan in body cells, and expandable rows puts detail content in a row’s expanded slot.

md-table-pagination is a controlled bar: it reports intent through mdPageChange / mdRowsPerPageChange and never slices your data. Feed it count, page and rows-per-page and do the paging yourself — which is what makes server-side pagination the same wiring as the client-side story.

Client pagination — page through the rows, or change the page size Open in Storybook
Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Initech$860.00Paid Umbrella$2,450.00Draft Soylent$1,975.00Paid Hooli$4,300.00Pending Stark Industries$8,120.00Paid Wayne Enterprises$6,640.00Draft Cyberdyne$2,010.00Paid Tyrell Corp$3,780.00Pending
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined" max-height="318px">
  <md-table id="pag-table" frozen-header row-count="10" column-template="2fr 1fr 1fr" label="Paged invoices">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body id="pag-body">
      <md-table-row value="p1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="p2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      <md-table-row value="p3"><md-table-cell head scope="row">Initech</md-table-cell><md-table-cell numeric>$860.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="p4"><md-table-cell head scope="row">Umbrella</md-table-cell><md-table-cell numeric>$2,450.00</md-table-cell><md-table-cell>Draft</md-table-cell></md-table-row>
      <md-table-row value="p5"><md-table-cell head scope="row">Soylent</md-table-cell><md-table-cell numeric>$1,975.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="p6"><md-table-cell head scope="row">Hooli</md-table-cell><md-table-cell numeric>$4,300.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      <md-table-row value="p7"><md-table-cell head scope="row">Stark Industries</md-table-cell><md-table-cell numeric>$8,120.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="p8"><md-table-cell head scope="row">Wayne Enterprises</md-table-cell><md-table-cell numeric>$6,640.00</md-table-cell><md-table-cell>Draft</md-table-cell></md-table-row>
      <md-table-row value="p9"><md-table-cell head scope="row">Cyberdyne</md-table-cell><md-table-cell numeric>$2,010.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="p10"><md-table-cell head scope="row">Tyrell Corp</md-table-cell><md-table-cell numeric>$3,780.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
    </md-table-body>
  </md-table>
  <md-table-pagination id="pag" slot="bottom" count="10" page="0" rows-per-page="5"></md-table-pagination>
</md-table-container>

<script type="module">
  var table = document.getElementById('pag-table');
  var pag = document.getElementById('pag');
  var page = 0;
  var perPage = 5;
  // Page by toggling row visibility rather than rebuilding rows — selection and
  // expanded state then survive a page change for free.
  var renderPage = function () {
    var rows = Array.prototype.slice.call(document.querySelectorAll('#pag-body md-table-row'));
    var start = page * perPage;
    rows.forEach(function (r, i) { r.style.display = i >= start && i < start + perPage ? '' : 'none'; });
    // NOTE: no row-offset here. It offsets aria-rowindex for rows that are NOT
    // in the DOM; this demo keeps all ten rows in the DOM and only hides them,
    // so the table already counts them and stamps the true absolute index.
    // Adding row-offset would double-count (page 2 would announce rows 12-16
    // against an aria-rowcount of 11). Use row-offset only when you render just
    // the current page — see the server-pagination example below.
  };
  pag.addEventListener('mdPageChange', function (e) { page = e.detail.page; renderPage(); });
  pag.addEventListener('mdRowsPerPageChange', function (e) { perPage = e.detail.rowsPerPage; page = 0; renderPage(); });
  renderPage();
</script>

Server-side paging is the same wiring with the data fetch in the middle. The first load has nothing to keep on screen, so it shows the skeleton; every page change after that keeps the current rows up under the overlay line while the next page is fetched:

Server-side pagination and sorting — skeleton first, overlay after Open in Storybook
Name Role Salary
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined" max-height="318px">
  <md-table id="srv-table" frozen-header row-count="47" column-template="2fr 1fr 1fr" label="Server-paged employees" loading loading-mode="skeleton" loading-rows="5">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col"><md-table-sort-label column="name">Name</md-table-sort-label></md-table-cell>
        <md-table-cell head scope="col">Role</md-table-cell>
        <md-table-cell head scope="col" numeric><md-table-sort-label column="salary">Salary</md-table-sort-label></md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body id="srv-body"></md-table-body>
  </md-table>
  <md-table-pagination id="srv-pag" slot="bottom" count="47" page="0" rows-per-page="5"></md-table-pagination>
</md-table-container>

<script type="module">
  var table = document.getElementById('srv-table');
  var body = document.getElementById('srv-body');
  var pag = document.getElementById('srv-pag');
  // The whole dataset "lives on the server" — the client only ever holds a page.
  var DATA = [];
  for (var i = 0; i < 47; i++) {
    DATA.push({
      name: 'Employee ' + String(i + 1).padStart(2, '0'),
      role: ['Engineer', 'Designer', 'Product Manager', 'Analyst'][i % 4],
      salary: 90000 + ((i * 37) % 40) * 2500,
    });
  }
  var page = 0, perPage = 5, sortBy = '', sortOrder = 'none', timer = 0;
  var money = function (n) { return '$' + n.toLocaleString('en-US'); };

  var fetchPage = function () {
    // Nothing on screen yet -> skeleton. Rows already up -> overlay, so a page
    // change never blanks the table.
    table.loadingMode = body.querySelector('md-table-row') ? 'overlay' : 'skeleton';
    table.loading = true;
    clearTimeout(timer);
    timer = setTimeout(function () {
      var rows = DATA.slice();
      if (sortBy && sortOrder !== 'none') {
        var dir = sortOrder === 'desc' ? -1 : 1;
        rows.sort(function (a, b) {
          var av = a[sortBy], bv = b[sortBy];
          return (typeof av === 'number' ? av - bv : String(av).localeCompare(String(bv))) * dir;
        });
      }
      var start = page * perPage;
      body.innerHTML = rows.slice(start, start + perPage).map(function (r) {
        return '<md-table-row><md-table-cell head scope="row">' + r.name +
          '</md-table-cell><md-table-cell>' + r.role +
          '</md-table-cell><md-table-cell numeric>' + money(r.salary) + '</md-table-cell></md-table-row>';
      }).join('');
      table.setAttribute('row-offset', String(start));
      table.loading = false;
    }, 700);
  };

  pag.addEventListener('mdPageChange', function (e) { page = e.detail.page; fetchPage(); });
  pag.addEventListener('mdRowsPerPageChange', function (e) { perPage = e.detail.rowsPerPage; page = 0; pag.page = 0; fetchPage(); });
  // Sorting is server-side too, so it re-sorts the whole set and returns to page 1.
  table.addEventListener('mdSortChange', function (e) {
    sortBy = e.detail.column; sortOrder = e.detail.order;
    page = 0; pag.page = 0; fetchPage();
  });
  fetchPage();
</script>

rows-per-page must be one of rows-per-page-options (default 5,10,25,50) — set it to a value outside that list and the selector renders blank, because no option matches. Change the list rather than the value if you need a different page size.

Every piece above, in one table: a toolbar that switches to selection mode, a quick filter, sortable columns, multiple selection with a select-all, a frozen header, a pinned first column and pagination — all of it driven by the same events documented in each section.

A complete data grid — filter, sort, select, page Open in Storybook
Name Role Team Salary Ada LovelaceMathematicianEngineering$120,000 Alan TuringCryptanalystSecurity$145,000 Grace HopperCompiler EngineerPlatform$132,000 Linus TorvaldsKernel EngineerInfrastructure$168,000 Margaret HamiltonSoftware LeadAvionics$175,000 Donald KnuthAuthor / ResearcherResearch$195,000 Barbara LiskovProgramming Language DesignerResearch$188,000 Katherine JohnsonOrbital AnalystAvionics$140,000 Tim Berners-LeeWeb ArchitectPlatform$210,000 Radia PerlmanNetwork EngineerInfrastructure$158,000 Anita BorgSystems ResearcherResearch$150,000 Guido van RossumLanguage DesignerPlatform$205,000
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined" max-height="420px">
  <md-table-toolbar id="fdg-toolbar" slot="top" headline="Employees" supporting-text="12 people" auto-bind>
    <md-icon-button slot="actions" id="fdg-filter-btn" icon="filter_list" aria-label="Filter employees"></md-icon-button>
    <md-icon-button slot="selection-actions" icon="delete" aria-label="Delete selected"></md-icon-button>
    <md-icon-button slot="selection-actions" icon="archive" aria-label="Archive selected"></md-icon-button>
  </md-table-toolbar>
  <div id="fdg-filter" hidden style="padding: 8px 16px 12px;">
    <md-text-field id="fdg-query" variant="outlined" label="Filter" density="-2" style="inline-size: 260px;"><span slot="leading-icon" class="material-symbols-outlined">search</span></md-text-field>
  </div>
  <md-table id="fdg-table" frozen-header selection="multiple" row-count="12" column-template="auto 1.6fr 1.4fr 1fr 1fr" label="Employees">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head padding="checkbox"><md-checkbox id="fdg-all" aria-label="Select all rows"></md-checkbox></md-table-cell>
        <md-table-cell head scope="col"><md-table-sort-label column="name">Name</md-table-sort-label></md-table-cell>
        <md-table-cell head scope="col"><md-table-sort-label column="role">Role</md-table-sort-label></md-table-cell>
        <md-table-cell head scope="col"><md-table-sort-label column="team">Team</md-table-sort-label></md-table-cell>
        <md-table-cell head scope="col" numeric><md-table-sort-label column="salary">Salary</md-table-sort-label></md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body id="fdg-body">
      <md-table-row value="g1"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Ada Lovelace"></md-checkbox></md-table-cell><md-table-cell head scope="row" data-col="name">Ada Lovelace</md-table-cell><md-table-cell data-col="role">Mathematician</md-table-cell><md-table-cell data-col="team">Engineering</md-table-cell><md-table-cell numeric data-col="salary">$120,000</md-table-cell></md-table-row>
      <md-table-row value="g2"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Alan Turing"></md-checkbox></md-table-cell><md-table-cell head scope="row" data-col="name">Alan Turing</md-table-cell><md-table-cell data-col="role">Cryptanalyst</md-table-cell><md-table-cell data-col="team">Security</md-table-cell><md-table-cell numeric data-col="salary">$145,000</md-table-cell></md-table-row>
      <md-table-row value="g3"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Grace Hopper"></md-checkbox></md-table-cell><md-table-cell head scope="row" data-col="name">Grace Hopper</md-table-cell><md-table-cell data-col="role">Compiler Engineer</md-table-cell><md-table-cell data-col="team">Platform</md-table-cell><md-table-cell numeric data-col="salary">$132,000</md-table-cell></md-table-row>
      <md-table-row value="g4"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Linus Torvalds"></md-checkbox></md-table-cell><md-table-cell head scope="row" data-col="name">Linus Torvalds</md-table-cell><md-table-cell data-col="role">Kernel Engineer</md-table-cell><md-table-cell data-col="team">Infrastructure</md-table-cell><md-table-cell numeric data-col="salary">$168,000</md-table-cell></md-table-row>
      <md-table-row value="g5"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Margaret Hamilton"></md-checkbox></md-table-cell><md-table-cell head scope="row" data-col="name">Margaret Hamilton</md-table-cell><md-table-cell data-col="role">Software Lead</md-table-cell><md-table-cell data-col="team">Avionics</md-table-cell><md-table-cell numeric data-col="salary">$175,000</md-table-cell></md-table-row>
      <md-table-row value="g6"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Donald Knuth"></md-checkbox></md-table-cell><md-table-cell head scope="row" data-col="name">Donald Knuth</md-table-cell><md-table-cell data-col="role">Author / Researcher</md-table-cell><md-table-cell data-col="team">Research</md-table-cell><md-table-cell numeric data-col="salary">$195,000</md-table-cell></md-table-row>
      <md-table-row value="g7"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Barbara Liskov"></md-checkbox></md-table-cell><md-table-cell head scope="row" data-col="name">Barbara Liskov</md-table-cell><md-table-cell data-col="role">Programming Language Designer</md-table-cell><md-table-cell data-col="team">Research</md-table-cell><md-table-cell numeric data-col="salary">$188,000</md-table-cell></md-table-row>
      <md-table-row value="g8"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Katherine Johnson"></md-checkbox></md-table-cell><md-table-cell head scope="row" data-col="name">Katherine Johnson</md-table-cell><md-table-cell data-col="role">Orbital Analyst</md-table-cell><md-table-cell data-col="team">Avionics</md-table-cell><md-table-cell numeric data-col="salary">$140,000</md-table-cell></md-table-row>
      <md-table-row value="g9"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Tim Berners-Lee"></md-checkbox></md-table-cell><md-table-cell head scope="row" data-col="name">Tim Berners-Lee</md-table-cell><md-table-cell data-col="role">Web Architect</md-table-cell><md-table-cell data-col="team">Platform</md-table-cell><md-table-cell numeric data-col="salary">$210,000</md-table-cell></md-table-row>
      <md-table-row value="g10"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Radia Perlman"></md-checkbox></md-table-cell><md-table-cell head scope="row" data-col="name">Radia Perlman</md-table-cell><md-table-cell data-col="role">Network Engineer</md-table-cell><md-table-cell data-col="team">Infrastructure</md-table-cell><md-table-cell numeric data-col="salary">$158,000</md-table-cell></md-table-row>
      <md-table-row value="g11"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Anita Borg"></md-checkbox></md-table-cell><md-table-cell head scope="row" data-col="name">Anita Borg</md-table-cell><md-table-cell data-col="role">Systems Researcher</md-table-cell><md-table-cell data-col="team">Research</md-table-cell><md-table-cell numeric data-col="salary">$150,000</md-table-cell></md-table-row>
      <md-table-row value="g12"><md-table-cell padding="checkbox"><md-checkbox aria-label="Select Guido van Rossum"></md-checkbox></md-table-cell><md-table-cell head scope="row" data-col="name">Guido van Rossum</md-table-cell><md-table-cell data-col="role">Language Designer</md-table-cell><md-table-cell data-col="team">Platform</md-table-cell><md-table-cell numeric data-col="salary">$205,000</md-table-cell></md-table-row>
    </md-table-body>
  </md-table>
  <md-table-pagination id="fdg-pag" slot="bottom" count="12" page="0" rows-per-page="5"></md-table-pagination>
</md-table-container>

<script type="module">
  var table = document.getElementById('fdg-table');
  var body = document.getElementById('fdg-body');
  var pag = document.getElementById('fdg-pag');
  var query = '', page = 0, perPage = 5, sortBy = '', sortOrder = 'none';

  var cell = function (row, col) { return (row.querySelector('[data-col="' + col + '"]').textContent || '').trim(); };
  var num = function (v) { return parseFloat(v.replace(/[^0-9.-]/g, '')); };

  // One render pass owns filter + sort + page. Rows are reordered and hidden,
  // never rebuilt, so selection survives every one of those changes.
  var render = function () {
    var rows = Array.prototype.slice.call(body.querySelectorAll('md-table-row'));
    if (sortBy && sortOrder !== 'none') {
      var dir = sortOrder === 'desc' ? -1 : 1;
      rows.sort(function (a, b) {
        var av = cell(a, sortBy), bv = cell(b, sortBy);
        return (sortBy === 'salary' ? num(av) - num(bv) : av.localeCompare(bv)) * dir;
      });
      rows.forEach(function (r) { body.appendChild(r); });
    }
    var q = query.trim().toLowerCase();
    var matches = rows.filter(function (r) { return !q || r.textContent.toLowerCase().indexOf(q) > -1; });
    pag.count = matches.length;
    if (page * perPage >= matches.length) { page = 0; pag.page = 0; }
    rows.forEach(function (r) { r.style.display = 'none'; });
    matches.slice(page * perPage, page * perPage + perPage).forEach(function (r) { r.style.display = ''; });
    table.empty = matches.length === 0;
    // No row-offset: every row stays in the DOM (hidden, not removed), so the
    // table's own body index is already the absolute one. row-offset is for the
    // case where only the current page exists in the DOM.
  };

  // The select-all checkbox needs no listener — selection="multiple" makes the
  // table handle slotted checkboxes, head box included.
  table.addEventListener('mdSortChange', function (e) { sortBy = e.detail.column; sortOrder = e.detail.order; render(); });
  pag.addEventListener('mdPageChange', function (e) { page = e.detail.page; render(); });
  pag.addEventListener('mdRowsPerPageChange', function (e) { perPage = e.detail.rowsPerPage; page = 0; render(); });

  var panel = document.getElementById('fdg-filter');
  var field = document.getElementById('fdg-query');
  document.getElementById('fdg-filter-btn').addEventListener('click', function () {
    panel.hidden = !panel.hidden;
    if (!panel.hidden) field.setFocus();
  });
  // md-text-field emits mdInput with the value AS the detail (a string) — not
  // wrapped in an object.
  field.addEventListener('mdInput', function (e) { query = e.detail; page = 0; pag.page = 0; render(); });
  render();
</script>
EventCancelableDetailFires
mdSortChangenoMdTableSortStateAfter a md-table-sort-label is clicked, or setSort() runs
mdSelectionChangenoMdTableSelectionStateWhenever the row selection changes
mdPinChangeno{ column, side }side is 'start' | 'end' | nullA column is pinned / unpinned via pinColumn()
mdColumnVisibilityChangeno{ column, visible, hidden }setColumnVisibility() runs
mdScrollno{ scrollLeft, scrollTop }rAF-throttled scroll of the frozen body scroller
interface MdTableSortState {
column: string; // '' when there is no sort
order: 'asc' | 'desc' | 'none';
}
interface MdTableSelectionState {
count: number; // selected rows
total: number; // selectable rendered rows
values: string[]; // each selected row's value / id
all: boolean;
indeterminate: boolean;
}

All five events bubble and are composed, so one delegated listener on an ancestor catches them. Sort a column or tick a row below and watch the two you will actually wire — mdSortChange and mdSelectionChange — arrive:

Both events, live — sort a column or tick a row and read the log
Client Amount Acme Corp$1,200.00 Globex$3,120.00 Initech$860.00
Sort a column, or tick a row — the events land here.
Show code for each technology
<md-table-container variant="outlined">
<md-table id="t" column-template="auto 2fr 1fr" selection="multiple" label="Invoices">
  <md-table-head>
    <md-table-row rowgroup="head">
      <md-table-cell head padding="checkbox"><md-checkbox id="all" aria-label="Select all rows"></md-checkbox></md-table-cell>
      <md-table-cell head scope="col"><md-table-sort-label column="client">Client</md-table-sort-label></md-table-cell>
      <md-table-cell head scope="col" numeric><md-table-sort-label column="amount">Amount</md-table-sort-label></md-table-cell>
    </md-table-row>
  </md-table-head>
  <md-table-body id="rows"></md-table-body>
</md-table>
</md-table-container>

<script type="module">
import '@awc-ui/core/define';

const table = document.getElementById('t');

// No checkbox listener needed: selection="multiple" makes the table handle
// the slotted boxes, select-all box included.

table.addEventListener('mdSortChange', (e) => {
  const { column, order } = e.detail;   // MdTableSortState
  table.animateNextChange();            // before the DOM mutation
  render(sortRows(data, column, order));// YOU sort
});

table.addEventListener('mdSelectionChange', (e) => {
  // MdTableSelectionState: count, total, values, all, indeterminate
  console.log(e.detail.count, e.detail.values);
});
</script>

Properties

PropertyAttributeTypeDefaultReflects
densitydensity| 'compact' | 'standard' | 'comfortable' | 0 | -1 | -2 | -3 | -4'standard'Yes
columnTemplatecolumn-templatestring''
columnscolumnsnumber0
minWidthmin-widthstring''
stickyHeadersticky-headerbooleanfalseYes
frozenHeaderfrozen-headerbooleanfalseYes
keepHeightkeep-heightbooleantrueYes
scrollbarscrollbar'overlay' | 'gutter''overlay'Yes
pinModepin-mode'stack' | 'static''stack'Yes
pinIconpin-iconstring''
stickyFootersticky-footerbooleanfalseYes
stripedstripedbooleanfalseYes
noDividersno-dividersbooleanfalseYes
hoverablehoverablebooleantrueYes
motionmotion'expressive' | 'none''expressive'Yes
selectionselection'none' | 'single' | 'multiple''none'Yes
sortBysort-bystring''Yes
sortOrdersort-orderMdTableSortOrder'asc'Yes
loadingloadingbooleanfalseYes
loadingModeloading-mode'overlay' | 'skeleton''overlay'Yes
loadingRowsloading-rowsnumber4
emptyemptybooleanfalseYes
labellabelstring''
captioncaptionstring''
summarysummarystring''
rowOffsetrow-offsetnumber0
rowCountrow-countnumber0

Methods

MethodParameters
getSelection()none
selectAll()none
deselectAll()none
toggleSelectAll()none
setSort()column: string, order: MdTableSortOrder
pinColumn()column: number, side: 'start' | 'end' | 'none'
setColumnVisibility()column: number, visible: boolean
animateNextChange()none

Slots

SlotDescription
(default)
empty
head
loader
loading

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-table-row-height-compact36px (dense)
--md-table-row-height-standard52px
--md-table-row-height-comfortable60px
--md-table-header-height56px
--md-table-cell-padding-inlineper-density default
--md-table-divider-colorbetween rows
--md-table-stripe-colorevery-other-row overlay color
--md-table-head-bgbackground for thead rows
--md-table-head-colortext color for thead
--md-table-foot-bgbackground for tfoot rows
--md-table-row-hover-colorrow hover state-layer color
--md-table-row-selected-bgselected row background
--md-table-row-selected-colorselected row text
--md-table-columns-templategrid-template-columns override
--md-table-cell-padding-block
--md-table-min-width
--md-table-body-max-h
--md-table-empty-min-height

CSS Shadow Parts

Style internal elements through shadow DOM with ::part():

PartDescription
skeleton-row
emptyThe empty-state region (when `empty`)
captionThe optional caption above the head
header-grid
gridThe grid container holding all rows
hscrollbar
hscrollbar-thumb
vscrollbar
vscrollbar-thumb
loadingThe loading overlay (when `loading`)
progress
  • Because the layout is CSS Grid rather than a native <table>, the ARIA structure carries the semantics: the rowgroups are rowgroups, md-table-row is a row, and md-table-cell is a cell — or a column / row header via head + scope.
  • Get scope right. head alone styles a cell like a header; scope="col" / scope="row" / scope="colgroup" is what makes data cells announce with their column or row. The trio — rowgroup on the row, head and scope on the cell — works together.
  • label names the role="table". When it’s omitted the caption text names the table instead (IDREFs can’t cross the shadow boundary, so the caption text is mirrored into aria-label rather than referenced); the generic “Data table” is the last resort. Never ship an unnamed data grid.
  • summary describes a complex table’s structure (“Columns are grouped under Profile and Employment”) and is exposed as aria-description.
  • For a paginated table set row-offset (0-based index of the first rendered row) and row-count (the full dataset size), so each row gets an aria-rowindex and AT announces “row 42 of 5000” instead of a position within the current page. Update row-offset on every page change. row-offset only applies when the DOM holds just the current page — the table counts every md-table-row present, visible or not, so if you page by hiding rows rather than removing them the indices are already absolute and row-offset would double-count them. (Setting row-offset alone doesn’t re-stamp anything either: change it together with the rows.)
  • Sticky and frozen headers must not cover the focused row — check keyboard scrolling after changing heights.
  • motion="none" for reduced-motion users; prefers-reduced-motion is honoured automatically.

Everything focusable in a table is something you slotted into a cell, so the tab order is the order you authored. Tab through this one: each row’s expand toggle and checkbox is a stop, and the rows announce their position in the whole dataset because row-offset and row-count are set:

Named, described and positioned — try tabbing through it
Client Amount Acme Corp $1,200.00
Three line items · paid by card ending 4242 on 4 May.
Globex $3,120.00
Awaiting PO approval — chased 2 days ago.
Initech $860.00
Partially credited against invoice 41.
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table
    column-template="auto auto 2fr 1fr"
    selection="multiple"
    label="Invoices"
    caption="Invoices — rows 21 to 23 of 120"
    summary="Four columns: an expand toggle, a selection checkbox, the client name and the amount in US dollars."
    row-offset="20"
    row-count="120"
    keep-height="false"
    >
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head padding="checkbox"></md-table-cell>
        <md-table-cell head padding="checkbox"></md-table-cell>
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="a1" expandable>
        <md-table-cell padding="checkbox"><md-table-expand-toggle button-label="Expand details for Acme Corp"></md-table-expand-toggle></md-table-cell>
        <md-table-cell padding="checkbox"><md-checkbox aria-label="Select Acme Corp"></md-checkbox></md-table-cell>
        <md-table-cell head scope="row">Acme Corp</md-table-cell>
        <md-table-cell numeric>$1,200.00</md-table-cell>
        <div slot="expanded" style="padding: 12px 16px; color: var(--md-sys-color-on-surface-variant);">Three line items · paid by card ending 4242 on 4 May.</div>
      </md-table-row>
      <md-table-row value="a2" expandable>
        <md-table-cell padding="checkbox"><md-table-expand-toggle button-label="Expand details for Globex"></md-table-expand-toggle></md-table-cell>
        <md-table-cell padding="checkbox"><md-checkbox aria-label="Select Globex"></md-checkbox></md-table-cell>
        <md-table-cell head scope="row">Globex</md-table-cell>
        <md-table-cell numeric>$3,120.00</md-table-cell>
        <div slot="expanded" style="padding: 12px 16px; color: var(--md-sys-color-on-surface-variant);">Awaiting PO approval — chased 2 days ago.</div>
      </md-table-row>
      <md-table-row value="a3" expandable>
        <md-table-cell padding="checkbox"><md-table-expand-toggle button-label="Expand details for Initech"></md-table-expand-toggle></md-table-cell>
        <md-table-cell padding="checkbox"><md-checkbox aria-label="Select Initech"></md-checkbox></md-table-cell>
        <md-table-cell head scope="row">Initech</md-table-cell>
        <md-table-cell numeric>$860.00</md-table-cell>
        <div slot="expanded" style="padding: 12px 16px; color: var(--md-sys-color-on-surface-variant);">Partially credited against invoice 41.</div>
      </md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>

RTL — column order, pinning sides and the scrollbars mirror under dir="rtl". column-template is a raw grid value though, so track sizes follow the columns rather than adapting to them: a width hand-tuned for one language needs re-checking in the next. See the RTL story and RTL.

The same table in both directions. Nothing is re-authored between them — only dir changes. The tracks are laid out in the inline direction, so the first column moves to the right-hand edge and the horizontal scrollbar starts from the other end:

Same markup, dir=ltr vs dir=rtl Open in Storybook
ltr
العميل المبلغ الحالة أكمي١٢٠٠٫٠٠مدفوع غلوبكس٣١٢٠٫٠٠معلق
rtl
العميل المبلغ الحالة أكمي١٢٠٠٫٠٠مدفوع غلوبكس٣١٢٠٫٠٠معلق
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display:grid;grid-template-columns:auto 1fr;gap:14px 16px;align-items:center;">
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">ltr</span>
  <div dir="ltr">
    <md-table-container variant="outlined">
      <md-table column-template="2fr 1fr 1fr" label="الفواتير">
        <md-table-head>
          <md-table-row rowgroup="head">
            <md-table-cell head scope="col">العميل</md-table-cell>
            <md-table-cell head scope="col" numeric>المبلغ</md-table-cell>
            <md-table-cell head scope="col">الحالة</md-table-cell>
          </md-table-row>
        </md-table-head>
        <md-table-body>
          <md-table-row value="1"><md-table-cell head scope="row">أكمي</md-table-cell><md-table-cell numeric>١٢٠٠٫٠٠</md-table-cell><md-table-cell>مدفوع</md-table-cell></md-table-row>
          <md-table-row value="2"><md-table-cell head scope="row">غلوبكس</md-table-cell><md-table-cell numeric>٣١٢٠٫٠٠</md-table-cell><md-table-cell>معلق</md-table-cell></md-table-row>
        </md-table-body>
      </md-table>
    </md-table-container>
  </div>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">rtl</span>
  <div dir="rtl">
    <md-table-container variant="outlined">
      <md-table column-template="2fr 1fr 1fr" label="الفواتير">
        <md-table-head>
          <md-table-row rowgroup="head">
            <md-table-cell head scope="col">العميل</md-table-cell>
            <md-table-cell head scope="col" numeric>المبلغ</md-table-cell>
            <md-table-cell head scope="col">الحالة</md-table-cell>
          </md-table-row>
        </md-table-head>
        <md-table-body>
          <md-table-row value="1"><md-table-cell head scope="row">أكمي</md-table-cell><md-table-cell numeric>١٢٠٠٫٠٠</md-table-cell><md-table-cell>مدفوع</md-table-cell></md-table-row>
          <md-table-row value="2"><md-table-cell head scope="row">غلوبكس</md-table-cell><md-table-cell numeric>٣١٢٠٫٠٠</md-table-cell><md-table-cell>معلق</md-table-cell></md-table-row>
        </md-table-body>
      </md-table>
    </md-table-container>
  </div>
</div>

column-template is a raw grid value, so size it in fr

Section titled “column-template is a raw grid value, so size it in fr”

The direction mirrors for free; the track sizes do not adapt. A width hand-tuned for one language’s headers is still that width after translation, and a px track that fitted “Client” will crowd or truncate the same column in Arabic or German. Size the text columns in fr / minmax() and keep px for genuinely fixed content — a checkbox, an icon column:

A px-sized text column against an fr-sized one, same translated content
correct
المنطقة الجغرافية المبلغ الحالة الشرق الأوسط١٢٠٠٫٠٠مدفوع
wrong
المنطقة الجغرافية المبلغ الحالة الشرق الأوسط١٢٠٠٫٠٠مدفوع
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display:grid;grid-template-columns:auto 1fr;gap:14px 16px;align-items:center;">
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">correct</span>
  <div dir="rtl">
    <md-table-container variant="outlined">
      <md-table column-template="minmax(120px, 2fr) 1fr 1fr" label="أرقام المبيعات">
        <md-table-head>
          <md-table-row rowgroup="head">
            <md-table-cell head scope="col">المنطقة الجغرافية</md-table-cell>
            <md-table-cell head scope="col" numeric>المبلغ</md-table-cell>
            <md-table-cell head scope="col">الحالة</md-table-cell>
          </md-table-row>
        </md-table-head>
        <md-table-body>
          <md-table-row value="c1"><md-table-cell head scope="row">الشرق الأوسط</md-table-cell><md-table-cell numeric>١٢٠٠٫٠٠</md-table-cell><md-table-cell>مدفوع</md-table-cell></md-table-row>
        </md-table-body>
      </md-table>
    </md-table-container>
  </div>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">wrong</span>
  <div dir="rtl">
    <md-table-container variant="outlined">
      <md-table column-template="72px 1fr 1fr" label="أرقام المبيعات">
        <md-table-head>
          <md-table-row rowgroup="head">
            <md-table-cell head scope="col">المنطقة الجغرافية</md-table-cell>
            <md-table-cell head scope="col" numeric>المبلغ</md-table-cell>
            <md-table-cell head scope="col">الحالة</md-table-cell>
          </md-table-row>
        </md-table-head>
        <md-table-body>
          <md-table-row value="w1"><md-table-cell head scope="row">الشرق الأوسط</md-table-cell><md-table-cell numeric>١٢٠٠٫٠٠</md-table-cell><md-table-cell>مدفوع</md-table-cell></md-table-row>
        </md-table-body>
      </md-table>
    </md-table-container>
  </div>
</div>

density takes the numeric rungs as well as the semantic words, and a numeric value locally overrides the inherited data-density. All five rungs, the same two rows, so the taper is visible in one place:

Density 0 through -4 — row height, cell padding and type taper together
0 ClientAmountAcme Corp$1,200.00Globex$3,120.00 -1 ClientAmountAcme Corp$1,200.00Globex$3,120.00 -2 ClientAmountAcme Corp$1,200.00Globex$3,120.00 -3 ClientAmountAcme Corp$1,200.00Globex$3,120.00 -4 ClientAmountAcme Corp$1,200.00Globex$3,120.00
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="display:grid;grid-template-columns:auto 1fr;gap:14px 16px;align-items:center;">
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">0</span>
  <md-table-container variant="outlined"><md-table column-template="2fr 1fr" density="0" label="Rung 0"><md-table-head><md-table-row rowgroup="head"><md-table-cell head scope="col">Client</md-table-cell><md-table-cell head scope="col" numeric>Amount</md-table-cell></md-table-row></md-table-head><md-table-body><md-table-row><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell></md-table-row><md-table-row><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell></md-table-row></md-table-body></md-table></md-table-container>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">-1</span>
  <md-table-container variant="outlined"><md-table column-template="2fr 1fr" density="-1" label="Rung -1"><md-table-head><md-table-row rowgroup="head"><md-table-cell head scope="col">Client</md-table-cell><md-table-cell head scope="col" numeric>Amount</md-table-cell></md-table-row></md-table-head><md-table-body><md-table-row><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell></md-table-row><md-table-row><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell></md-table-row></md-table-body></md-table></md-table-container>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">-2</span>
  <md-table-container variant="outlined"><md-table column-template="2fr 1fr" density="-2" label="Rung -2"><md-table-head><md-table-row rowgroup="head"><md-table-cell head scope="col">Client</md-table-cell><md-table-cell head scope="col" numeric>Amount</md-table-cell></md-table-row></md-table-head><md-table-body><md-table-row><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell></md-table-row><md-table-row><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell></md-table-row></md-table-body></md-table></md-table-container>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">-3</span>
  <md-table-container variant="outlined"><md-table column-template="2fr 1fr" density="-3" label="Rung -3"><md-table-head><md-table-row rowgroup="head"><md-table-cell head scope="col">Client</md-table-cell><md-table-cell head scope="col" numeric>Amount</md-table-cell></md-table-row></md-table-head><md-table-body><md-table-row><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell></md-table-row><md-table-row><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell></md-table-row></md-table-body></md-table></md-table-container>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">-4</span>
  <md-table-container variant="outlined"><md-table column-template="2fr 1fr" density="-4" label="Rung -4"><md-table-head><md-table-row rowgroup="head"><md-table-cell head scope="col">Client</md-table-cell><md-table-cell head scope="col" numeric>Amount</md-table-cell></md-table-row></md-table-head><md-table-body><md-table-row><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell></md-table-row><md-table-row><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell></md-table-row></md-table-body></md-table></md-table-container>
</div>

The two are independent signals, so they compose without any extra wiring: dir="rtl" and data-density="-2" on the same ancestor. The second table asks for comfortable rows inside that region — and because the semantic sizes are themselves scale-aware, it relaxes relative to the rung rather than escaping it:

dir=rtl inside data-density=-2, and one table asking for comfortable rows
العميل المبلغ الحالة أكمي١٢٠٠٫٠٠مدفوع غلوبكس٣١٢٠٫٠٠معلق العميل المبلغ الحالة أكمي١٢٠٠٫٠٠مدفوع غلوبكس٣١٢٠٫٠٠معلق
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div dir="rtl" data-density="-2" style="display:grid;gap:16px;">
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" striped label="الفواتير">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">العميل</md-table-cell>
          <md-table-cell head scope="col" numeric>المبلغ</md-table-cell>
          <md-table-cell head scope="col">الحالة</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="d1"><md-table-cell head scope="row">أكمي</md-table-cell><md-table-cell numeric>١٢٠٠٫٠٠</md-table-cell><md-table-cell>مدفوع</md-table-cell></md-table-row>
        <md-table-row value="d2"><md-table-cell head scope="row">غلوبكس</md-table-cell><md-table-cell numeric>٣١٢٠٫٠٠</md-table-cell><md-table-cell>معلق</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
  <md-table-container variant="outlined">
    <md-table column-template="2fr 1fr 1fr" striped density="comfortable" label="الفواتير — مريح">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">العميل</md-table-cell>
          <md-table-cell head scope="col" numeric>المبلغ</md-table-cell>
          <md-table-cell head scope="col">الحالة</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="d3"><md-table-cell head scope="row">أكمي</md-table-cell><md-table-cell numeric>١٢٠٠٫٠٠</md-table-cell><md-table-cell>مدفوع</md-table-cell></md-table-row>
        <md-table-row value="d4"><md-table-cell head scope="row">غلوبكس</md-table-cell><md-table-cell numeric>٣١٢٠٫٠٠</md-table-cell><md-table-cell>معلق</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
</div>

Headers, cells and the empty state are all your content, so they come straight from your i18n layer. Only the pagination and expand-toggle chrome has label props of its own — and md-table-pagination’s use percent tokens (%from%, %to%, %count%), not the braces used elsewhere in the library. The localization story switches the lot.

Translated content and translated pagination chrome Open in Storybook
Kunde Betrag Status Acme GmbH1.200,00 €Bezahlt Globex AG3.120,00 €Offen Initech KG860,00 €Bezahlt Umbrella SE2.450,00 €Entwurf Soylent GmbH1.975,00 €Bezahlt Hooli AG4.300,00 €Offen Stark Industries8.120,00 €Bezahlt Wayne Enterprises6.640,00 €Entwurf Cyberdyne GmbH2.010,00 €Bezahlt Tyrell Corp3.780,00 €Offen
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined">
  <md-table column-template="2fr 1fr 1fr" label="Rechnungen">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Kunde</md-table-cell>
        <md-table-cell head scope="col" numeric>Betrag</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body id="loc-body">
      <md-table-row value="l1"><md-table-cell head scope="row">Acme GmbH</md-table-cell><md-table-cell numeric>1.200,00 €</md-table-cell><md-table-cell>Bezahlt</md-table-cell></md-table-row>
      <md-table-row value="l2"><md-table-cell head scope="row">Globex AG</md-table-cell><md-table-cell numeric>3.120,00 €</md-table-cell><md-table-cell>Offen</md-table-cell></md-table-row>
      <md-table-row value="l3"><md-table-cell head scope="row">Initech KG</md-table-cell><md-table-cell numeric>860,00 €</md-table-cell><md-table-cell>Bezahlt</md-table-cell></md-table-row>
      <md-table-row value="l4"><md-table-cell head scope="row">Umbrella SE</md-table-cell><md-table-cell numeric>2.450,00 €</md-table-cell><md-table-cell>Entwurf</md-table-cell></md-table-row>
      <md-table-row value="l5"><md-table-cell head scope="row">Soylent GmbH</md-table-cell><md-table-cell numeric>1.975,00 €</md-table-cell><md-table-cell>Bezahlt</md-table-cell></md-table-row>
      <md-table-row value="l6"><md-table-cell head scope="row">Hooli AG</md-table-cell><md-table-cell numeric>4.300,00 €</md-table-cell><md-table-cell>Offen</md-table-cell></md-table-row>
      <md-table-row value="l7"><md-table-cell head scope="row">Stark Industries</md-table-cell><md-table-cell numeric>8.120,00 €</md-table-cell><md-table-cell>Bezahlt</md-table-cell></md-table-row>
      <md-table-row value="l8"><md-table-cell head scope="row">Wayne Enterprises</md-table-cell><md-table-cell numeric>6.640,00 €</md-table-cell><md-table-cell>Entwurf</md-table-cell></md-table-row>
      <md-table-row value="l9"><md-table-cell head scope="row">Cyberdyne GmbH</md-table-cell><md-table-cell numeric>2.010,00 €</md-table-cell><md-table-cell>Bezahlt</md-table-cell></md-table-row>
      <md-table-row value="l10"><md-table-cell head scope="row">Tyrell Corp</md-table-cell><md-table-cell numeric>3.780,00 €</md-table-cell><md-table-cell>Offen</md-table-cell></md-table-row>
    </md-table-body>
  </md-table>
  <md-table-pagination id="loc-pag" slot="bottom" count="10" page="0" rows-per-page="5" label-rows-per-page="Zeilen pro Seite:" label-displayed-rows="%from%–%to% von %count%"></md-table-pagination>
</md-table-container>

<script type="module">
  var pag = document.getElementById('loc-pag');
  var body = document.getElementById('loc-body');
  var page = 0, perPage = 5;
  var renderPage = function () {
    var rows = Array.prototype.slice.call(body.querySelectorAll('md-table-row'));
    rows.forEach(function (r, i) { r.style.display = i >= page * perPage && i < (page + 1) * perPage ? '' : 'none'; });
  };
  pag.addEventListener('mdPageChange', function (e) { page = e.detail.page; renderPage(); });
  pag.addEventListener('mdRowsPerPageChange', function (e) { perPage = e.detail.rowsPerPage; page = 0; renderPage(); });
  renderPage();
</script>

Density — dual-typed here: compact / standard / comfortable or the numeric rungs -1…-4, which locally override the inherited data-density (0 is the uncompacted default, not a rung — it inherits). Row heights are also settable directly via --md-table-row-height-*. See Density and the Density section above.

i18n — headers, cells and the empty state are your slotted content, so they come from your i18n layer; lang and dir are inherited from any ancestor. Localize every aria-label on the row checkboxes and expand toggles too, and prefer fr units over fixed px in column-template so longer translations still fit.

Custom propertyPurposeDefault
--md-table-row-height-compact / -standard / -comfortableRow heights per semantic density36px / 52px / 60px, each shifted by the density rung (compact floors at 28px, comfortable at 40px)
--md-table-header-heightHeader row height56px, density-shifted, floored at 40px
--md-table-cell-padding-inline / -padding-blockCell padding16px / 8px at standard (12px / 4px compact, 20px / 8px comfortable)
--md-table-divider-colorRow rules (also toggled by no-dividers)outline-variant
--md-table-stripe-colorAlternate rows when stripedon-surface at half the hover state-layer opacity
--md-table-head-bg / --md-table-head-colorHeader bandsurface-container / on-surface-variant
--md-table-foot-bgFooter bandsurface-container-low
--md-table-row-hover-colorRow hover state-layer colour, mixed at the M3 hover opacity (hoverable, on by default)on-surface
--md-table-row-selected-bg / -selected-colorSelected rowssecondary-container / on-secondary-container
--md-table-min-widthSame floor as the min-width propmax-content
--md-table-columns-templateTrack template, as a custom propertyderived from the column-template prop
--md-table-body-max-hBody cap in frozen modenone
--md-table-empty-min-heightHeight reserved for the empty state160px
Custom properties — header, hover and divider colours Open in Storybook
Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined" style="--md-table-head-bg: var(--md-sys-color-primary-container); --md-table-head-color: var(--md-sys-color-on-primary-container); --md-table-row-hover-color: var(--md-sys-color-primary); --md-table-stripe-color: var(--md-sys-color-surface-container-high); --md-table-divider-color: var(--md-sys-color-primary);">
  <md-table column-template="2fr 1fr 1fr" label="Custom properties">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="r1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="r2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>
A narrow column — fr units and ellipsis cells Open in Storybook
Client Amount A client with a very long name$1,200.00 Globex International Holdings$3,120.00
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<div style="inline-size: 100%; max-inline-size: 320px;">
  <md-table-container variant="outlined">
    <md-table column-template="1fr auto" label="In a narrow column">
      <md-table-head>
        <md-table-row rowgroup="head">
          <md-table-cell head scope="col">Client</md-table-cell>
          <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        </md-table-row>
      </md-table-head>
      <md-table-body>
        <md-table-row value="n1"><md-table-cell head scope="row" ellipsis>A client with a very long name</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell></md-table-row>
        <md-table-row value="n2"><md-table-cell head scope="row" ellipsis>Globex International Holdings</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell></md-table-row>
      </md-table-body>
    </md-table>
  </md-table-container>
</div>

Two more theme treatments in Storybook: vibrant and dark theme. The responsive story steps a table through the breakpoints, and custom CSS collects the property overrides in one place.

CSS parts reach inside the shadow root for what custom properties don’t cover — grid, header-grid, caption, empty, skeleton-row, loading, progress, hscrollbar, hscrollbar-thumb, vscrollbar, vscrollbar-thumb. header-grid and the two scrollbar pairs exist only in frozen-header mode (that’s the layout that splits the header off and draws its own scrollbars), and caption only renders when caption is set:

CSS parts — a styled caption, a header-grid rule and a tinted scroll thumb
Client Amount Status Acme Corp$1,200.00Paid Globex$3,120.00Pending Initech$860.00Paid Umbrella$2,450.00Draft Soylent$1,975.00Paid Hooli$4,300.00Pending
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<style>
  .parted::part(caption) {
  color: var(--md-sys-color-primary);
  font-weight: 600;
  letter-spacing: .06em;
  text-transform: uppercase;
  }
  .parted::part(header-grid) { box-shadow: 0 2px 0 var(--md-sys-color-primary); }
  .parted::part(grid) { background: var(--md-sys-color-surface-container-lowest); }
  .parted::part(vscrollbar-thumb) { background: var(--md-sys-color-primary); }
</style>
<md-table-container variant="outlined" max-height="240px">
  <md-table class="parted" frozen-header caption="Q3 invoices" column-template="2fr 1fr 1fr" label="Themed with CSS parts">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="c1"><md-table-cell head scope="row">Acme Corp</md-table-cell><md-table-cell numeric>$1,200.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="c2"><md-table-cell head scope="row">Globex</md-table-cell><md-table-cell numeric>$3,120.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
      <md-table-row value="c3"><md-table-cell head scope="row">Initech</md-table-cell><md-table-cell numeric>$860.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="c4"><md-table-cell head scope="row">Umbrella</md-table-cell><md-table-cell numeric>$2,450.00</md-table-cell><md-table-cell>Draft</md-table-cell></md-table-row>
      <md-table-row value="c5"><md-table-cell head scope="row">Soylent</md-table-cell><md-table-cell numeric>$1,975.00</md-table-cell><md-table-cell>Paid</md-table-cell></md-table-row>
      <md-table-row value="c6"><md-table-cell head scope="row">Hooli</md-table-cell><md-table-cell numeric>$4,300.00</md-table-cell><md-table-cell>Pending</md-table-cell></md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>
md-table::part(header-grid) {
box-shadow: 0 1px 0 var(--md-sys-color-outline-variant);
}

A table is composed from ten elements. Each one is documented here rather than on its own page: none of them mean anything outside a table, and the parent writes several of their attributes for you.

Here they are together — the container’s top and bottom bands holding a toolbar and a pagination bar outside the scroll region, head / body / foot rowgroups, and a row’s expanded slot opening a detail panel underneath it:

The sub-components together — top/bottom bands, three rowgroups, and an expanded row
Client Amount Status Acme Corp $1,200.00 Paid
Three line items · paid by card ending 4242 on 4 May.
Globex $3,120.00 Pending
Awaiting PO approval — chased 2 days ago.
Initech $860.00 Paid
Total $5,180.00
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-table-container variant="outlined" max-height="300px">
  <md-table-toolbar slot="top" headline="Invoices" supporting-text="Expand a row for its detail"></md-table-toolbar>
  <md-table column-template="auto 2fr 1fr 1fr" sticky-footer label="Sub-components together">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head padding="checkbox"></md-table-cell>
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="s1" expandable>
        <md-table-cell padding="checkbox"><md-table-expand-toggle button-label="Expand details for Acme Corp"></md-table-expand-toggle></md-table-cell>
        <md-table-cell head scope="row">Acme Corp</md-table-cell>
        <md-table-cell numeric>$1,200.00</md-table-cell>
        <md-table-cell>Paid</md-table-cell>
        <div slot="expanded" style="padding: 12px 16px; color: var(--md-sys-color-on-surface-variant);">Three line items · paid by card ending 4242 on 4 May.</div>
      </md-table-row>
      <md-table-row value="s2" expandable>
        <md-table-cell padding="checkbox"><md-table-expand-toggle button-label="Expand details for Globex"></md-table-expand-toggle></md-table-cell>
        <md-table-cell head scope="row">Globex</md-table-cell>
        <md-table-cell numeric>$3,120.00</md-table-cell>
        <md-table-cell>Pending</md-table-cell>
        <div slot="expanded" style="padding: 12px 16px; color: var(--md-sys-color-on-surface-variant);">Awaiting PO approval — chased 2 days ago.</div>
      </md-table-row>
      <md-table-row value="s3">
        <md-table-cell padding="checkbox"></md-table-cell>
        <md-table-cell head scope="row">Initech</md-table-cell>
        <md-table-cell numeric>$860.00</md-table-cell>
        <md-table-cell>Paid</md-table-cell>
      </md-table-row>
    </md-table-body>
    <md-table-foot>
      <md-table-row rowgroup="foot" selectable="false">
        <md-table-cell padding="checkbox"></md-table-cell>
        <md-table-cell head scope="row">Total</md-table-cell>
        <md-table-cell numeric>$5,180.00</md-table-cell>
        <md-table-cell></md-table-cell>
      </md-table-row>
    </md-table-foot>
  </md-table>
  <md-table-pagination slot="bottom" count="3" page="0" rows-per-page="5"></md-table-pagination>
</md-table-container>

The surface around a md-table. It provides elevation, shape, the scroll region, and the top / bottom bands where the toolbar and pagination live.

  • top and bottom sit OUTSIDE the scroll region. That is exactly why the toolbar and pagination belong there — they stay put while the rows scroll. Put them in the default slot and they scroll away with the table.
  • max-height is what makes the scroll region finite, and therefore what makes sticky-header / frozen-header on md-table work at all. Without it the container grows and the page scrolls instead — nothing to stick within. min-height is the opposite lever: keep an empty state visible.
  • No density prop. Density belongs on md-table — and there it is dual-typed (words or numbers).
  • Sticky behaviour itself is never configured here: sticky-header, frozen-header and sticky-footer are all md-table props.
  • variant defaults to elevated. elevation (0–5) overrides the shadow level implied by the variant — leave it unset unless you need a specific step.
  • vibrant tints the whole table with primary / tertiary container tones and cascades into the contained md-table via custom properties.

Properties

PropertyAttributeTypeDefaultReflects
variantvariant'elevated' | 'outlined' | 'filled' | 'flat''elevated'Yes
shapeshape'extra-large' | 'large' | 'medium' | 'small' | 'none''large'Yes
maxHeightmax-heightstring''
minHeightmin-heightstring''
vibrantvibrantbooleanfalseYes
elevationelevation0 | 1 | 2 | 3 | 4 | 5Yes

Slots

SlotDescription
(default)
top
bottom

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-table-container-colorSurface background
--md-table-container-shapeBorder-radius
--md-table-container-elevationBox-shadow
--md-table-container-outline-colorOutline color (variant=outlined)
--md-table-container-outline-widthOutline width (variant=outlined)

CSS Shadow Parts

Style internal elements through shadow DOM with ::part():

PartDescription
topWrapper around `slot[name="top"]` (toolbar etc.)
scrollThe scroll viewport that contains the table
bottomWrapper around `slot[name="bottom"]` (pagination etc.)

The column-header rowgroup — <thead> for md-table. Purely presentational: it marks the region, applies the head background, and lets its rows flow into the table’s grid.

  • It renders with display: contents, so its rows land directly on the table’s grid tracks. It is not a box — you cannot pad it, border it, give it a height, or scroll it. Style the cells instead.
  • You still set rowgroup="head" on each row, and head + scope="col" on each cell. The wrapper does not cascade anything to its descendants.
  • Sticky behaviour comes from md-table, not from here: use its sticky-header (pinned inside the scroll region) or frozen-header (lifted out of it), paired with max-height on md-table-container. Never set position: sticky on this element.
  • Internally it relays a slotchange as a bubbling event so md-table notices row reorders (slotchange isn’t composed). That’s plumbing — don’t listen to it.

Slots

SlotDescription
(default)

The data rowgroup — <tbody> for md-table. Purely presentational; its rows flow into the table’s grid.

  • It renders with display: contents, so rows land on the table’s tracks. It is not a box: don’t size it, pad it, border it or give it overflow: auto. Scrolling belongs to md-table-container via its max-height.
  • Rows still carry their own rowgroup. rowgroup="body" is the default on md-table-row; the wrapper doesn’t stamp it, and it certainly doesn’t make cells into headers.
  • Several md-table-body elements in one table are allowed — useful for visually grouped sections — but each is announced as a rowgroup unless you set presentational.
  • presentational drops the rowgroup role. Use it only when the grouping is decorative; leave it off for the normal single-body case.
  • Sticky headers and footers are md-table props (sticky-header, frozen-header, sticky-footer) — never something you configure here.

Properties

PropertyAttributeTypeDefaultReflects
presentationalpresentationalbooleanfalseYes

Slots

SlotDescription
(default)

The summary rowgroup — <tfoot> for md-table. Where totals and aggregate rows live. Purely presentational; its rows flow into the table’s grid.

  • It renders with display: contents, so the rows land on the table’s tracks. It is not a box — no padding, borders, height or scrolling on this element. Style the cells.
  • Set rowgroup="foot" on its rows. The wrapper doesn’t stamp it, and body rows in the foot get the wrong styling and the wrong semantics.
  • Set selectable="false" on totals rows, so selectAll() on md-table doesn’t sweep the summary row up with the data.
  • Sticky behaviour comes from md-table’s sticky-footer, not from here — paired with max-height on md-table-container. Never set position: sticky on this element.
  • presentational drops the rowgroup role for a purely decorative grouping; leave it off for a real summary group.

Properties

PropertyAttributeTypeDefaultReflects
presentationalpresentationalbooleanfalseYes

Slots

SlotDescription
(default)

One row of a md-table. A real grid box that spans the table’s tracks and lays out its md-table-cell children, with selection, click and expansion state.

  • value is the row’s identity in the table’s selection state. A body row without a value cannot be tracked by md-table’s getSelection().
  • selectable defaults to true, not false. Aggregate and totals rows must set selectable="false" or selectAll() picks them up as data.
  • rowgroup must match the wrapping grouprowgroup="head" inside md-table-head, "foot" inside md-table-foot. The default is body, so a header row left untouched gets body styling and semantics.
  • The row is a real grid box spanning the table’s tracks, unlike the rowgroups, which are display: contents. Only cells and the expanded slot belong in it — a wrapper <div> around the cells breaks the layout.
  • clickable and selection are independent: a row can be clickable without being selectable, and vice versa. Set each explicitly.
  • Detail content goes in the expanded slot, not in a second injected row.

Properties

PropertyAttributeTypeDefaultReflects
rowgrouprowgroup'head' | 'body' | 'foot''body'Yes
selectedselectedbooleanfalseYes
disableddisabledbooleanfalseYes
selectableselectablebooleantrue
clickableclickablebooleanfalseYes
valuevaluestring''
expandableexpandablebooleanfalseYes
expandedexpandedbooleanfalseYes
highlighthighlightbooleanfalseYes

Methods

MethodParameters
toggle()none

Slots

SlotDescription
(default)
expanded

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-table-row-hover-color

CSS Shadow Parts

Style internal elements through shadow DOM with ::part():

PartDescription
expanded

One cell of a md-table. Renders as a data cell, a column header or a row header depending on head and scope, with alignment, spanning, sticky pinning and truncation options.

  • head and scope together are what make a header. head alone only styles the cell; scope is what associates it with its column or row for assistive tech. Set both — scope="col" on column headers, scope="row" on the identifying first column.
  • numeric is not just alignment — it applies tabular figures so digits line up across rows. Use it on every numeric column.
  • A sticky cell needs an opaque background or the scrolling rows bleed through it. That’s --md-table-cell-sticky-bg. The table’s pin-mode and pinColumn() coordinate multi-column pinning.
  • colspan / rowspan use the HTML attribute casingcolspan="2", not col-span="2" (the properties are colSpan / rowSpan).
  • ellipsis truncates instead of wrapping, so the full text must be reachable another way — a md-tooltip or the row’s expanded slot. Truncation on its own loses information.
  • padding="checkbox" is the tightened padding for a selection column; padding="none" is for edge-to-edge content like an image.
  • variant="meta" is the built-in secondary/meta text style — don’t shrink text with custom CSS.

Properties

PropertyAttributeTypeDefaultReflects
alignalign'start' | 'center' | 'end' | 'justify''start'Yes
numericnumericbooleanfalseYes
paddingpadding'default' | 'none' | 'checkbox''default'Yes
headheadbooleanfalseYes
scopescope'col' | 'row' | 'colgroup' | 'rowgroup' | ''''Yes
stickysticky'start' | 'end' | ''''Yes
colSpancolspannumber1
rowSpanrowspannumber1
ellipsisellipsisbooleanfalseYes
variantvariant'body' | 'meta''body'Yes
noPinIndicatorno-pin-indicatorbooleanfalseYes
pinIconpin-iconstring''
densitydensity0 | -1 | -2 | -3 | -40Yes

Slots

SlotDescription
(default)

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-table-cell-colorForeground (text) color
--md-table-cell-backgroundBackground color
--md-table-cell-padding-inlineHorizontal padding
--md-table-cell-min-heightMinimum cell height
--md-table-cell-sticky-bgOpaque bg for sticky columns
--md-table-cell-padding-block
--md-table-cell-pin-display
--md-table-cell-pin-color
--md-table-cell-pin-size
--md-table-cell-sticky-transition

CSS Shadow Parts

Style internal elements through shadow DOM with ::part():

PartDescription
pin
content

The band above a md-table. Title and supporting text in the resting state; a selection count and bulk actions when rows are selected.

  • num-selected > 0 switches the toolbar into selection mode: the headline is replaced by the selection caption, and selection-actions replace actions. A stale count leaves it stuck in (or out of) selection mode.
  • auto-bind makes the toolbar find the sibling table and track its selection itself — no listener needed. It only works when the toolbar and the table are inside the same md-table-container.
  • label-selected uses the %count% token, not {count}. This is a different token style from the rest of the library — md-transfer-list and md-time-picker use braces. Keep %count% when translating.
  • Bulk actions belong in selection-actions; putting them in actions shows them when nothing is selected.
  • The toolbar is not the table’s accessible name. Set label or caption on md-table as well — the headline is visual only.
  • compact is the built-in tighter band; don’t shrink it with custom CSS.

Properties

PropertyAttributeTypeDefaultReflects
headlineheadlinestring''
supportingTextsupporting-textstring''
numSelectednum-selectednumber0Yes
autoBindauto-bindbooleanfalse
labelSelectedlabel-selectedstring'%count% selected'
compactcompactbooleanfalseYes
densitydensity0 | -1 | -2 | -3 | -40Yes

Slots

SlotDescription
leading
headline
supporting-text
title-area
selection-actions
actions

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-table-toolbar-colorForeground color
--md-table-toolbar-bgBackground color (default mode)
--md-table-toolbar-selection-bgBackground color (selection mode)
--md-table-toolbar-selection-colorForeground color (selection mode)
--md-table-toolbar-padding-blockTop/bottom padding
--md-table-toolbar-padding-inlineLeft/right padding
--md-table-toolbar-min-heightMinimum height (default: 64px)

CSS Shadow Parts

Style internal elements through shadow DOM with ::part():

PartDescription
leadingContainer for the `leading` slot
mainContainer for headline / supporting text
selection-caption"%count% selected" caption (selection mode)
headline<h2> element
supporting-text<p> element
actions

The page controls under a md-table. A rows-per-page selector, a “1–10 of 120” readout, and previous/next (optionally first/last) navigation.

  • page is zero-based. “Page 1” in the readout is page="0". Treating it as one-based puts an off-by-one in every fetch.
  • It does not slice your data. It reports intent through mdPageChange and mdRowsPerPageChange; you re-render the rows and update page, count and rows-per-page yourself.
  • rows-per-page-options is a comma-separated string, not an arrayrows-per-page-options="10,25,50", never el.rowsPerPageOptions = [10, 25, 50]. It is parsed with .split(','), so it is string-only in both the attribute and the property form — unlike md-color-picker’s presets, which is typed string | string[] and does accept an array via the property. "all" is a legal entry and maps to the full count.
  • label-displayed-rows uses %from%, %to% and %count% — percent tokens, matching md-table-toolbar and unlike the {…} tokens used by md-transfer-list and md-time-picker. Keep all three when translating.
  • Changing rows-per-page usually should reset page to 0 — that’s your call, not the component’s, or the user can land on page 12 of 3.
  • Use disabled while a page is loading so users can’t queue conflicting page changes.

Properties

PropertyAttributeTypeDefaultReflects
countcountnumber0
pagepagenumber0Yes
rowsPerPagerows-per-pagenumber10Yes
rowsPerPageOptionsrows-per-page-optionsstring'5,10,25,50'
showFirstLastshow-first-lastbooleanfalseYes
labelDisplayedRowslabel-displayed-rowsstring'%from%–%to% of %count%'
labelRowsPerPagelabel-rows-per-pagestring'Rows per page:'
labelFirstPagelabel-first-pagestring'First page'
labelPreviousPagelabel-previous-pagestring'Previous page'
labelNextPagelabel-next-pagestring'Next page'
labelLastPagelabel-last-pagestring'Last page'
labelAlllabel-allstring'All'
compactcompactbooleanfalseYes
disableddisabledbooleanfalseYes
densitydensity0 | -1 | -2 | -3 | -40Yes

Methods

MethodParameters
goToPage()page: number
setRowsPerPage()rpp: number

Slots

SlotDescription
actions

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-table-pagination-colorForeground color
--md-table-pagination-bgBackground color
--md-table-pagination-padding-blockTop/bottom padding
--md-table-pagination-padding-inlineLeft/right padding
--md-table-pagination-select-widthRows-per-page select width (default 72px)

CSS Shadow Parts

Style internal elements through shadow DOM with ::part():

PartDescription
displayThe "from–to of count" label
rppRows-per-page region
rpp-selectThe md-select picker
navNavigation buttons region
first-button
prev-button
next-button
last-button

The sortable-column affordance for md-table. It goes inside a header cell, shows the sort direction, and emits a request to sort — it does not sort anything itself.

  • It requests, it doesn’t sort. mdSortRequest tells you which column was clicked and its defaultOrder; you decide the next order, re-sort your rows, and reflect the result.
  • active and order are display state you own. Nothing updates them automatically — leave them stale and the arrow lies about the current sort.
  • Exactly one label may be active at a time. Two active indicators make the sort state ambiguous.
  • default-order is the direction used on the first click of this column; desc is usually right for dates and amounts.
  • column is the key echoed back in the event — keep it matching your data field and untranslated. Never use the label text as the key.
  • Call md-table’s animateNextChange() before re-rendering or you lose the reorder animation.
  • disabled keeps the header readable while removing the affordance.

Properties

PropertyAttributeTypeDefaultReflects
columncolumnstring''
disableddisabledbooleanfalseYes
defaultOrderdefault-order'asc' | 'desc''asc'
iconPositionicon-position'start' | 'end''end'Yes
iconiconstring''
inactiveIconinactive-icon'always' | 'hover''always'Yes
activeactivebooleanfalseYes
orderorder'asc' | 'desc' | 'none''none'Yes
densitydensity0 | -1 | -2 | -3 | -40Yes

Slots

SlotDescription
(default)

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-table-sort-label-colorInactive color
--md-table-sort-label-active-colorActive color
--md-table-sort-label-icon-sizeIcon size (default 18px)
--md-table-sort-label-gapGap between text and icon
--md-table-sort-label-rest-opacity

CSS Shadow Parts

Style internal elements through shadow DOM with ::part():

PartDescription
iconThe arrow indicator
labelThe text label

The chevron that expands a md-table row. A tiny wrapper around md-icon-button that rotates with the row’s expanded state.

  • It holds no state. The parent md-table-row’s expanded drives the rotation, and the row is what emits mdRowExpandedChange. Never try to read expanded state off the toggle.
  • button-label defaults to the English "Expand row" and is the toggle’s only accessible name. In a table of twenty rows it is also ambiguous — make it row-specific (“Expand invoice for Acme”).
  • The glyph rotates rather than swapping, so use a directional icon. The default is chevron_right; a symmetric glyph like add looks broken when rotated.
  • It has no density prop — it follows the cell it sits in.
  • Give its cell padding="checkbox" so a small control isn’t ringed by full cell padding.

Properties

PropertyAttributeTypeDefaultReflects
buttonLabelbutton-labelstring'Expand row'
iconiconstring'chevron_right'

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-table-expand-toggle-rotationExpanded rotation (default 90deg)

md-table-container · md-table-head · md-table-body · md-table-foot · md-table-row · md-table-cell · md-table-toolbar · md-table-pagination · md-table-sort-label · md-table-expand-toggle · md-list

For AI Agents — md-table

Two artefacts to give your AI agent so it generates correct UI with this component. The per-component spec answers "how do I use this exact tag?". The main-llm spec answers "which tag should I pick in the first place?".

Per-component

md-table spec card

Identity · when to use / when NOT · decision cues · behavioural contract · do/don't · anti-patterns · full API. Paste into your agent when you're implementing with this component.

Main-LLM spec

AWC UI Operator's Manual

System-prompt preamble · decision matrix · token reference · page recipes · anti-patterns. Paste into the system prompt at the start of a piece of work.

Open spec

md-table readme.md

# md-table

<!-- llm:meta
tag: md-table
category: data
status: custom
m3-guidelines: none — M3 has no data-table page
m3-derived-from: https://m3.material.io/components/lists/guidelines
form-associated: false
depends-on: md-progress-indicator
used-by: none
accepts-children: md-table-head, md-table-body, md-table-foot, md-table-row
-->

**The composable table primitive.** You author every row; the table owns the
CSS-Grid layout, sticky and frozen headers, column pinning and visibility,
sort and selection state, striping, expressive reorder motion, and the loading
and empty states.

> ⚠️ **Not a Material Design 3 component.** M3 has no data-table page, so the
> Do / Don't below is house rules derived from the M3 list guidance and the WAI
> table patterns.

> ⚠️ **Two table families exist in this library and they are separate
> implementations with separate CSS.** `md-table` is **hand-authored** — you
> write every row, and it never fetches, sorts or filters your data.

> Setup, theming, density and i18n are configured once for the whole library —
> see [`main-llm.md`](../../../../../main-llm.md) at the repo root.

---

## When to use

- A table whose rows you **compose by hand**: mixed content, embedded controls,
  bespoke cells, server-rendered markup.
- You need frozen headers, pinned columns, or column show/hide.
- You want selection, sorting affordances and reorder motion while keeping the
  data pipeline entirely yours.

## When NOT to use

| Situation | Use instead |
|---|---|
| A vertical list of records | `md-list` |
| Rich, self-contained items | `md-card` collection |
| Hierarchy / reporting lines | `md-organization-chart` |
| Comparing two values | Plain text |
| Layout (the old `<table>` misuse) | CSS Grid |

## Decision cues

| Need | Setting |
|---|---|
| Explicit column widths | `column-template` (a CSS `grid-template-columns` value) |
| Just a column count | `columns` (equal `1fr` tracks) |
| Header stays put inside the scroller | `sticky-header` + a bounded `md-table-container` |
| Header outside the scroll area, scrollbar on the body only | `frozen-header` |
| Column pinning or hiding | `column-template` (no `repeat()`) + `pinColumn()` / `setColumnVisibility()` |
| Several pinned columns all visible | `pin-mode="static"` |
| Zebra striping | `striped` |
| Row selection | `selection="single"` or `"multiple"` |
| Sorted state shown in the header | `sort-by` + `sort-order` (+ `md-table-sort-label`) |
| Loading | `loading` + `loading-mode="overlay"` or `"skeleton"` |
| Empty state | `empty` (+ the `empty` slot) |
| Row heights | `density="compact\|standard\|comfortable"`, or the numeric rungs `-1`…`-4` |
| Correct AT positions while paginating | `row-offset` + `row-count` |

## API contract

```html
<md-table-container max-height="60vh">
  <md-table
    column-template="2fr 1fr 1fr"          <!-- default: "" -->
    columns="0"                            <!-- default: 0 (derive from row 1) -->
    min-width="640px"                      <!-- default: "" -->
    density="compact|standard|comfortable" <!-- default: standard; also -1…-4 -->
    sticky-header                          <!-- default: false -->
    frozen-header                          <!-- default: false -->
    sticky-footer                          <!-- default: false -->
    keep-height="true"                     <!-- default: true -->
    scrollbar="overlay|gutter"             <!-- default: overlay (frozen mode) -->
    pin-mode="stack|static"                <!-- default: stack -->
    pin-icon=""                            <!-- default: "" (built-in push-pin) -->
    striped                                <!-- default: false -->
    no-dividers                            <!-- default: false -->
    hoverable                              <!-- default: true -->
    motion="expressive|none"               <!-- default: expressive -->
    selection="none|single|multiple"       <!-- default: none -->
    sort-by="name"                         <!-- default: "" -->
    sort-order="asc|desc|none"             <!-- default: asc -->
    loading                                <!-- default: false -->
    loading-mode="overlay|skeleton"        <!-- default: overlay -->
    loading-rows="4"                       <!-- default: 4 -->
    empty                                  <!-- default: false -->
    label="Invoices"                       <!-- default: "" -->
    caption=""                             <!-- default: "" -->
    summary=""                             <!-- default: "" -->
    row-offset="0"                         <!-- default: 0 -->
    row-count="0"                          <!-- default: 0 (use rendered count) -->
  >
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col">Client</md-table-cell>
        <md-table-cell head scope="col" numeric>Amount</md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="inv-1">
        <md-table-cell>Acme Corp</md-table-cell>
        <md-table-cell numeric>$1,200</md-table-cell>
        <md-table-cell>Paid</md-table-cell>
      </md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>
```

**Events** (all bubble and cross shadow boundaries) —
`mdSortChange` `{ column, order }`,
`mdSelectionChange` `{ count, total, values, all, indeterminate }`,
`mdPinChange` `{ column, side }`,
`mdColumnVisibilityChange` `{ column, visible, hidden }`,
`mdScroll` `{ scrollLeft, scrollTop }` (frozen mode only, one per frame).

**Methods** (all async — `await` them) — `getSelection()`, `selectAll()`,
`deselectAll()`, `toggleSelectAll()`, `setSort(column, order)`,
`pinColumn(column, 'start' | 'end' | 'none')`,
`setColumnVisibility(column, visible)`, `animateNextChange()`.

**Slots** — the default slot takes the rowgroups (`md-table-head` /
`md-table-body` / `md-table-foot`) or bare rows; `empty` replaces the built-in
"No data" message; `loader` replaces the built-in progress line (`loading` is
the older alias for it). The `head` slot is **assigned by the table itself** in
`frozen-header` mode — never write `slot="head"` by hand.

**Parts** — `grid`, `header-grid`, `caption`, `empty`, `skeleton-row`,
`loading`, `progress`, `hscrollbar`, `hscrollbar-thumb`, `vscrollbar`,
`vscrollbar-thumb`.

### Behavioral contract worth knowing

- **The layout is CSS Grid, not a real `<table>`.** The shadow grid owns the
  tracks and every row is a real grid item that subgrids its cells onto them.
  The rowgroups are `display: contents`. Consequences: declare the tracks
  (`column-template` or `columns`, else they are derived from the first row's
  cell count), keep `md-table-cell` as the direct children of a row, and never
  wrap rows in your own element.
- **`md-table` sorts nothing and selects no data by itself.** `sort-by` /
  `sort-order` are display state, and `mdSortChange` is a *request* — you
  reorder the rows in the handler. Clicking an `md-table-sort-label` cycles that
  column: its `default-order`, then the opposite, then off (`sort-by` clears and
  `sort-order` resets to `asc`).
- **Selection checkboxes are auto-wired.** With `selection` set, an
  `md-checkbox` slotted into a body row toggles that row, and one slotted into
  `md-table-head` is the select-all. The table pushes the model back into
  those checkboxes (`checked`, `indeterminate`, `disabled`), so no wiring is
  needed — and adding your own handler is safe, because the table's side is
  declarative rather than a toggle.
- In `selection="single"` selecting a row deselects every other one, and the
  select-all checkbox is **disabled** — `selectAll()` is a no-op outside
  `selection="multiple"`. Head and foot rows never join the selection model.
- `mdSelectionChange` fires on user *and* programmatic selection changes, but
  **not** on first load; call `await table.getSelection()` for the initial
  snapshot.
- `sticky-header` keeps the header in the scroll region (bound the scroller with
  `md-table-container[max-height]`). **`frozen-header` is a different
  architecture**: the header gets its own grid outside the scroll area, the body
  becomes a focusable scroll region with custom always-visible scrollbars, and
  horizontal scroll is mirrored between the two. Size the frozen body with
  `md-table-container[max-height]` (it forwards the value) or the
  `--md-table-body-max-h` property. `mdScroll` and the `scrollbar` prop only
  apply in frozen mode.
- **`keep-height` is ON by default**: after the first render the table never
  shrinks below its initial height, so paging or filtering to fewer rows cannot
  make the page jump. Set `keep-height="false"` for a table that should resize.
- **`pinColumn()` and `setColumnVisibility()` have preconditions**: an explicit
  `column-template` **without `repeat()`**, no `colspan` cells, and every row
  carrying one cell per column. They warn to the console and no-op otherwise.
  Indexes are always the *original* column positions; the table re-sequences the
  cells, rewrites the host's `column-template` attribute, and keeps hidden cells
  in the DOM so their state survives. The last visible column cannot be hidden.
- `motion="expressive"` (the default) **already brackets sort and pagination**:
  the table FLIPs its rows around `mdSortChange`, and `md-table-container` arms
  it for a slotted `md-table-pagination`. Call `animateNextChange()` only for
  mutations of your own (filters, async refetch), immediately before the
  synchronous DOM change. `prefers-reduced-motion` always wins.
- `density` is **dual-typed** here: the semantic sizes `compact` / `standard` /
  `comfortable` (36 / 52 / 60px rows) *or* the numeric rungs `-1`…`-4` used by
  the rest of the library. `0` is accepted by the type but inert — it is the
  uncompacted default. The two combine: `density="compact"` inside a
  `data-density="-2"` region condenses further.
- `label`, `caption` and `summary` all feed accessibility: `aria-label` is
  `label` → `caption` → `"Data table"`, the caption is rendered visually but
  `aria-hidden` (so it is not announced twice), and `summary` becomes
  `aria-description`.
- The structural role **promotes from `table` to `treegrid`** as soon as any row
  carries `expandable`, because only a treegrid row may expose `aria-expanded`.
- The table stamps `rowgroup`, `data-stripe`, `data-sticky`, `data-last`,
  `data-adjacent-foot`, `aria-rowindex`, `aria-selected` and `aria-sort` onto
  your rows and cells on load and on every slot change. Don't write them
  yourself.
- `empty` swaps in the empty state and announces "No data" politely; `loading`
  sets `aria-busy`. `loading-mode="overlay"` dims the body under a progress
  line, `"skeleton"` replaces the body with `loading-rows` shimmer rows.

---

## Do / Don't

House rules, derived from
[M3 · Lists · Guidelines](https://m3.material.io/components/lists/guidelines)
and the WAI table patterns — M3 has no data-table page.

| ✅ Do | ❌ Don't |
|---|---|
| Declare `column-template` or `columns` | Don't rely on content to define the grid — only the first row's cell count is derived |
| Keep `md-table-row` / `md-table-cell` as the direct structure | Don't wrap rows in your own elements — `display: contents` depends on it |
| Give the table a `label` or `caption` | Don't ship an unnamed data grid |
| Use `md-table-cell head scope="col"` for headers | Don't style a body cell to look like a header |
| Reorder your own data in `mdSortChange` | Don't expect the table to sort |
| Use `numeric` on number cells | Don't left-align numbers |
| Use `frozen-header` or `sticky-header` for long tables | Don't make users scroll back for context |
| Use the `empty` slot for a real empty state | Don't render a zero-row table with no explanation |
| Set `row-offset` / `row-count` when paginating | Don't let AT announce "row 1 of 20" on page 5 |

---

## Patterns

```html
<!-- Sortable + selectable table with a toolbar and pagination -->
<md-table-container variant="outlined" max-height="60vh">
  <md-table-toolbar slot="top" headline="Invoices"></md-table-toolbar>

  <md-table id="invoices" column-template="auto 2fr 1fr 1fr" sticky-header striped
            selection="multiple" label="Invoices">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head padding="checkbox"><md-checkbox></md-checkbox></md-table-cell>
        <md-table-cell head scope="col">
          <md-table-sort-label column="name">Client</md-table-sort-label>
        </md-table-cell>
        <md-table-cell head scope="col" numeric>
          <md-table-sort-label column="amount" default-order="desc">Amount</md-table-sort-label>
        </md-table-cell>
        <md-table-cell head scope="col">Status</md-table-cell>
      </md-table-row>
    </md-table-head>

    <md-table-body id="invoice-rows">
      <md-table-row value="inv-1">
        <md-table-cell padding="checkbox"><md-checkbox></md-checkbox></md-table-cell>
        <md-table-cell>Acme Corp</md-table-cell>
        <md-table-cell numeric>$1,200</md-table-cell>
        <md-table-cell><md-chip label="Paid" color="success"></md-chip></md-table-cell>
      </md-table-row>
    </md-table-body>
  </md-table>

  <md-table-pagination slot="bottom" count="120" rows-per-page="25"></md-table-pagination>
</md-table-container>

<script type="module">
  const table = document.getElementById('invoices');

  // The table already brackets the reorder with its FLIP motion — just
  // re-render the rows synchronously in the handler.
  table.addEventListener('mdSortChange', (e) => {
    renderRows(sortData(e.detail.column, e.detail.order));
  });

  table.addEventListener('mdSelectionChange', (e) => {
    console.log(e.detail.count, 'of', e.detail.total, e.detail.values);
  });

  // Initial snapshot: mdSelectionChange does not fire on load.
  console.log(await table.getSelection());
</script>
```

```html
<!-- Frozen header + pinned first column -->
<md-table-container max-height="420px">
  <md-table id="grid" frozen-header pin-mode="static"
            column-template="200px 120px 120px 120px" label="Metrics">
    <md-table-head>
      <md-table-row rowgroup="head">
        <md-table-cell head scope="col" sticky="start">Region</md-table-cell>
        <md-table-cell head scope="col" numeric>Q1</md-table-cell>
        <md-table-cell head scope="col" numeric>Q2</md-table-cell>
        <md-table-cell head scope="col" numeric>Q3</md-table-cell>
      </md-table-row>
    </md-table-head>
    <md-table-body>
      <md-table-row value="emea">
        <md-table-cell head scope="row" sticky="start">EMEA</md-table-cell>
        <md-table-cell numeric>120</md-table-cell>
        <md-table-cell numeric>135</md-table-cell>
        <md-table-cell numeric>141</md-table-cell>
      </md-table-row>
    </md-table-body>
  </md-table>
</md-table-container>

<script type="module">
  const grid = document.getElementById('grid');
  // Original 0-based column index; needs a repeat()-free column-template.
  await grid.pinColumn(1, 'end');
  await grid.setColumnVisibility(2, false);
</script>
```

```html
<!-- Loading and empty states -->
<md-table loading loading-mode="skeleton" loading-rows="6"
          column-template="2fr 1fr" label="Invoices"></md-table>

<md-table empty column-template="2fr 1fr" label="Invoices">
  <div slot="empty">No invoices yet.</div>
</md-table>

<md-table loading column-template="2fr 1fr" label="Invoices">
  <md-progress-indicator slot="loader" variant="circular" indeterminate
                         aria-label="Loading"></md-progress-indicator>
</md-table>
```

```html
<!-- Filtering your own rows, with the reorder animation -->
<script type="module">
  const table = document.getElementById('invoices');
  async function applyFilter(term) {
    await table.animateNextChange();   // arm BEFORE the mutation
    renderRows(data.filter((r) => r.client.includes(term)));
  }
</script>
```

## Anti-patterns

| ❌ Wrong | ✅ Right | Why |
|---|---|---|
| No `column-template` / `columns` on a table whose first row is not representative | Declare the tracks | Only the first row's cell count is derived. |
| Wrapping rows in a `<div>` for layout | Keep the rowgroup structure | `display: contents` and subgrid both break. |
| `column-template="repeat(4, 1fr)"` then `pinColumn()` | Write the four tracks out | Pinning refuses a template containing `repeat()`. |
| Calling `animateNextChange()` inside a `mdSortChange` handler | Just re-render | Sort and slotted pagination are bracketed automatically. |
| `table.getSelection()` used as a plain value | `await table.getSelection()` | Every `@Method` returns a Promise. |
| Waiting for `mdSelectionChange` to learn the initial state | `await table.getSelection()` | It does not fire on load. |
| `density="0"` expecting it to opt out of an inherited `data-density` | `style="--md-sys-density-scale: 0"` | Rung `0` is inert; only `-1`…`-4` have rules. |
| `sticky-header` with an unbounded container | `md-table-container[max-height]` | There is no finite scroll box to stick inside. |
| Writing `slot="head"` on `md-table-head` | Set `frozen-header` on the table | The table assigns and removes that slot itself. |
| Setting `aria-selected` / `data-stripe` on rows | Let the table stamp them | They are rewritten on every sync. |
| A table with no `label`/`caption` | Name it | Screen-reader users can't identify it. |
| Using a table for page layout | CSS Grid | Semantics. |

## Accessibility, RTL, density, i18n

**Accessibility**
- Because the layout is CSS Grid rather than a native `<table>`, ARIA carries the
  semantics: the inner structural element is `role="table"` (or `treegrid` once
  any row is `expandable`), the rowgroups are `rowgroup`, `md-table-row` is
  `row`, and `md-table-cell` is a cell or a column/row header via `head` +
  `scope`. **Get `scope` right** — it is what makes a cell announce with its
  column.
- `label`, `caption` and `summary` name and describe the grid; a caption alone
  is enough to name it.
- For a paginated table set `row-offset` and `row-count`, so AT announces
  "row 21 of 120" rather than "row 1 of 20".
- `aria-sort` is written onto the header cell for you whenever an
  `md-table-sort-label` is inside it.
- In `frozen-header` mode the body is a focusable scroll region (Tab reaches
  it) — required because the custom horizontal scrollbar is pointer-only. Left
  and Right arrows scroll horizontally, and Ctrl/Cmd + Home / End jump to the
  horizontal ends; all of that is inert unless the body actually overflows
  horizontally. Vertical scrolling (Up/Down, PageUp/PageDown, plain Home/End)
  is the browser's native behaviour.
- Sticky and frozen headers must not cover the focused row — check keyboard
  scrolling.
- `motion="none"` opts out of the reorder animation;
  `prefers-reduced-motion: reduce` already does.

**RTL** — the table uses logical properties throughout, and horizontal scrolling,
pin sides and the custom scrollbar thumbs all normalise the RTL `scrollLeft`
convention. `column-template` is a raw grid value, so a hardcoded asymmetric
template still needs checking per direction.

**Density** — `density="compact|standard|comfortable"`, or the numeric rungs
`-1`…`-4` (rung `0` exists in the type but has no rule — it is the default).
The table also follows a global `data-density` ancestor, and the semantic sizes
stack on top of it. Row heights are independently settable via
`--md-table-row-height-*`.

**i18n** — headers, cells, the caption and the empty state are your content.
Longer translations change column widths; prefer `fr` units over fixed `px` in
`column-template`, and format numbers and dates with `Intl`.

## Related components

`md-table-container` · `md-table-head` · `md-table-body` · `md-table-foot` ·
`md-table-row` · `md-table-cell` · `md-table-toolbar` · `md-table-pagination` ·
`md-table-sort-label` · `md-table-expand-toggle` · `md-list`

## Theming

| Custom property | Purpose | Default |
|---|---|---|
| `--md-table-row-height-standard` | Row height at `density="standard"` | `calc(52px + density scale × 4px)` |
| `--md-table-row-height-compact` | Row height at `density="compact"` | `max(28px, 36px + density scale × 4px)` |
| `--md-table-row-height-comfortable` | Row height at `density="comfortable"` | `max(40px, 60px + density scale × 4px)` |
| `--md-table-header-height` | Header row height | `max(40px, 56px + density scale × 4px)` |
| `--md-table-cell-padding-inline` | Cell inline padding | `--md-sys-spacing-inset-lg` (16px); 12px compact, 20px comfortable |
| `--md-table-cell-padding-block` | Cell block padding | `--md-sys-spacing-inset-sm` (8px); 4px compact |
| `--md-table-divider-color` | Rule between rows | `--md-sys-color-outline-variant` |
| `--md-table-stripe-color` | `striped` alternate-row overlay | `on-surface` at half the hover-layer opacity |
| `--md-table-head-bg` | Header row background | `--md-sys-color-surface-container` |
| `--md-table-head-color` | Header text color | `--md-sys-color-on-surface-variant` |
| `--md-table-foot-bg` | Footer row background | `--md-sys-color-surface-container-low` |
| `--md-table-row-hover-color` | Row hover state-layer color | `--md-sys-color-on-surface` |
| `--md-table-row-selected-bg` | Selected row background | `--md-sys-color-secondary-container` |
| `--md-table-row-selected-color` | Selected row text | `--md-sys-color-on-secondary-container` |
| `--md-table-columns-template` | `grid-template-columns` when no `column-template` prop is set | none |
| `--md-table-min-width` | Minimum grid width | `max-content` |
| `--md-table-empty-min-height` | Height of the empty state | `160px` |
| `--md-table-body-max-h` | Cap on the frozen body's scroll area | `none` (set for you by `md-table-container[max-height]`) |

**CSS parts** — `grid`, `header-grid`, `caption`, `empty`, `skeleton-row`,
`loading`, `progress`, `hscrollbar`, `hscrollbar-thumb`, `vscrollbar`,
`vscrollbar-thumb`.

```css
md-table {
  --md-table-head-bg: var(--md-sys-color-primary-container);
  --md-table-row-height-standard: 44px;
}
md-table::part(hscrollbar-thumb) {
  background: var(--md-sys-color-primary);
}
```

<!-- Auto Generated Below -->


## Overview

Material Design 3 — Table

The core composable table primitive for hand-authored tables.

Layout architecture: the shadow `.md-table__grid` owns the column tracks
(`grid-template-columns`), and every slotted `md-table-row` is a REAL grid
item spanning all tracks that lays its cells on the shared tracks via
`grid-template-columns: subgrid`. Rows being real boxes is what makes row
hover, stripes, dividers, sticky headers and focus rings actually work —
and a wrong cell count skews one row, never the whole table.

Coordination is push-based (the house pattern): the table stamps context
onto its rows (`rowgroup`, stripe parity, stickiness) and pushes sort state
into `md-table-sort-label`s — no MutationObservers, no `:host-context()`.

High-level features:
  - Density: `compact` | `standard` | `comfortable`
  - Sticky header / footer (pair with `md-table-container` for the scroll box)
  - Sticky first / last column via cell `sticky="start" | "end"`
  - Sortable columns via `<md-table-sort-label>` (3-state asc → desc → none)
  - Selection coordination — emits `mdSelectionChange`; `selectAll()` /
    `deselectAll()` / `toggleSelectAll()` for the select-all checkbox
  - Horizontal scroll when the content is wider than the container
  - Loading + empty-state slots, striped rows, configurable dividers

## Properties

| Property         | Attribute         | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | Type                                                                    | Default        |
| ---------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | -------------- |
| `caption`        | `caption`         | Optional caption rendered above the table head.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | `string`                                                                | `''`           |
| `columnTemplate` | `column-template` | CSS `grid-template-columns` value (e.g. `"auto 1fr 120px"`). Takes precedence over the `columns` prop. Use this for fine control, including using `minmax()`, `fr` units and `auto`.                                                                                                                                                                                                                                                                                                                                                                                                                                                              | `string`                                                                | `''`           |
| `columns`        | `columns`         | Number of columns when no `column-template` is provided. The grid uses `repeat(<columns>, minmax(0, 1fr))`. When neither is set, the column count is derived from the first row's cells.                                                                                                                                                                                                                                                                                                                                                                                                                                                          | `number`                                                                | `0`            |
| `density`        | `density`         | Layout density. Accepts BOTH vocabularies:  - the semantic sizes `'compact' \| 'standard' \| 'comfortable'` (row heights   36 / 52 / 60px), which is this component's original API, and - a numeric rung `0 \| -1 \| -2 \| -3 \| -4`, the scale every other component in   the library uses.  A numeric value drives `--md-sys-density-scale` on the host (see the DENSITY block in the CSS) exactly as a global `data-density` ancestor would, and takes the `standard` row-height base. The two are not exclusive: the semantic sizes are themselves scale-aware, so `density="compact"` inside a `data-density="-2"` region condenses further. | `"comfortable" \| "compact" \| "standard" \| -1 \| -2 \| -3 \| -4 \| 0` | `'standard'`   |
| `empty`          | `empty`           | Render the empty-state slot when true.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `boolean`                                                               | `false`        |
| `frozenHeader`   | `frozen-header`   | Frozen-header architecture: the header is rendered OUTSIDE the vertical scroll area, so the scrollbar spans only the body and runs UNDER the head Pair with `md-table-container[max-height]` to cap the body — max-height controls the SIZE, this prop controls the ARCHITECTURE. Without it, a max-height container scrolls the whole table (use `sticky-header` there for the classic in-flow pinned header). | `boolean` | `false` |
| `hoverable`      | `hoverable`       | Hover surface on rows.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `boolean`                                                               | `true`         |
| `keepHeight`     | `keep-height`     | Height ratchet (default ON): once the table has rendered, it never shrinks below that initial height. Paging, filtering to fewer/zero rows, or an async refetch that momentarily empties the body keep the table (and therefore the page) geometrically stable — no content below jumps up, and no scrollbar can flash from a transient collapse (async rebuilds have a few frames where fresh rows haven't hydrated and measure 0). Set `keep-height="false"` for tables that intentionally resize (e.g. a density-toggle demo).                                                                                                                 | `boolean`                                                               | `true`         |
| `label`          | `label`           | Accessible name for the `role="table"`. When omitted, the `caption` text names the table (WAI: a caption identifies the table for screen-reader users browsing in tables mode); the generic "Data table" is the last resort. IDREFs cannot cross the shadow boundary, so the caption text is mirrored into `aria-label` rather than referenced via `aria-labelledby`.                                                                                                                                                                                                                                                                             | `string`                                                                | `''`           |
| `loading`        | `loading`         | Show the loading state.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | `boolean`                                                               | `false`        |
| `loadingMode`    | `loading-mode`    | Loading presentation: - `overlay` (default) — an indeterminate progress line under the header   plus a scrim that dims/disables the body while the header/footer stay put. - `skeleton` — the body is replaced by shimmering skeleton rows; the   header and footer are untouched.                                                                                                                                                                                                                                                                                                                                                                | `"overlay" \| "skeleton"`                                               | `'overlay'`    |
| `loadingRows`    | `loading-rows`    | Number of skeleton rows rendered in `loading-mode="skeleton"`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | `number`                                                                | `4`            |
| `minWidth`       | `min-width`       | Minimum table width (e.g. `"640px"`). When the container is narrower, the grid keeps this width and the container shows a horizontal scrollbar instead of squishing the columns. Also settable via `--md-table-min-width`.                                                                                                                                                                                                                                                                                                                                                                                                                        | `string`                                                                | `''`           |
| `motion`         | `motion`          | Built-in expressive motion. With `expressive` (the default) the table FLIP-animates its rows around sort and page changes: it snapshots the row boxes when the triggering event fires (`mdSortRequest`, or a pagination event heard by the surrounding `md-table-container`), lets the consumer's synchronous handler mutate the rows, then glides survivors to their new positions and fades + rises entrants. Async mutations settle as a clean no-op. `prefers-reduced-motion` always wins; set `none` to opt out — e.g. when driving your own `flipRows` wiring around the same events.                                                       | `"expressive" \| "none"`                                                | `'expressive'` |
| `noDividers`     | `no-dividers`     | Hide the dividers between rows.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | `boolean`                                                               | `false`        |
| `pinIcon`        | `pin-icon`        | Table-wide custom pin-indicator icon (Material Symbols ligature, e.g. "anchor"). Cast to every pinned header cell so consumers set it once; a cell's own `pin-icon` attribute wins over the cast. Empty = built-in push-pin SVG.                                                                                                                                                                                                                                                                                                                                                                                                                  | `string`                                                                | `''`           |
| `pinMode`        | `pin-mode`        | How MULTIPLE pinned columns on the same side behave during horizontal scroll: - `stack` (default) — every pinned column sticks at the same edge, so   later ones slide OVER earlier ones while scrolling (a deck-of-cards   effect; only the top one stays fully visible). - `static` — each pinned column gets a cumulative inset equal to the   widths of the pinned columns before it, so ALL of them stay visible   side-by-side (spreadsheet-style).                                                                                                                                                                                         | `"stack" \| "static"`                                                   | `'stack'`      |
| `rowCount`       | `row-count`       | Total row count for assistive tech when only a subset is rendered (pagination / virtualization). 0 = use the rendered count.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `number`                                                                | `0`            |
| `rowOffset`      | `row-offset`      | Absolute 0-based index of the FIRST rendered body row within the full dataset (WAI/ARIA pagination): with `row-count` set, each row gets an `aria-rowindex` so AT reports "row 42 of 5000" instead of a position within the rendered page. Update it on every page change.                                                                                                                                                                                                                                                                                                                                                                        | `number`                                                                | `0`            |
| `scrollbar`      | `scrollbar`       | Vertical scrollbar presentation (frozen mode): - `overlay` (default) — a custom always-visible thumb FLOATS over the   rows' right edge; no gutter is reserved, so row backgrounds and   dividers run the full width and the bar never takes layout space. - `gutter` — classic inset bar in a reserved gap: `scrollbar-gutter:   stable` keeps a fixed strip inside the table (rows stop short of the   edge) and the native bar renders in it, crossing the row borders.                                                                                                                                                                        | `"gutter" \| "overlay"`                                                 | `'overlay'`    |
| `selection`      | `selection`       | Selection mode. - `none`     — no selection (default) - `single`   — radio-like: selecting a row DESELECTS every other row;                `selectAll()`/the select-all checkbox are inert (disabled) - `multiple` — checkbox-like; select-all checkbox + selectAll() available                                                                                                                                                                                                                                                                                                                                                                           | `"multiple" \| "none" \| "single"`                                      | `'none'`       |
| `sortBy`         | `sort-by`         | Currently active sort column id. Read by `<md-table-sort-label column="…">` to render its arrow. Empty = no sort.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `string`                                                                | `''`           |
| `sortOrder`      | `sort-order`      | Currently active sort direction.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | `"asc" \| "desc" \| "none"`                                             | `'asc'`        |
| `stickyFooter`   | `sticky-footer`   | Stick the foot row(s) to the bottom of the scroll container.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `boolean`                                                               | `false`        |
| `stickyHeader`   | `sticky-header`   | Stick the head row(s) to the top of the scroll container.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | `boolean`                                                               | `false`        |
| `striped`        | `striped`         | Show alternating row colors.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `boolean`                                                               | `false`        |
| `summary`        | `summary`         | Long description of a COMPLEX table's structure (WAI caption & summary pattern) — e.g. "Columns are grouped under Profile and Employment". Exposed as `aria-description` on the table; invisible otherwise.                                                                                                                                                                                                                                                                                                                                                                                                                                       | `string`                                                                | `''`           |


## Events

| Event                      | Description                                                           | Type                                                                   |
| -------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `mdColumnVisibilityChange` | Emits when a column is hidden/shown via `setColumnVisibility`.        | `CustomEvent<{ column: number; visible: boolean; hidden: number[]; }>` |
| `mdPinChange`              | Emits when a column is pinned/unpinned via `pinColumn`.               | `CustomEvent<{ column: number; side: "start" \| "end" \| null; }>`     |
| `mdScroll`                 | rAF-throttled scroll position of the frozen body scroller.            | `CustomEvent<{ scrollLeft: number; scrollTop: number; }>`              |
| `mdSelectionChange`        | Emitted whenever the row selection state changes.                     | `CustomEvent<MdTableSelectionState>`                                   |
| `mdSortChange`             | Emitted when sort changes (after a `md-table-sort-label` is clicked). | `CustomEvent<MdTableSortState>`                                        |


## Methods

### `animateNextChange() => Promise<void>`

Snapshot the current row boxes and FLIP-animate whatever the next
SYNCHRONOUS DOM mutation does to them. Call it right before you reorder /
show-hide rows outside the table's own events (custom filters, async
refetch completion, …); sort and pagination are bracketed automatically.

#### Returns

Type: `Promise<void>`



### `deselectAll() => Promise<void>`

Programmatically deselect every row.

#### Returns

Type: `Promise<void>`



### `getSelection() => Promise<MdTableSelectionState>`

Returns the current selection state (synchronous snapshot).

#### Returns

Type: `Promise<MdTableSelectionState>`



### `pinColumn(column: number, side: "start" | "end" | "none") => Promise<"start" | "end" | null>`

Pin a column to an edge (or unpin with `'none'`). `column` is the ORIGINAL
0-based column index (its position before any pinning). The table owns the
whole mechanic: it physically re-sequences every row's cells (selection and
expansion state survive — the elements just move), maintains `sticky`
attributes, permutes the column template, and re-floors the header tracks
so pin icons / sort arrows never clip in frozen mode. The move itself is an
intentional SNAP (no FLIP) — the frozen header re-measures its tracks over
the next frames, and animating against that settling would jitter.
Resolves to the column's effective side after the call.

#### Parameters

| Name     | Type                         | Description |
| -------- | ---------------------------- | ----------- |
| `column` | `number`                     |             |
| `side`   | `"none" \| "start" \| "end"` |             |

#### Returns

Type: `Promise<"start" | "end" | null>`



### `selectAll() => Promise<void>`

Selects every body row that is not `disabled` (no-op unless selection="multiple"). Note that `selectable="false"` rows are selected too — that flag only removes a row from the selection totals.

#### Returns

Type: `Promise<void>`



### `setColumnVisibility(column: number, visible: boolean) => Promise<boolean>`

Hide or show a column. `column` is the ORIGINAL 0-based index (same
addressing as `pinColumn`). Hidden cells stay in the DOM (state
survives — they just stop displaying) and their track leaves the
template. The last visible column cannot be hidden. Resolves to the
column's effective visibility.

#### Parameters

| Name      | Type      | Description |
| --------- | --------- | ----------- |
| `column`  | `number`  |             |
| `visible` | `boolean` |             |

#### Returns

Type: `Promise<boolean>`



### `setSort(column: string, order: MdTableSortOrder) => Promise<void>`

Set the active sort column / order programmatically.

#### Parameters

| Name     | Type                        | Description |
| -------- | --------------------------- | ----------- |
| `column` | `string`                    |             |
| `order`  | `"none" \| "desc" \| "asc"` |             |

#### Returns

Type: `Promise<void>`



### `toggleSelectAll() => Promise<void>`

Toggle the "all selected" state (used by the select-all checkbox).

#### Returns

Type: `Promise<void>`




## Shadow Parts

| Part                 | Description |
| -------------------- | ----------- |
| `"caption"`          |             |
| `"empty"`            |             |
| `"grid"`             |             |
| `"header-grid"`      |             |
| `"hscrollbar"`       |             |
| `"hscrollbar-thumb"` |             |
| `"loading"`          |             |
| `"progress"`         |             |
| `"skeleton-row"`     |             |
| `"vscrollbar"`       |             |
| `"vscrollbar-thumb"` |             |


## Dependencies

### Depends on

- [md-progress-indicator](../md-progress-indicator)

### Graph
```mermaid
graph TD;
  md-table --> md-progress-indicator
  style md-table fill:#f9f,stroke:#333,stroke-width:4px
```

----------------------------------------------

*Built with [StencilJS](https://stenciljs.com/)*

AWC UI Operator’s Manual

# main-llm.md — AWC UI build director

<!-- llm:meta
role: director
audience: llm
library: "@awc-ui/core"
component-count: 57
sub-component-count: 24
manual-count: 81
per-component-docs: ./packages/core/src/components/<tag>/readme.md
-->

**You are building a web app with AWC UI, a Material Design 3 web-component
library.** This file is the entry point and the only document you need before
writing UI code. Everything here is self-contained: tokens, recipes,
composition rules and the ship checklist. Per-component detail lives in
`./packages/core/src/components/<tag>/readme.md` — e.g.
[`md-button`](./packages/core/src/components/md-button/readme.md).

Your job, in order:

1. **Interview** the user (§1). Do not skip it and do not guess.
2. **Lock the configuration** their answers imply (§2) and bootstrap it (§3–§4).
3. **Route every UI need through the decision matrix** (§5) — never pick a
   component by name-similarity.
4. **Load the component's readme.md before writing a single line of its
   markup** (§6). The file is
   `./packages/core/src/components/<tag>/readme.md` — read it in full, do not
   skim it, and do not write the markup from memory of a similar library. Each
   one has a `When NOT to use`, a `Do / Don't` table sourced from
   [m3.material.io](https://m3.material.io), and an `Anti-patterns` table of
   mistakes models actually make. If you are about to use three components,
   load all three readmes first.
5. **Check what nests inside what** (§7) and start from a recipe (§8) rather
   than from a blank page.
6. **Apply the universal rules** (§9) — the API, content and accessibility
   rules every component is bound by, and the ones §10 checks you against.
7. **Run the ship checklist** (§10) before declaring done.

**Fail closed.** If you cannot satisfy a step — no component fits, a token
doesn't exist, an accessible name has nowhere to come from — say so and ask.
Do not invent an `md-*` tag, a prop, or a token. If it is not in this file or
in the component's manual, it does not exist.

---

## §1 — Interview the user

Ask these **one at a time**, in this order. Each answer closes off decisions
downstream, so don't batch them into a wall of questions. Skip a question only
if the user has already answered it unprompted.

If the user says "just pick sensible defaults", use the **bold** option and tell
them what you chose.

### 1.1 Scope and shape

1. **What is the app?** One or two sentences — domain, primary job, who uses it.
2. **What kind of surface is it?**
   - Internal tool / admin console / dashboard
   - Data-heavy CRUD application
   - Consumer-facing product
   - Marketing or content site
   - Mobile-first / PWA
3. **Which framework?** React · Angular · Vue · Svelte · **plain HTML** ·
   Next · Nuxt · SvelteKit · Astro
4. **Does it server-render?** (SSR/SSG, or **client-only SPA**)
5. **Roughly how many distinct screens**, and what are the top 3?

### 1.2 Look and feel

6. **Density** — how much information per screen?
   - `0` — **default**, comfortable, touch-friendly
   - `-1` / `-2` — compact; typical for admin consoles
   - `-3` / `-4` — ultra-compact; dense data tables, trading/ops screens
7. **Theme** — light only, dark only, or **both with a user toggle**?
   Does it follow the OS preference?
8. **Brand color** — a seed/primary color, or **stock MD3 palette**?
9. **Expressive motion** — keep **ripple and shape-morph on** (default), or turn
   them off for a flatter, more utilitarian feel?
10. **Shape language** — **rounded** (MD3 default) or squared?

### 1.3 Internationalization

11. **How many locales**, and which?
12. **Any RTL locales** (Arabic, Hebrew, Farsi, Urdu)? — this changes layout
    verification and directional-icon handling.
13. **Which i18n engine?** (i18next, vue-i18n, ngx-translate, Paraglide, custom)
    Components are engine-agnostic — you localize in the consumer layer.
14. **Locale-formatted values** — dates, numbers, currency? Which locale drives
    `Intl`?

### 1.4 Data and forms

15. **Is there significant tabular data?** How many rows, and is it
    server-paged? (Drives how you page `md-table` — it holds the state, you
    supply each page of rows.)
16. **Are there charts?** Which questions should they answer?
17. **How heavy are the forms?** Validation rules, async validation, multi-step?
18. **Rich text editing anywhere?** — ⚠️ **AWC UI has no RTE component.** If yes,
    you must integrate a third-party editor (TipTap, Lexical, Quill) and style
    it to the MD3 tokens yourself. Confirm this with the user explicitly.

### 1.5 Constraints

19. **Accessibility target** — **WCAG 2.1 AA** (what the library is tested to),
    or stricter?
20. **Browser/device support floor?**
21. **Anything already decided** you must not change — existing design system,
    router, state library, CSS approach?

---

## §2 — Map answers to configuration

| Answer | What you set |
|---|---|
| Admin console / data-heavy | `data-density="-1"` or `-2` on `<html>`; prefer `size="xs"`/`"sm"` on actions |
| Consumer / marketing | leave density alone (`0` is the default); larger button sizes (`md`/`lg`) for CTAs |
| Mobile-first | `md-navigation-bar` + `md-fab`; avoid `md-navigation-rail`, `md-transfer-list`, wide tables |
| Desktop-first | `md-navigation-rail` or `md-app-bar`; rail over bottom bar |
| Dark mode | `data-theme="dark"` on `<html>`; wire a toggle, and mirror OS via `prefers-color-scheme` |
| Both themes with toggle | persist the choice; set the attribute before first paint to avoid a flash |
| Brand color | override the `--md-sys-color-*` roles in your own stylesheet, loaded after the tokens (§4.2) |
| Flat / utilitarian | `data-ripple="off"` and `data-shape-morph="off"` on `<html>` |
| Any RTL locale | `dir="rtl"` on `<html>`; add `mirror-icon` to `md-button`s with directional glyphs; swap the glyph name yourself everywhere else (§4.5) |
| Multiple locales | build a dictionary in the consumer layer and feed component text props from it; never hardcode strings in markup |
| `Intl`-formatted values | pass a `locale` prop where a component exposes one; format everything else before it reaches the component |
| SSR | import from `@awc-ui/core/hydrate` on the server; use the client/server wrappers in `@awc-ui/react` |
| Heavy forms | components are form-associated via `ElementInternals` — use a real `<form>`, `md-button type="submit"`, and native `required` (§4.7) |
| Rich text | integrate a third-party editor; there is no `md-rich-text` |

### 2.1 Global switches — the complete set

All are attributes on `<html>` (or any ancestor; the nearest one wins).

```html
<html
  lang="en"
  dir="ltr"                  <!-- or rtl -->
  data-theme="dark"          <!-- omit for light -->
  data-density="-1"          <!-- -1 … -4; see below -->
  data-ripple="off"          <!-- default on -->
  data-shape-morph="off"     <!-- default on -->
>
```

**`data-density="0"` is inert.** No `[density="0"]` rule exists — density `0` is
simply the base values on `:root`, and a `0` rule would pin them onto every
element (reflected props write `density="0"` almost everywhere) and break global
inheritance. So the *overriding* range is `-1 … -4`. To escape an inherited rung
for one subtree, reset the scale directly:

```css
.opt-out-of-density { --md-sys-density-scale: 0; }
```

Per-component overrides beat the global one: a `density` prop, or
`ripple="off"` / `shape-morph="off"` on the element. **53 of the 57 top-level
components expose a `density` prop.** The four that don't: `md-divider`,
`md-ripple`, `md-sparkline`, `md-tabs`. (Nine sub-components also lack one —
they inherit density from the parent that owns their layout.)

---

## §3 — Install and bootstrap

```bash
npm install @awc-ui/core
```

**Register everything (recommended).** Two imports, once per app entry — one
defines every component, one loads the tokens. Both come from `@awc-ui/core`
itself, so this path needs no second package:

```ts
import { defineCustomElements } from '@awc-ui/core/loader';
import '@awc-ui/core/css/tokens.css';

defineCustomElements(window);
```

`@awc-ui/core/css/tokens.css` is the complete, self-contained token sheet —
light and dark colour roles, shape, elevation, motion, typescale, spacing and
z-index. It is the same set documented in §4.1.

**One-line alternative.** `@awc-ui/core/define` does both steps in a single
import. It loads the package's own token sheet, so it needs nothing else
installed:

```ts
import '@awc-ui/core/define'; // defines every component + loads the token sheet
```

`define` is client-only (it eval-guards `window`) and, because it imports CSS,
it needs a bundler. Never import it into a server graph.

**Per-component registration**, for size-sensitive bundles — load the token
sheet once yourself:

```ts
import '@awc-ui/core/css/tokens.css';
import '@awc-ui/core/components/md-button';
import '@awc-ui/core/components/md-text-field';
```

**Framework wrappers** — use these instead of raw elements; they handle
registration, typed props, and event binding. Below the version floor, drop to
the raw custom elements and the `loader` import above.

| Framework | Package | Requires |
|---|---|---|
| React / Next | `@awc-ui/react` | React 18+ |
| Angular | `@awc-ui/angular` (`AwcUiModule`) | Angular 17+ |
| Vue / Nuxt | `@awc-ui/vue` | Vue 3 |
| Svelte / SvelteKit | `@awc-ui/svelte` | Svelte 4+ |
| Plain HTML / Astro | `@awc-ui/core/loader` | — |

**SSR** — `@awc-ui/core/hydrate` renders Declarative Shadow DOM on the server.
`@awc-ui/react` ships matching client/server wrappers. Keep `define` and
`loader` out of the server graph; both are browser entries.

**Fonts** — components expect Roboto and Material Symbols Outlined to be
available. The library does **not** inject them. Every `icon="…"` prop renders a
Material Symbols glyph inside shadow DOM, so the font must be registered at the
**document** level (font registration crosses shadow boundaries; class rules do
not — the components declare the class rule inside their own roots):

```html
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap">
```

To swap the whole library to a different Material Symbols cut, set
`--md-sys-icon-font-family` on `:root` and load the matching `@font-face`.

---

## §4 — Global configuration reference

### 4.1 The token system

All AWC UI styling resolves to design tokens. **Never hardcode hex colours,
pixel radii, shadows, or font stacks** — they break theming, dark mode, density
and RTL in one stroke. Every value below is a real custom property defined by
`@awc-ui/tokens`; you may read them, and you may override them.

#### Color roles

Light is the default; `[data-theme="dark"]` swaps the palette. Every role has a
matching `on-` role for content drawn on top of it.

| Token | Light | Dark | Typical usage |
|---|---|---|---|
| `--md-sys-color-primary` | `#6750A4` | `#D0BCFF` | Filled buttons, active states |
| `--md-sys-color-on-primary` | `#FFFFFF` | `#381E72` | Text/icons on primary |
| `--md-sys-color-primary-container` | `#EADDFF` | `#4F378B` | FAB, selected segments |
| `--md-sys-color-on-primary-container` | `#21005D` | `#EADDFF` | Text on primary-container |
| `--md-sys-color-secondary` | `#625B71` | `#CCC2DC` | Secondary accents |
| `--md-sys-color-secondary-container` | `#E8DEF8` | `#4A4458` | Tonal buttons, chips |
| `--md-sys-color-tertiary` | `#7D5260` | `#EFB8C8` | Tertiary accents |
| `--md-sys-color-tertiary-container` | `#FFD8E4` | `#633B48` | Tertiary surfaces |
| `--md-sys-color-error` | `#B3261E` | `#F2B8B5` | Error / destructive |
| `--md-sys-color-on-error` | `#FFFFFF` | `#601410` | Content on error |
| `--md-sys-color-error-container` | `#F9DEDC` | `#8C1D18` | Error surfaces |
| `--md-sys-color-surface` | `#FFFBFE` | `#1C1B1F` | Page / card background |
| `--md-sys-color-on-surface` | `#1C1B1F` | `#E6E1E5` | Body text |
| `--md-sys-color-surface-variant` | `#E7E0EC` | `#49454F` | Muted fills |
| `--md-sys-color-on-surface-variant` | `#49454F` | `#CAC4D0` | Secondary text, icons |
| `--md-sys-color-surface-container-lowest` | `#FFFFFF` | `#0F0D13` | Lowest surface tier |
| `--md-sys-color-surface-container-low` | `#F7F2FA` | `#1D1B20` | Card / elevated button bg |
| `--md-sys-color-surface-container` | `#F3EDF7` | `#211F26` | Default container tier |
| `--md-sys-color-surface-container-high` | `#ECE6F0` | `#2B2930` | Dialog / menu surfaces |
| `--md-sys-color-surface-container-highest` | `#E6E0E9` | `#36343B` | Highest surface tier |
| `--md-sys-color-outline` | `#79747E` | `#938F99` | Borders (3:1 contrast) |
| `--md-sys-color-outline-variant` | `#CAC4D0` | `#49454F` | Dividers (low contrast) |
| `--md-sys-color-inverse-surface` | `#313033` | `#E6E1E5` | Snackbar, inverse chips |
| `--md-sys-color-inverse-on-surface` | `#F4EFF4` | `#313033` | Content on inverse-surface |
| `--md-sys-color-scrim` | `#000000` | `#000000` | Modal scrims |

**Semantic status roles** — beyond the baseline M3 palette, the library adds
`success`, `warning` and `info`, each with `on-`, `-container` and
`on-…-container` companions, in both themes, AA-verified. Used by `md-chip`,
`md-badge`, `md-meter`, `md-status-dot`.

| Token | Light | Dark |
|---|---|---|
| `--md-sys-color-success` | `#2E6B4F` | `#9DD5B0` |
| `--md-sys-color-success-container` | `#B8F0CE` | `#14512F` |
| `--md-sys-color-warning` | `#7A5900` | `#EFC148` |
| `--md-sys-color-warning-container` | `#FFDF9B` | `#5C4200` |
| `--md-sys-color-info` | `#38608F` | `#A2C9FE` |
| `--md-sys-color-info-container` | `#D3E4FF` | `#1E4975` |

#### Shape tokens

| Token | Value | Typical usage |
|---|---|---|
| `--md-sys-shape-corner-none` | `0px` | Square edges |
| `--md-sys-shape-corner-extra-small` | `4px` | Snackbar, small chips |
| `--md-sys-shape-corner-small` | `8px` | Cards (outlined), small surfaces |
| `--md-sys-shape-corner-medium` | `12px` | Cards (default), inputs |
| `--md-sys-shape-corner-large` | `16px` | Dialogs, large cards |
| `--md-sys-shape-corner-extra-large` | `28px` | Bottom sheets |
| `--md-sys-shape-corner-full` | `9999px` | Buttons, chips, FAB |

Partial-corner variants also exist for surfaces that meet an edge:
`--md-sys-shape-corner-extra-small-top`, `-large-top`, `-large-end`,
`-extra-large-top`. Their values are four-value `border-radius` shorthands
(e.g. `16px 16px 0px 0px`), so assign them to `border-radius`, not to a single
corner.

#### Elevation tokens

`-0` is the keyword `none`; `-1` through `-5` are complete `box-shadow` values,
and those five are **re-declared with deeper shadows under `[data-theme="dark"]`**
— so always use the token rather than copying its value.

| Token | When |
|---|---|
| `--md-sys-elevation-0` | `none` — resting cards, flat surfaces |
| `--md-sys-elevation-1` | Elevated buttons and cards at rest |
| `--md-sys-elevation-2` | Hover bump |
| `--md-sys-elevation-3` | FAB, menus, dialogs at rest |
| `--md-sys-elevation-4` – `-5` | Reserved for the most prominent surfaces |

#### Motion tokens

Durations come in four families of four: `short1…4` (50/100/150/200ms),
`medium1…4` (250/300/350/400ms), `long1…4` (450/500/550/600ms), and
`extra-long1…4` (700/800/900/1000ms).

| Token | Value | When |
|---|---|---|
| `--md-sys-motion-duration-short2` | `100ms` | Buttons, ripples, state layers |
| `--md-sys-motion-duration-medium2` | `300ms` | Dialogs, sheets, menus |
| `--md-sys-motion-duration-long2` | `500ms` | Large surface transitions |
| `--md-sys-motion-duration-extra-long1` | `700ms` | Ambient / chart entrances |
| `--md-sys-motion-easing-standard` | `cubic-bezier(0.2, 0, 0, 1)` | Small utility transitions |
| `--md-sys-motion-easing-standard-accelerate` | `cubic-bezier(0.3, 0, 1, 1)` | Exits |
| `--md-sys-motion-easing-standard-decelerate` | `cubic-bezier(0, 0, 0, 1)` | Entrances |
| `--md-sys-motion-easing-emphasized` | `cubic-bezier(0.2, 0, 0, 1)` | Expressive transitions |
| `--md-sys-motion-easing-emphasized-accelerate` | `cubic-bezier(0.3, 0, 0.8, 0.15)` | Expressive exits |
| `--md-sys-motion-easing-emphasized-decelerate` | `cubic-bezier(0.05, 0.7, 0.1, 1)` | Expressive entrances |
| `--md-sys-motion-easing-linear` | `linear` | Progress, indeterminate loops |

> `--md-sys-motion-easing-emphasized` deliberately equals the standard curve:
> the full M3 emphasized curve is a two-phase path interpolator that CSS cannot
> express. The accelerate and decelerate halves *are* expressible, and are the
> ones to reach for.

MD3 Expressive also ships spring pairs, each an easing plus its natural
duration: `--md-sys-motion-spring-{spatial,effects}-{fast,default,slow}-easing`
and `…-duration`. Spatial = position/size/shape; effects = colour/opacity.

#### State-layer opacities

| Token | Value |
|---|---|
| `--md-sys-state-hover-state-layer-opacity` | `0.08` |
| `--md-sys-state-focus-state-layer-opacity` | `0.12` |
| `--md-sys-state-pressed-state-layer-opacity` | `0.12` |
| `--md-sys-state-dragged-state-layer-opacity` | `0.16` |
| `--md-sys-state-disabled-container-opacity` | `0.12` |
| `--md-sys-state-disabled-content-opacity` | `0.38` |

#### Typography, spacing and layering

- **Typescale** — `--md-sys-typescale-<role>-<size>-*` for
  `display`/`headline`/`title`/`label`/`body` × `large`/`medium`/`small`. Each
  role exposes `-font-family`, `-font-size`, `-line-height`, `-font-weight`,
  `-letter-spacing`, plus a `-font` shorthand
  (e.g. `--md-sys-typescale-headline-medium-font: 400 28px/36px Roboto, sans-serif`).
  Use the shorthand on the `font` property for headings you write yourself.
- **Spacing** — a 4px scale that tightens with density:
  `--md-sys-spacing-inset-{xs,sm,md,lg,xl}` (internal padding, 4/8/12/16/24px)
  and `--md-sys-spacing-gap-{xs,sm,md,lg}` (between siblings, 4/8/12/16px), plus
  `--md-sys-spacing-row-height` (`56px` at density 0).
- **Layering** — `--md-sys-z-index-app-bar` (100), `-navigation` (200),
  `-bottom-sheet` (300), `-popup` (1000), `-dialog-scrim` (1001), `-tooltip`
  (1500), `-snackbar` (2000). Use these instead of inventing z-indexes, or your
  overlay will land under a menu.

### 4.2 Theming and rebranding

To rebrand, redefine the role variables **after** the tokens stylesheet — both
themes, or dark mode inherits your light brand colour:

```css
:root {
  --md-sys-color-primary: #00629E;
  --md-sys-color-on-primary: #FFFFFF;
  --md-sys-color-primary-container: #CFE5FF;
  --md-sys-color-on-primary-container: #001D33;
}
[data-theme="dark"] {
  --md-sys-color-primary: #9BCBFF;
  --md-sys-color-on-primary: #003354;
  --md-sys-color-primary-container: #004A78;
  --md-sys-color-on-primary-container: #CFE5FF;
}
```

Per-component knobs are `--md-<component>-*` custom properties, listed in each
manual's Theming section (`--md-button-container-color`,
`--md-card-container-shape`, …). Prefer those over `::part()`, and prefer
`::part()` over reaching into shadow internals, which are unstable and will
break on any release.

**Overriding a colour role forfeits the library's contrast testing.** Re-verify
AA (4.5:1 text, 3:1 borders and dividers) after any palette change. The Theme
Generator at <https://awc-ui.dev/theme-generator> takes a seed colour, emits both
palettes as `--md-sys-color-*` overrides, and runs the WCAG checks live — use it
rather than hand-picking container and `on-` pairs.

### 4.3 Density

`data-density` steps `-1 → -4`; each rung trims ~4px of padding and touch
target, driving `--md-sys-density-scale` and the spacing tokens. `-4` is the
floor. Set it globally, override locally with the `density` prop. Do not go
below `-2` on touch-primary surfaces — you will break the 48px target.

Two details that bite:

- **`density="0"` does nothing** — see §2 for why, and for the
  `--md-sys-density-scale: 0` escape hatch.
- **`md-table` accepts two vocabularies**: the semantic `compact` / `standard` /
  `comfortable` (row heights 36 / 52 / 60px) *and* the numeric rungs. They
  compose — `density="compact"` inside a `data-density="-2"` region condenses
  further.

### 4.4 Dark mode

Set `data-theme="dark"` on `<html>` (or any ancestor — the nearest wins). The
tokens swap automatically and inherit across every shadow boundary; no
per-component code. To mirror the OS, read `prefers-color-scheme` and write the
attribute **before first paint**, or the page flashes light.

### 4.5 RTL

Layout is written with CSS logical properties, so it flips with `dir="rtl"`
without extra work. Two things still need you:

- **Directional glyphs do not flip.** Material Symbols are not auto-mirrored.
  `md-button` has a `mirror-icon` prop that mirrors its own leading/trailing
  glyph — set it on buttons using arrows, chevrons, `send`, `reply`, and leave
  it off for `add`, `search`, `favorite`. **`mirror-icon` exists only on
  `md-button`.** Everywhere else — `md-icon-button icon="…"`, `md-list-item`
  leading/trailing icons, `md-app-bar leading-icon` — swap the glyph name
  yourself (`arrow_back` ↔ `arrow_forward`).
- **Icon props you supply yourself** — e.g. `md-transfer-list`'s
  `move-right-icon` / `move-left-icon` / `move-all-*-icon` — must be swapped by
  you when the direction flips.

Never write physical CSS (`margin-left`, `padding-right`, `left`) around these
components. Use `margin-inline-start`, `padding-inline-end`, `inset-inline-end`.

### 4.6 Internationalization

Components are **i18n-engine-agnostic by design**. Every user-visible string is
either slotted content or a prop. Localize in the consumer layer: resolve your
dictionary to plain strings, then pass them in. Do not add a translation engine
inside a component. `locale` props exist only where a component computes an
`Intl`-formatted value itself: `md-date-picker`, `md-number-field`,
`md-meter`, `md-dialog`, and the charts (`md-bar-chart`, `md-line-chart`,
`md-area-chart`, `md-pie-chart`). `md-time-picker` has no
`locale` prop — it takes `format="12h" | "24h"` instead.

Templated strings keep their placeholder tokens when translated — translate
around the braces, don't remove them:

```html
<md-transfer-list count-template="{checked} / {total} ausgewählt"></md-transfer-list>
```

Other templated props: `md-autocomplete status-template`, `md-otp-field
cell-label-template`. Default prop values are English (`Search`, `Dismiss`,
`No results`, `Move selected to target`, …) — every one of them needs
translating in a localized app.

### 4.7 Forms and validation

Fourteen components are form-associated via `ElementInternals`, so they
participate in `FormData` and constraint validation like native controls:

`md-text-field` · `md-number-field` · `md-otp-field` · `md-select` ·
`md-multi-select` · `md-autocomplete` · `md-checkbox` · `md-radio` ·
`md-switch` · `md-slider` · `md-rating` · `md-date-picker` · `md-time-picker` ·
`md-button`

Rules:

- Use a real `<form>`. `md-button type="submit"` calls `form.requestSubmit()`
  (not `submit()`), so the `submit` event fires **and** built-in constraint
  validation runs. `required` genuinely blocks submit.
  `md-button type="reset"` calls `form.reset()`.
- Give every control a `name`, or it will not appear in `FormData`.
- Do **not** add hidden `<input>`s to mirror values — that's the old pattern and
  it double-submits.
- Validity changes are announced on a `mdValidityChange` event on the control.
  Error presentation is `error` + `error-text` on the field.
- Boolean state props differ by control — `md-checkbox` uses `checked`,
  `md-switch` uses **`selected`**, `md-select-option` uses `selected`. Check the
  manual; guessing `checked` on a switch silently does nothing.

---

## §5 — Component decision matrix

Route by **need**, not by name. If the need isn't listed, find the closest row
and read that component's `When NOT to use`.

### 5.1 Actions

| Need | Use | Don't use |
|---|---|---|
| Discrete labelled action | `md-button` | `md-chip`, raw `<button>` |
| Icon-only action | `md-icon-button` | `md-button` with no label |
| A destructive action | `md-button` + `md-dialog` to confirm | an unconfirmed `filled` button |
| The single most prominent screen action (mobile) | `md-fab` | a second `filled` `md-button` |
| One prominent action that expands to several | `md-fab-menu` + `md-fab-menu-item` | a stack of FABs |
| Primary action + variants of it | `md-split-button` | button + separate menu |
| 2–5 related actions as one unit | `md-button-group` | loose adjacent buttons |
| Mutually exclusive view/mode switch | `md-segmented-button-set` + `md-segmented-button` | radio buttons, tabs |
| Overflow / contextual actions | `md-menu` + `md-menu-item` | a row of text buttons |

### 5.2 Text input

| Need | Use | Don't use |
|---|---|---|
| Any single-line or multi-line text entry | `md-text-field` | raw `<input>` |
| A number with steppers and locale formatting | `md-number-field` | `md-text-field type="number"` |
| A one-time code / PIN | `md-otp-field` | a row of text fields |
| Text entry with suggestions | `md-autocomplete` | `md-select` |
| Site/app-wide search with a results surface | `md-search` | `md-text-field` with an icon |

### 5.3 Selection

| Need | Use | Don't use |
|---|---|---|
| One of many, from a list | `md-select` + `md-select-option` | radio group over ~7 options |
| One of few (2–5), all visible | `md-radio` | `md-select` |
| Several of many | `md-multi-select` | many `md-checkbox`es |
| Several of few, all visible | `md-checkbox` | `md-multi-select` |
| Instant on/off setting | `md-switch` | `md-checkbox` |
| A value in a numeric range | `md-slider` | `md-text-field type=number` |
| Subjective score | `md-rating` | slider |
| A color | `md-color-picker` | `<input type=color>` |
| Assign a subset from a bounded pool, side by side | `md-transfer-list` | two lists + buttons |
| Filter / attribute / removable entry | `md-chip` | small buttons |
| A date | `md-date-picker` | three selects |
| A time | `md-time-picker` | text field |

### 5.4 Navigation

| Need | Use | Don't use |
|---|---|---|
| Top-level destinations, mobile | `md-navigation-bar` + `md-navigation-tab` | tabs |
| Top-level destinations, desktop | `md-navigation-rail` + `md-navigation-rail-tab` | bottom bar |
| App header: title, actions, search | `md-app-bar` | a custom `<header>` |
| A dense action strip | `md-toolbar` | app bar |
| Sibling views **within** one screen | `md-tabs` + `md-tab` + `md-tab-panels` + `md-tab-panel` | navigation bar |
| Hierarchy / where-am-I | `md-breadcrumbs` + `md-breadcrumb-item` | text links |
| A linear multi-step flow | `md-stepper` + `md-step` | tabs |
| Contextual popup actions | `md-menu` + `md-menu-item`, `md-menu-item-group`, `md-sub-menu-item` | dialog |
| Hierarchical command menu (File → Export → PDF) | `md-menu` + `md-sub-menu-item` | nested dialogs |

### 5.5 Containment and feedback

| Need | Use | Don't use |
|---|---|---|
| Group related content | `md-card` | a bare `<div>` with a border |
| Blocking decision or focused task | `md-dialog` | a new page |
| Critical error the user must acknowledge | `md-dialog` | `md-snackbar` |
| Supplementary content from the bottom (mobile) | `md-bottom-sheet` | dialog |
| Supplementary content from the side (desktop) | `md-side-sheet` | dialog |
| Brief confirmation of an action, optionally undoable | `md-snackbar` | dialog, alert |
| Explain a control on hover/focus | `md-tooltip` | a dialog or inline hint |
| Progressive disclosure of sections | `md-accordion` + `md-accordion-item` | tabs |
| Visual separation | `md-divider` | a styled `<hr>` |
| A vertical set of records | `md-list` + `md-list-item` | a table |
| Determinate/indeterminate progress | `md-progress-indicator` | spinner GIF |
| Brand-consistent page/content loading | `md-loading-indicator` | custom spinner |
| Content-shaped loading placeholder | `md-skeleton` | a spinner over the whole page |
| Count or status on an element | `md-badge` | superscript text |
| Compact status dot | `md-status-dot` | a colored emoji |
| Read-only value within a known range (quota, battery) | `md-meter` | `md-progress-indicator` |
| A person or entity image/initials | `md-avatar` | a raw `<img>` |
| Touch feedback inside a custom control | `md-ripple` | custom CSS animation |

### 5.6 Data

| Need | Use | Don't use |
|---|---|---|
| Any table | `md-table-container` wrapping `md-table`, with `-head`/`-body`/`-row`/`-cell`/`-foot` inside the table and `-toolbar`/`-pagination` beside it in the container (§7.1) | a native `<table>` |
| Sorting, selection, paging on that table | the same parts — `md-table` carries the STATE (`sort-by`, `sort-order`, `selection`, `row-offset`, `row-count`, `loading`) and emits events; you own the data and do the actual sorting/paging | expecting it to sort an array for you |
| Hierarchy / reporting lines | `md-organization-chart` | nested lists |
| Compare categories | `md-bar-chart` | pie chart |
| Trend over time | `md-line-chart` | bar chart |
| Trend with cumulative volume | `md-area-chart` | line chart |
| Parts of a whole (≤ ~6 slices) | `md-pie-chart` | bar chart |
| Inline micro-trend in a cell or card | `md-sparkline` | a full chart |

> **There is no data-driven table component.** `md-table` is composable: you
> render the rows. It tracks and announces sort, selection and pagination state
> and emits events when the user changes them, but the sorting, filtering and
> slicing of your data is yours to perform. Render rows from your own array in
> response to those events.

### 5.7 Choosing the variant

The matrix picks the component; this picks its shape. Every value below is a
real enum member — anything not listed here is not a valid value.

| Component | Prop | Values, and when |
|---|---|---|
| `md-button` | `variant` | `filled` the one primary action · `tonal` a strong secondary · `outlined` secondary, and the safe choice for a destructive action behind a confirm · `text` low emphasis, dialog Cancel · `elevated` when it sits on a busy or coloured background (default `filled`) |
| `md-button` / `md-icon-button` / `md-button-group` | `size` | `xs` `sm` `md` `lg` `xl` — default `sm`; go `md`/`lg` for consumer CTAs, `xs`/`sm` for dense admin UI |
| `md-icon-button` | `variant` | `standard` (default) · `filled` · `tonal` · `outlined` |
| `md-button-group` | `variant` | `standard` spaced · `connected` fused into one bar |
| `md-fab` | `size` | `standard` (default) · `medium` · `large` |
| `md-fab` | `variant` | `primary-container` (default) · `secondary-container` · `tertiary-container` · `surface` · `primary` · `secondary` · `tertiary` |
| `md-card` | `variant` | `filled` for a group of comparable items · `elevated` (default) for a hero or featured item · `outlined` for a settings or form section |
| `md-text-field` / `md-select` | `variant` | `outlined` for forms on a surface · `filled` for dense or tinted layouts (`md-text-field` defaults to `filled`, `md-select` to `outlined`) — pick one and use it for every field on the screen |
| `md-text-field` | `type` | any native input type: `password`, `email`, `tel`, `url`, `search`, … (default `text`) |
| `md-text-field` | `multiline` | `"auto-grow"` grows with content · `"fixed"` with `rows` for a fixed comment box · `false` (default) single line |
| `md-search` | `variant` | `contained` (default) · `divided` |
| `md-app-bar` | `variant` | `small` (default) · `medium` · `large` for a prominent headline · `search` for a bar that hosts a field |
| `md-chip` | `variant` | `assist` (default) · `filter` for toggleable facets · `input` for user-entered removable values · `suggestion` |
| `md-chip` | `appearance` | `outlined` (default) · `filled` · `elevated` |
| `md-badge` | `variant` | `small` a bare dot · `large` (default) a count |
| `md-tooltip` | `variant` | `plain` (default) a short label on an icon control · `rich` an explanatory popover that may hold a link or action |
| `md-divider` | — | **no `variant`** — use the booleans `inset`, `inset-start`, `inset-end` |
| `md-side-sheet` | `variant` | `standard` coexists with page content · `modal` overlays with a scrim |
| `md-bottom-sheet` | `variant` | `standard` (default) · `detached` floating above the edge |
| `md-dialog` | `fullscreen` | boolean — a full-screen dialog for a long mobile task |
| `md-date-picker` | `variant` | `modal-input` (default) calendar plus a typed field · `modal` calendar only · `docked` inline, anchored to the field |
| `md-time-picker` | `variant` | `dial` · `input` (default) |
| `md-progress-indicator` | `variant` | `linear` (default) · `circular`; add `indeterminate` when the total is unknown |
| `md-list-item` | `lines` | `1` · `2` · `3` — must match how much supporting text you pass |
| `md-navigation-bar` | — | 3–5 destinations. Fewer than three: use `md-tabs`. More than five: a rail or a menu |
| `md-navigation-rail` | — | 3–7 destinations; cap the overflow with `max-visible` |

### 5.8 Not in the library

Rich text editor · file upload/dropzone · calendar/scheduler view · map ·
toast stack manager (use `md-snackbar` and manage the queue yourself) ·
data grid with virtualized columns. If the user needs one, say so plainly and
integrate a third-party component styled with the MD3 tokens.

**There is no `md-grid`, no `md-data-table`, no `md-layout`, no `md-icon`.**
If a tag is not listed in §6, it does not exist — do not emit it.

---

## §6 — Component inventory

**Every component has exactly one manual, and it is the readme.md in its own
source folder:**

```
./packages/core/src/components/<tag>/readme.md
```

e.g. [`md-select`](./packages/core/src/components/md-select/readme.md),
[`md-button`](./packages/core/src/components/md-button/readme.md).

**Load that file before you use the component.** There is no second, shorter
summary to rely on — this readme is the single source, so anything you do not
read there, you do not know. Each one carries When-to-use, a Do/Don't table
from M3, copy-paste patterns, an anti-patterns table, and the theming surface.

Work through them one at a time: pick the component from the decision matrix
(§5), load its readme, write that component's markup, then move to the next.

There are 81 manuals for 57 components: 24 of them document sub-components that
are only valid inside a parent (a table cell, a tab panel, a select option).
Every sub-component manual names its parent in the first line, and §7 below
summarises the nesting.

`status` in each manual's `llm:meta` block is one of:

- **`md3-mapped`** — has a Material Design 3 guidelines page; the Do/Don't is
  sourced from it.
- **`custom`** — an addition to MD3; guidance is derived house rules.
- **`sub-component`** — only valid inside a specific parent.

| Category | Components |
|---|---|
| Actions | `md-button` `md-icon-button` `md-fab` `md-fab-menu` `md-fab-menu-item` `md-split-button` `md-button-group` `md-segmented-button` `md-segmented-button-set` |
| Text input | `md-text-field` `md-number-field` `md-otp-field` `md-autocomplete` `md-search` |
| Selection | `md-select` `md-select-option` `md-multi-select` `md-checkbox` `md-radio` `md-switch` `md-slider` `md-rating` `md-color-picker` `md-transfer-list` `md-chip` |
| Pickers | `md-date-picker` `md-time-picker` |
| Navigation | `md-app-bar` `md-toolbar` `md-navigation-bar` `md-navigation-tab` `md-navigation-rail` `md-navigation-rail-tab` `md-tabs` `md-tab` `md-tab-panels` `md-tab-panel` `md-breadcrumbs` `md-breadcrumb-item` `md-menu` `md-menu-item` `md-menu-item-group` `md-sub-menu-item` `md-stepper` `md-step` |
| Containment | `md-card` `md-dialog` `md-bottom-sheet` `md-side-sheet` `md-snackbar` `md-tooltip` `md-accordion` `md-accordion-item` `md-divider` `md-list` `md-list-item` |
| Data | `md-table` `md-table-container` `md-table-head` `md-table-body` `md-table-row` `md-table-cell` `md-table-foot` `md-table-toolbar` `md-table-pagination` `md-table-sort-label` `md-table-expand-toggle` `md-organization-chart` |
| Charts | `md-bar-chart` `md-line-chart` `md-area-chart` `md-pie-chart` `md-sparkline` |
| Status & feedback | `md-progress-indicator` `md-loading-indicator` `md-skeleton` `md-badge` `md-status-dot` `md-meter` `md-avatar` `md-ripple` |

---

## §7 — Composition rules

### 7.1 What nests inside what

A sub-component is only valid inside its parent. Putting one anywhere else
produces an unstyled, unregistered-looking element with no keyboard behaviour,
because the parent is what wires roving tabindex, ARIA ids and selection.

| Parent | Children it manages |
|---|---|
| `md-button-group` | `md-button`, `md-icon-button` |
| `md-segmented-button-set` | `md-segmented-button` |
| `md-fab-menu` | `md-fab-menu-item` (anchored to an `md-fab` via `anchor="<id>"`) |
| `md-menu` | `md-menu-item`, `md-sub-menu-item`, `md-menu-item-group` — **not** `md-divider`; separate rows with `md-menu-item`'s own `divider` (or `gap`) prop |
| `md-menu-item-group` | `md-menu-item` |
| `md-sub-menu-item` | a nested `md-menu` in `slot="submenu"` — the items go in *that* menu. There is no default slot, so anything else you nest renders nothing |
| `md-select`, `md-multi-select`, `md-autocomplete` | `md-select-option` |
| `md-list` | `md-list-item`, `md-divider` |
| `md-tabs` | `md-tab` |
| `md-tab-panels` | `md-tab-panel` (one per tab, in tab order) |
| `md-navigation-bar` | `md-navigation-tab` |
| `md-navigation-rail` | `md-navigation-rail-tab`, plus an `md-fab` in `slot="fab"` |
| `md-accordion` | `md-accordion-item` |
| `md-stepper` | `md-step` (horizontal steppers also take `slot="content"`) |
| `md-breadcrumbs` | `md-breadcrumb-item` |
| `md-table-container` | `md-table` — **it wraps the table, not the other way round** — plus `md-table-toolbar` in `slot="top"` and `md-table-pagination` in `slot="bottom"`, which sit outside the scroll region |
| `md-table` | `md-table-head`, `md-table-body`, `md-table-foot` (and bare `md-table-row`). It does **not** accept the container, toolbar or pagination |
| `md-table-head` / `-body` / `-foot` | `md-table-row` |
| `md-table-row` | `md-table-cell`, plus `md-table-expand-toggle` for an expandable row |
| `md-table-cell` | `md-table-sort-label` in a header cell |
| `md-tooltip` | **its trigger** — the tooltip wraps the element it describes in its default slot |
| `md-dialog` | body content in the default slot; `md-button` in `slot="actions"` |
| `md-bottom-sheet` / `md-side-sheet` | content in the default slot; `md-button` in `slot="actions"`; headline in `slot="headline"` |
| `md-app-bar` | `md-icon-button` in `slot="leading"` and `slot="trailing"`; `md-menu` for overflow; a field in `slot="search"` on `variant="search"` |
| `md-toolbar` | `md-icon-button`, `md-button`, `md-button-group`; an `md-fab` in `slot="fab"`; `slot="leading"` / `slot="trailing"` for the end clusters |

### 7.2 Pairs that belong together

These don't nest — they sit next to each other in a working flow. Reaching for
one usually means you want the other.

| This | Goes with | Why |
|---|---|---|
| `md-icon-button` | `md-tooltip` | The tooltip supplies the visible meaning the icon lacks |
| `md-button` (`soft-disabled`) | `md-tooltip` | Explains *why* the action is unavailable |
| `md-fab` | `md-fab-menu` | The FAB is the menu's anchor |
| `md-split-button` | `md-menu` | The trailing half opens it |
| `md-card` | `md-button`, `md-icon-button`, `md-divider` | Footer actions, corner action, internal sections |
| `md-list-item` | `md-checkbox`, `md-switch`, `md-icon-button` | Trailing controls in a selectable or settings row |
| `md-bottom-sheet` / `md-side-sheet` | `md-list` | Action menus and filter panels inside the sheet |
| `md-search` | `md-list`, `md-avatar`, `md-icon-button` | Results in the panel; account and voice/filter affordances in the trailing slot |
| `md-date-picker` | `md-time-picker` | Date + time row for booking and scheduling forms |
| `md-time-picker` | `md-button` | The trigger, when `hide-trigger` is set. The picker **is** its own dialog — don't nest it in another one |
| `md-text-field` | `md-button` | Submit / cancel in the form footer |
| `md-multi-select` / `md-autocomplete` | `md-text-field`, `md-menu`, `md-chip` | The field is the trigger (and inherits its variant, density and error state), the menu is the option surface, chips are the selected values |
| `md-autocomplete` | `md-progress-indicator` | The loading row while suggestions are fetched |
| `md-transfer-list` | `md-checkbox`, `md-text-field`, `md-icon-button` | Per-row select, per-side search, mover controls |
| `md-number-field` | `md-text-field` hooks, `md-icon-button` | It *is* an `md-text-field` internally — every `--md-text-field-*` custom property passes through — and its steppers are `md-icon-button`s |
| `md-otp-field` | `md-button` | Verify action (`auto-submit` covers the no-button flow) |
| Any chart | `md-card` | Charts belong on a dashboard tile |
| `md-line-chart` | `md-segmented-button-set` | Period picker (1W / 1M / 1Y) driving the range |
| `md-sparkline` | `md-list-item`, `md-table-cell` | Trend column beside a value |
| `md-skeleton` | `md-card`, `md-list` | Render N placeholders in the real layout while fetching |
| `md-meter` | `md-chip`, `md-card` | Same semantic status colour on both; quota and usage summaries live on a card |
| `md-color-picker` | `md-text-field`, `md-button` | Label and helper text beside it in a form; save / cancel in the surrounding popover or dialog |
| `md-rating` | `md-text-field`, `md-card` | A rating row in a review form; aggregate scores on a review card |
| `md-accordion` | `md-divider` | Optional inner dividers inside long item content |
| `md-stepper` | `md-button` | Next / back / submit adjacent to the stepper |

### 7.3 Nesting that is always wrong

- A dialog opened from inside a dialog. Use `md-stepper` inside **one**
  `md-dialog`.
- A component inside a native interactive element (`<button>`, `<a>`) — it
  nests interactive controls and destroys the accessibility tree. Use the
  component's own `href` / `type` props.
- `md-tabs` used for top-level app navigation. Tabs switch sibling views of the
  same data; destinations are `md-navigation-bar` / `md-navigation-rail`.

---

## §8 — Page recipes

Complete, runnable screens. Each renders as-is once the components are
registered and the token sheet and font links from §3 are loaded — no
placeholder identifiers, no helper functions to write.

### 8.1 Login screen

`<form>` is load-bearing: it is what makes `required` block submit and what
`type="submit"` calls `requestSubmit()` on.

```html
<main style="display: grid; place-items: center; min-block-size: 100dvh; padding: 24px;">
  <form id="login-form" style="inline-size: min(420px, 100%);">
    <md-card variant="elevated" style="padding: 32px; display: flex; flex-direction: column; gap: 20px;">
      <h1 style="margin: 0; font: var(--md-sys-typescale-headline-medium-font);">Sign in</h1>

      <md-text-field
        variant="outlined"
        label="Email"
        type="email"
        name="email"
        autocomplete="username"
        required
      ></md-text-field>

      <md-text-field
        variant="outlined"
        label="Password"
        type="password"
        name="password"
        autocomplete="current-password"
        password-toggle="internal"
        required
      ></md-text-field>

      <md-button variant="filled" type="submit" full-width>Sign in</md-button>
      <md-button variant="text" href="/forgot-password">Forgot password?</md-button>
    </md-card>
  </form>
</main>

<script type="module">
  document.getElementById('login-form').addEventListener('submit', (e) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    console.log(data.get('email'), data.get('password'));
  });
</script>
```

### 8.2 Settings page (mobile)

Top app bar + grouped rows of instant-apply switches. `md-switch` uses
**`selected`**, not `checked`. The rows are `type="text"` (non-interactive), so
the switch is the only control — one tab stop per setting.

```html
<md-app-bar variant="small" headline="Settings">
  <md-icon-button slot="leading" icon="arrow_back" aria-label="Back"></md-icon-button>
</md-app-bar>

<main style="padding: 16px; display: flex; flex-direction: column; gap: 16px;">
  <md-card variant="outlined">
    <md-list>
      <md-list-item headline="Notifications" supporting-text="Push, email, in-app" lines="2">
        <md-switch slot="trailing" selected aria-label="Enable notifications" data-setting="notifications"></md-switch>
      </md-list-item>
      <md-divider></md-divider>
      <md-list-item headline="Dark mode" supporting-text="Match system" lines="2">
        <md-switch slot="trailing" aria-label="Enable dark mode" data-setting="dark"></md-switch>
      </md-list-item>
      <md-divider></md-divider>
      <md-list-item headline="Sync over cellular">
        <md-switch slot="trailing" selected aria-label="Sync over cellular" data-setting="cellular"></md-switch>
      </md-list-item>
    </md-list>
  </md-card>
</main>

<md-snackbar id="settings-toast" message="Setting saved"></md-snackbar>

<script type="module">
  const toast = document.getElementById('settings-toast');
  document.querySelectorAll('md-switch[data-setting]').forEach((sw) => {
    sw.addEventListener('mdChange', (e) => {
      if (sw.dataset.setting === 'dark') {
        document.documentElement.setAttribute('data-theme', e.detail.selected ? 'dark' : 'light');
      }
      toast.show();
    });
  });
</script>
```

### 8.3 Settings form (deferred save, with validation)

When settings are saved on submit rather than applied instantly, use a real
form. `required` blocks the submit; every control needs a `name` to reach
`FormData`.

```html
<form id="profile-form" style="max-inline-size: 560px; margin: 24px auto; padding: 0 16px;">
  <md-card variant="outlined" style="padding: 24px; display: flex; flex-direction: column; gap: 20px;">
    <h2 style="margin: 0; font: var(--md-sys-typescale-title-large-font);">Profile</h2>

    <md-text-field
      variant="outlined"
      label="Display name"
      name="displayName"
      required
      supporting-text="Shown on your public profile"
    ></md-text-field>

    <md-select variant="outlined" label="Language" name="language" value="en" required>
      <md-select-option value="en" label="English"></md-select-option>
      <md-select-option value="de" label="Deutsch"></md-select-option>
      <md-select-option value="ar" label="العربية"></md-select-option>
    </md-select>

    <!-- md-checkbox has NO label slot. It is a labelable, form-associated
         element: wrap it in a native <label> (which names it AND forwards
         clicks), or give the host an aria-label. Text between the tags is
         NOT rendered. -->
    <label style="display: inline-flex; align-items: center; gap: 12px; cursor: pointer;">
      <md-checkbox name="newsletter" value="yes" supporting-text="Monthly, no more"></md-checkbox>
      <span>Send me product news</span>
    </label>

    <div style="display: flex; gap: 8px; justify-content: flex-end;">
      <md-button variant="text" type="reset">Reset</md-button>
      <md-button variant="filled" type="submit">Save changes</md-button>
    </div>
  </md-card>
</form>

<md-snackbar id="saved-toast" message="Changes saved" action="Undo"></md-snackbar>

<script type="module">
  const form = document.getElementById('profile-form');
  const toast = document.getElementById('saved-toast');

  form.addEventListener('submit', (e) => {
    e.preventDefault();
    const data = Object.fromEntries(new FormData(form));
    console.log(data); // { displayName, language, newsletter? }
    toast.show();
  });

  toast.addEventListener('mdAction', () => form.reset());
</script>
```

### 8.4 Dashboard with a FAB

```html
<md-app-bar variant="medium" headline="Inbox">
  <md-icon-button slot="trailing" icon="search" aria-label="Search"></md-icon-button>
  <md-icon-button slot="trailing" icon="filter_list" aria-label="Filter"></md-icon-button>
</md-app-bar>

<main style="padding: 16px; display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));">
  <md-card variant="filled" interactive style="padding: 16px;">
    <strong>Acme contract</strong>
    <p style="margin: 4px 0 0; color: var(--md-sys-color-on-surface-variant);">Renewal due in 5 days</p>
  </md-card>
  <md-card variant="filled" interactive style="padding: 16px;">
    <strong>Q4 report draft</strong>
    <p style="margin: 4px 0 0; color: var(--md-sys-color-on-surface-variant);">Shared by Alex</p>
  </md-card>
</main>

<md-fab
  icon="add"
  aria-label="New item"
  style="position: fixed; inset-block-end: 16px; inset-inline-end: 16px; z-index: var(--md-sys-z-index-navigation);"
></md-fab>
```

`inset-inline-end` (not `right`) is what keeps the FAB in the correct corner
under `dir="rtl"`.

### 8.5 Destructive confirmation dialog

`md-dialog` traps focus and returns it to the trigger on close — do not add your
own focus management. The error colouring goes through the button's own custom
properties, never a hex.

```html
<md-button id="open-delete" variant="outlined">Delete account</md-button>

<md-dialog id="confirm-delete" headline="Delete account?" icon="warning">
  <p style="margin: 0;">
    This will permanently delete your account and all associated data.
    This action cannot be undone.
  </p>
  <md-button id="cancel-delete" slot="actions" variant="text">Cancel</md-button>
  <md-button
    id="do-delete"
    slot="actions"
    variant="filled"
    style="--md-button-container-color: var(--md-sys-color-error);
           --md-button-label-color: var(--md-sys-color-on-error);"
  >Delete</md-button>
</md-dialog>

<script type="module">
  const dialog = document.getElementById('confirm-delete');
  document.getElementById('open-delete').addEventListener('mdClick', () => dialog.show());
  document.getElementById('cancel-delete').addEventListener('mdClick', () => dialog.close());
  document.getElementById('do-delete').addEventListener('mdClick', () => {
    dialog.close();
    // perform the deletion
  });
</script>
```

If you omit `slot="actions"` entirely, `md-dialog` renders its own Cancel / OK
pair (labels via `cancel-label` / `ok-label`) and closes itself.

### 8.6 Tabbed content page

`md-tab-panels` finds the nearest preceding `md-tabs` (or the one named by
`for`), follows its `mdTabChange`, and wires `aria-controls` /
`aria-labelledby` both ways. **No JavaScript is required**, and `md-tab` has no
`value` or `selected` prop — selection lives on `md-tabs` as `active-tab-index`.

```html
<md-tabs id="profile-tabs" aria-label="Profile sections" active-tab-index="0">
  <md-tab label="Posts"></md-tab>
  <md-tab label="Replies"></md-tab>
  <md-tab label="Likes" badge="3"></md-tab>
</md-tabs>

<md-tab-panels for="profile-tabs">
  <md-tab-panel style="padding: 16px;">
    <p style="margin: 0;">Your posts appear here.</p>
  </md-tab-panel>
  <md-tab-panel style="padding: 16px;">
    <p style="margin: 0;">Your replies appear here.</p>
  </md-tab-panel>
  <md-tab-panel style="padding: 16px;">
    <p style="margin: 0;">Posts you liked appear here.</p>
  </md-tab-panel>
</md-tab-panels>
```

To react to the switch (lazy-loading a panel, for example), listen on the tabs:

```js
document.getElementById('profile-tabs')
  .addEventListener('mdTabChange', (e) => console.log(e.detail.index, e.detail.previousIndex));
```

### 8.7 The full recipe library

Twenty-two more complete screens live in the documentation, each with a live
demo, the full markup, framework tabs, and production notes. When the user's
request matches one of these, read that page (append the slug to
`https://awc-ui.dev/recipes/`) instead of composing from scratch:

| Recipe slug | Screen |
|---|---|
| `two-factor-verification` | OTP entry, resend countdown, backup-codes dialog |
| `kpi-overview` | Stat tiles with sparklines + charts under a range switcher |
| `data-grid-console` | Bulk-action data table: filters, selection, undo |
| `server-fleet-status` | Table of hosts with meters, sparklines, status dots |
| `release-health-drilldown` | Table row → side-sheet master-detail with charts |
| `csv-import-wizard` | Stepper: upload, map columns, validate, import |
| `role-permission-assignment` | Transfer-list permissions with confirm dialog |
| `org-chart-explorer` | Searchable org chart with side-sheet profiles |
| `moderation-queue` | Verdict controls, confidence meter, card queue |
| `checkout-wizard` | Three-step commerce checkout with validation |
| `appointment-booking` | Date + time pickers inside a stepper flow |
| `survey-nps` | Paged survey using every input control |
| `notification-preferences` | Accordion × switch matrix, quiet hours |
| `app-shell` | Responsive rail/bar chrome, SSR-ready, zero CLS |
| `inbox-shell` | Three-zone list + reading-pane layout |
| `mobile-filter-sheet` | Bottom-sheet filters with applied-chip row |
| `product-reviews` | Rating input + per-star meter breakdown |
| `order-tracking` | Shipment timeline stepper with progress meter |
| `portfolio-markets` | Finance charts + holdings table + trade ticket |
| `pricing-page` | Plan cards, billing toggle, comparison table |
| `async-feedback-patterns` | Skeleton/progress/snackbar choreography rules |
| `charts-gallery` | Every chart component, live-retheming palette |

---

## §9 — Universal do's and don'ts

Apply to every component. Component-specific rules live in each manual.

### 9.1 API and styling

| ✅ Do | ❌ Don't |
|---|---|
| Use the custom element directly — it *is* the control, with its own `href` / `type` props | Wrap it in a native `<button>` / `<a>` (§7.3) |
| Set arrays and objects as **JS properties** (`el.items = [...]`) | Pass them as HTML attributes — they won't parse |
| Theme via `--md-<component>-*` properties, then `::part()` | Reach into shadow internals or override `.md-*` classes |
| Change appearance through design tokens (§4.1) | Hardcode hex colors, px spacing, shadows, or font stacks |
| Use `--md-sys-z-index-*` for your own overlays | Invent z-indexes that land under a menu |
| Use logical CSS (`margin-inline-start`, `inset-inline-end`) | Use `margin-left`, `right`, `padding-right` |
| Set `data-density` / `data-theme` / `dir` once, globally | Set them per component unless you mean a local exception |
| Listen to the component's `md*` events | Rely on native `click` — it fires even when the component's `disabled` / `loading` guard suppressed the action |
| Check the manual for the state prop name | Assume `checked`; `md-switch` uses `selected` |
| Render the element, then call its `@Method` | Call `.show()` on an element not yet in the DOM |

Toggling components flip their own state on activation and *then* emit. On
`md-button`, `mdClick` is cancelable — `preventDefault()` on it vetoes the
toggle and any navigation. `mdChange` fires after the flip and is not
cancelable. If a controlled parent rejects a change it must revert the child
explicitly.

`md-button` has a **toggle mode**: set `toggle` and the button flips its own
`selected` on each activation and exposes `aria-pressed`. `selected` is the
state, not the switch — set it for the initial pressed state and read it back
from `mdClick`'s `detail.selected`. Setting `toggle` and then styling
"pressed" yourself, or setting `selected` without `toggle` (which emits no
`aria-pressed` at all), both produce a control that lies to assistive tech.

### 9.2 Content and hierarchy

| ✅ Do | ❌ Don't |
|---|---|
| Use sentence case for all labels | Uppercase or title-case UI text |
| Keep one high-emphasis action per screen region | Compete `filled` buttons against each other |
| Use `soft-disabled` + `md-tooltip` for contextually-unavailable actions | Use `disabled` and remove it from tab order with no explanation |
| Localize every text prop and `aria-label` | Leave default English prop values in a translated app |

**`disabled` vs `soft-disabled`.** Both render the disabled appearance, but
`disabled` also removes the control from the tab order, so a keyboard or screen
reader user can never find out *why* it is off. `soft-disabled` keeps the
control focusable and announced while still blocking activation — which is what
lets an `md-tooltip` on it explain the gate. Use it whenever the reason is
informative rather than obvious.

Nesting rules — dialog inside a dialog, a component inside a native
`<button>` / `<a>`, tabs used as app navigation — live in §7.3.

### 9.3 Accessibility contract

The library ships WCAG 2.1 AA keyboard and ARIA wiring by default. These are the
rules you must not break:

1. **Every icon-only control gets an accessible name.** `md-icon-button`,
   `md-fab`, and icon-only `md-tab` are nameless without `aria-label` (or
   `aria-labelledby`). `md-fab` will also accept `label`, and warns in the
   console when it has neither.
2. **Every form field gets a `label` prop.** `placeholder` is not a label — it
   disappears on type.
3. **Don't break the tab order.** Every interactive control must be reachable by
   Tab and operable by Enter/Space. If you add `tabindex="-1"` to anything,
   have a reason.
4. **Don't write your own focus trap.** `md-dialog`, `md-bottom-sheet` and
   `md-side-sheet` trap focus while open and restore it to the trigger on
   close — including across shadow boundaries. Adding your own fights theirs.
5. **Don't wrap components in your own live regions.** `md-snackbar` announces
   itself (`politeness="polite"` by default, `"assertive"` when the message must
   interrupt). For an error that blocks the task, use `md-dialog`.
6. **Don't disable motion yourself.** Components honour
   `prefers-reduced-motion: reduce` internally.
7. **Directional icons do not auto-flip in RTL** — see §4.5.
8. **Re-verify contrast after overriding any colour role** — the library's AA
   testing covers the shipped palette only. The Theme Generator at
   <https://awc-ui.dev/theme-generator> runs the WCAG checks live (§4.2).

---

## §10 — Before you ship

1. **Every icon-only control has an accessible name.**
2. **Keyboard-only pass**: reach and operate every control; focus is always
   visible; no traps in dialogs/menus/sheets; focus returns to the trigger.
3. **Theme pass**: light *and* dark, if both are supported — and the attribute
   is set before first paint.
4. **RTL pass**, if an RTL locale ships — check directional icons specifically,
   and grep your own CSS for physical properties.
5. **Density pass** at the configured rung — no clipped labels, touch targets
   still adequate. Remember `density="0"` is inert; use
   `--md-sys-density-scale: 0` to opt a subtree out.
6. **Forms**: every control has a `name`; `required` blocks submit; `FormData`
   contains every field; reset works.
7. **No hardcoded strings** left in markup if the app is localized — including
   default prop values like `Search`, `Dismiss`, `No results`.
8. **No hardcoded design values** — grep the diff for hex colours, `px` radii,
   `box-shadow`, and `z-index` literals.
9. **Contrast** re-verified against AA if colour roles were overridden.
10. **SSR**: if server-rendered, confirm no hydration mismatch and that content
    is present with JS disabled.
11. **No shadow-internal CSS** — only tokens, custom properties, and `::part()`.
12. **Every `md-*` tag you emitted appears in §6.** If it doesn't, it doesn't
    exist.