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

Property Attribute Type Default Reflects
density density | 'compact' | 'standard' | 'comfortable' | 0 | -1 | -2 | -3 | -4 'standard' Yes
columnTemplate column-template string '' —
columns columns number 0 —
minWidth min-width string '' —
stickyHeader sticky-header boolean false Yes
frozenHeader frozen-header boolean false Yes
keepHeight keep-height boolean true Yes
scrollbar scrollbar 'overlay' | 'gutter' 'overlay' Yes
pinMode pin-mode 'stack' | 'static' 'stack' Yes
pinIcon pin-icon string '' —
stickyFooter sticky-footer boolean false Yes
striped striped boolean false Yes
noDividers no-dividers boolean false Yes
hoverable hoverable boolean true Yes
motion motion 'expressive' | 'none' 'expressive' Yes
selection selection 'none' | 'single' | 'multiple' 'none' Yes
sortBy sort-by string '' Yes
sortOrder sort-order MdTableSortOrder 'asc' Yes
loading loading boolean false Yes
loadingMode loading-mode 'overlay' | 'skeleton' 'overlay' Yes
loadingRows loading-rows number 4 —
empty empty boolean false Yes
label label string '' —
caption caption string '' —
summary summary string '' —
rowOffset row-offset number 0 —
rowCount row-count number 0 —

Methods

Method Parameters
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

Slot Description
(default) —
empty —
head —
loader —
loading —

CSS Custom Properties

Override on the host element for per-instance theming:

Property Description
--md-table-row-height-compact 36px (dense)
--md-table-row-height-standard 52px
--md-table-row-height-comfortable 60px
--md-table-header-height 56px
--md-table-cell-padding-inline per-density default
--md-table-divider-color between rows
--md-table-stripe-color every-other-row overlay color
--md-table-head-bg background for thead rows
--md-table-head-color text color for thead
--md-table-foot-bg background for tfoot rows
--md-table-row-hover-color row hover state-layer color
--md-table-row-selected-bg selected row background
--md-table-row-selected-color selected row text
--md-table-columns-template grid-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():

Part Description
skeleton-row —
empty The empty-state region (when `empty`)
caption The optional caption above the head
header-grid —
grid The grid container holding all rows
hscrollbar —
hscrollbar-thumb —
vscrollbar —
vscrollbar-thumb —
loading The 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

Property Attribute Type Default Reflects
variant variant 'elevated' | 'outlined' | 'filled' | 'flat' 'elevated' Yes
shape shape 'extra-large' | 'large' | 'medium' | 'small' | 'none' 'large' Yes
maxHeight max-height string '' —
minHeight min-height string '' —
vibrant vibrant boolean false Yes
elevation elevation 0 | 1 | 2 | 3 | 4 | 5 — Yes

Slots

Slot Description
(default) —
top —
bottom —

CSS Custom Properties

Override on the host element for per-instance theming:

Property Description
--md-table-container-color Surface background
--md-table-container-shape Border-radius
--md-table-container-elevation Box-shadow
--md-table-container-outline-color Outline color (variant=outlined)
--md-table-container-outline-width Outline width (variant=outlined)

CSS Shadow Parts

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

Part Description
top Wrapper around `slot[name="top"]` (toolbar etc.)
scroll The scroll viewport that contains the table
bottom Wrapper 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

Slot Description
(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

Property Attribute Type Default Reflects
presentational presentational boolean false Yes

Slots

Slot Description
(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

Property Attribute Type Default Reflects
presentational presentational boolean false Yes

Slots

Slot Description
(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 group — rowgroup="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

Property Attribute Type Default Reflects
rowgroup rowgroup 'head' | 'body' | 'foot' 'body' Yes
selected selected boolean false Yes
disabled disabled boolean false Yes
selectable selectable boolean true —
clickable clickable boolean false Yes
value value string '' —
expandable expandable boolean false Yes
expanded expanded boolean false Yes
highlight highlight boolean false Yes

Methods

Method Parameters
toggle() none

Slots

Slot Description
(default) —
expanded —

CSS Custom Properties

Override on the host element for per-instance theming:

Property Description
--md-table-row-hover-color —

CSS Shadow Parts

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

Part Description
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 casing — colspan="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

Property Attribute Type Default Reflects
align align 'start' | 'center' | 'end' | 'justify' 'start' Yes
numeric numeric boolean false Yes
padding padding 'default' | 'none' | 'checkbox' 'default' Yes
head head boolean false Yes
scope scope 'col' | 'row' | 'colgroup' | 'rowgroup' | '' '' Yes
sticky sticky 'start' | 'end' | '' '' Yes
colSpan colspan number 1 —
rowSpan rowspan number 1 —
ellipsis ellipsis boolean false Yes
variant variant 'body' | 'meta' 'body' Yes
noPinIndicator no-pin-indicator boolean false Yes
pinIcon pin-icon string '' —
density density 0 | -1 | -2 | -3 | -4 0 Yes

Slots

Slot Description
(default) —

CSS Custom Properties

Override on the host element for per-instance theming:

Property Description
--md-table-cell-color Foreground (text) color
--md-table-cell-background Background color
--md-table-cell-padding-inline Horizontal padding
--md-table-cell-min-height Minimum cell height
--md-table-cell-sticky-bg Opaque 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():

Part Description
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

Property Attribute Type Default Reflects
headline headline string '' —
supportingText supporting-text string '' —
numSelected num-selected number 0 Yes
autoBind auto-bind boolean false —
labelSelected label-selected string '%count% selected' —
compact compact boolean false Yes
density density 0 | -1 | -2 | -3 | -4 0 Yes

Slots

Slot Description
leading —
headline —
supporting-text —
title-area —
selection-actions —
actions —

CSS Custom Properties

Override on the host element for per-instance theming:

Property Description
--md-table-toolbar-color Foreground color
--md-table-toolbar-bg Background color (default mode)
--md-table-toolbar-selection-bg Background color (selection mode)
--md-table-toolbar-selection-color Foreground color (selection mode)
--md-table-toolbar-padding-block Top/bottom padding
--md-table-toolbar-padding-inline Left/right padding
--md-table-toolbar-min-height Minimum height (default: 64px)

CSS Shadow Parts

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

Part Description
leading Container for the `leading` slot
main Container 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 array — rows-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

Property Attribute Type Default Reflects
count count number 0 —
page page number 0 Yes
rowsPerPage rows-per-page number 10 Yes
rowsPerPageOptions rows-per-page-options string '5,10,25,50' —
showFirstLast show-first-last boolean false Yes
labelDisplayedRows label-displayed-rows string '%from%–%to% of %count%' —
labelRowsPerPage label-rows-per-page string 'Rows per page:' —
labelFirstPage label-first-page string 'First page' —
labelPreviousPage label-previous-page string 'Previous page' —
labelNextPage label-next-page string 'Next page' —
labelLastPage label-last-page string 'Last page' —
labelAll label-all string 'All' —
compact compact boolean false Yes
disabled disabled boolean false Yes
density density 0 | -1 | -2 | -3 | -4 0 Yes

Methods

Method Parameters
goToPage() page: number
setRowsPerPage() rpp: number

Slots

Slot Description
actions —

CSS Custom Properties

Override on the host element for per-instance theming:

Property Description
--md-table-pagination-color Foreground color
--md-table-pagination-bg Background color
--md-table-pagination-padding-block Top/bottom padding
--md-table-pagination-padding-inline Left/right padding
--md-table-pagination-select-width Rows-per-page select width (default 72px)

CSS Shadow Parts

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

Part Description
display The "from–to of count" label
rpp Rows-per-page region
rpp-select The md-select picker
nav Navigation 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

Property Attribute Type Default Reflects
column column string '' —
disabled disabled boolean false Yes
defaultOrder default-order 'asc' | 'desc' 'asc' —
iconPosition icon-position 'start' | 'end' 'end' Yes
icon icon string '' —
inactiveIcon inactive-icon 'always' | 'hover' 'always' Yes
active active boolean false Yes
order order 'asc' | 'desc' | 'none' 'none' Yes
sortedAscendingLabel sorted-ascending-label string 'sorted ascending' —
sortedDescendingLabel sorted-descending-label string 'sorted descending' —
density density 0 | -1 | -2 | -3 | -4 0 Yes

Slots

Slot Description
(default) —

CSS Custom Properties

Override on the host element for per-instance theming:

Property Description
--md-table-sort-label-color Inactive color
--md-table-sort-label-active-color Active color
--md-table-sort-label-icon-size Icon size (default 18px)
--md-table-sort-label-gap Gap between text and icon
--md-table-sort-label-rest-opacity —

CSS Shadow Parts

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

Part Description
icon The arrow indicator
label The 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

Property Attribute Type Default Reflects
buttonLabel button-label string 'Expand row' —
icon icon string 'chevron_right' —

CSS Custom Properties

Override on the host element for per-instance theming:

Property Description
--md-table-expand-toggle-rotation Expanded 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`).
- Sort labels accept `column` as either an HTML attribute or a JavaScript
  property (including React props). The live property takes precedence;
  assigning `''` clears the binding. Changing a label's column or replacing
  header rows updates its arrow and the header cell's `aria-sort` from the
  table's current sort state.
- **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, and horizontal scroll is mirrored between
  the two. Native touch panning works on both axes in LTR and RTL. The default
  `scrollbar="overlay"` keeps the custom bars; `scrollbar="gutter"` uses native
  bars on both overflowing axes without a duplicate custom horizontal bar.
  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`       | 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 native bars render on both overflowing axes.                                                                                                                                                                        | `"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: 56
sub-component-count: 25
manual-count: 81
per-component-docs: ./packages/core/src/components/<tag>/readme.md
-->

**Use this reference when building, updating, or reviewing an interface with
AWC UI, a Material Design 3 web-component library.** Start with the user's
request and the existing project. The sections below cover setup, component
selection, composition, and verification; read the sections relevant to the
task. Per-component detail lives in
`./packages/core/src/components/<tag>/readme.md` — e.g.
[`md-button`](./packages/core/src/components/md-button/readme.md).

1. **Match the task's scope** (§1). Reuse decisions from the project and
   conversation. A focused edit does not need a product interview or scaffold.
2. **Choose documented components** using the decision matrix (§5), and read
   each affected component's manual before changing its markup (§6). Check its
   API, `When NOT to use`, accessibility, and `Anti-patterns` sections; do not
   infer behavior from a similar library or sibling component.
3. **Use the existing integration.** For a new app or a requested setup change,
   use §2–§4. For composition, consult §7 and the relevant recipes in §8.
4. **Follow the API and accessibility rules** (§9). Do not invent an `md-*`
   tag, prop, event, slot, CSS part, or token. If the documented API cannot
   satisfy the request, explain the gap and resolve the consequential choice.
5. **Verify the affected behavior** using the applicable checks in §10.
   Report what changed and what was checked. A review reports findings without
   changing files unless a fix was requested.

---

## §1 — Match the task

| Task | How to proceed |
|---|---|
| New app | Establish the app's purpose and use known project or user decisions. Ask only about missing choices that materially affect the result; use reasonable defaults for minor details and state consequential assumptions. Then apply the relevant setup in §2–§4. |
| Existing app or focused fix | Preserve the framework, configuration, design, and product scope unless the request changes them. Inspect the affected code and manuals, then make the requested change. Do not restart discovery or scaffold another app. |
| Review or explanation | Inspect and explain the requested surface. Keep the work read-only unless the user asks for a fix. |

### Optional discovery checklist

Use the questions below as a reference when a new app or requested feature
leaves an important decision open. They are not a required interview, an
ordered sequence, or a reason to stop a focused task. Project configuration
and answers already given in the conversation take precedence; do not ask for
them again. Ask only what matters to the current task, and group related
questions when that helps. The **bold** options are starting defaults for a
new app, not instructions to override an existing project.

### 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,
    the feature needs a third-party editor (TipTap, Lexical, Quill) styled with
    MD3 tokens. Resolve that choice if the project or request does not cover it.

### 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

Apply this reference to new setup or requested configuration changes. Preserve
an existing project's choices for unrelated work.

| 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 20.3.31+ |
| Vue / Nuxt | `@awc-ui/vue` | Vue 3 |
| Svelte / SvelteKit | `@awc-ui/svelte` | Svelte 5.57+ |
| 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.
  Invalid submissions show the platform's message in the control's own inline
  error styling, with no browser validation popover. Correcting or resetting a
  field clears its generated message. `error` + `error-text` remain available
  for app-provided messages and take precedence over generated messages.
  `checkValidity()` checks silently; `reportValidity()` shows inline errors and
  focuses the first invalid control. Core submit buttons and Enter do the same.
- 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.

The library has 56 components and 25 sub-components, with 81 manuals in total.
Sub-components 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 Reusable interaction patterns

- **One footer for a dialog wizard.** Set `md-stepper.nav = false` and put
  Back / Continue in the dialog's `actions` slot. Keep the steps in the body;
  the dialog owns scrolling and keeps its header/footer visible. In fullscreen
  mode use the header close button for dismissal. Use a real form and submit
  button so Enter and inline validation follow the same path as a click.
- **Overlay handoffs wait for completion.** Use `await search.close(); await
  search.whenClosed();` before opening a result dialog or unmounting search.
  `mdClose` signals the state change; `whenClosed()` includes motion and cleanup.
  Do not replace that contract with a guessed timeout or a second focus trap.
- **Appearance controls fill their allocated space.** For an inline color picker
  in settings, set `--md-color-picker-width: 100%`. Its compact default remains
  appropriate for a popover. No shadow-DOM width overrides or extra tabindex.
- **Loading belongs to the operation.** Use `md-button.loading` for the initiating
  action and `md-skeleton` with `announce=false` for the content being replaced.
  Preserve the content layout, expose one live status, and guard repeated submits.
  Abort or ignore stale responses when the owner closes or a newer request wins.
  Provide inline error/retry and preserve entered values; do not add artificial waits.
- **Separate routing intent from selection synchronization.** `md-navigation-bar`
  emits `mdChange` for both user and programmatic changes. For a controlled router,
  use each `md-navigation-tab`'s `mdTabClick` for user intent, `manual-activation`
  on the bar, and synchronize `activeIndex` from the confirmed route. Avoid routing
  again merely because browser Back/Forward updated the selected index.
- **Keep table chrome outside the column scroller.** Use `slot="top"` and
  `slot="bottom"` on the toolbar and pagination. Set `min-inline-size: 0` on the
  containing grid/flex child and a readable minimum width on the table. Nested
  controls own their keys; do not add row-level Enter/Space interception in apps.

### 7.4 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

Use the checks relevant to the affected components and behavior. For a small
fix, verify the changed interaction and nearby regressions; a new app or broad
change needs wider coverage. Do not turn a focused edit into an unrelated
full-app audit.

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.


## MCP server and reusable skills

Core provides a read-only MCP server (`@awc-ui/mcp`) and `awc-ui-build` / `awc-ui-review` skills. MCP tools `search_components`, `get_component`, `list_guides`, and `get_guide` expose versioned Core documentation. Match the reported Core version to the consumer installation; use local manuals when versions differ. The server is optional and grants no permission for project mutations.

The Core package ships skills in `skills/`; `awc-ui ai-setup --skills` installs them under the consumer project’s `.agents/skills/` without overwriting customized copies. For configuration and skill-installer availability, see [AI integration](https://awc-ui.dev/guides/building-with-ai/#optional-mcp-and-skills). Use existing project and user decisions rather than restarting discovery for a focused edit.