Skip to content

Bar Chart

Compare magnitudes across categories. Vertical or horizontal bars, grouped or stacked, with legend, tooltips, zoom, drill-down and an expressive entrance animation. Bars are corner-rounded by default so they rhyme with the surrounding chip and button shape.

Live preview Open in Storybook
Show code for each technology
<md-bar-chart label="Sales by quarter" legend="top-end"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Desktop",
      data: [120, 200, 150, 80]
    },
    {
      label: "Mobile",
      data: [220, 182, 191, 234]
    },
    {
      label: "Tablet",
      data: [150, 232, 201, 154]
    }
  ];

  const xAxis = {
    data: ["Q1", "Q2", "Q3", "Q4"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

Already installed? See the Installation guide for one-time package setup (core + tokens, fonts). Each tab below shows two patterns for using md-bar-chart 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-bar-chart) ─── -->
<script type="module">
  import '@awc-ui/core/components/md-bar-chart';
</script>


<md-bar-chart></md-bar-chart>
  • Comparing magnitudes across categories: revenue by region, count by status.
  • Part-to-whole across categories (stack="normal" / stack="percentage").
  • Ranked comparisons with long category names (layout="horizontal").
SituationUse instead
A trend over timemd-line-chart
Trend plus cumulative volumemd-area-chart
Parts of a single whole (≤ ~6)md-pie-chart
An inline micro-trendmd-sparkline
Exact values users must readmd-table
Two or three numbersPlain text
A continuous distributionA histogram — build the bins yourself into series
NeedSetting
Long category labelslayout="horizontal"
Compare compositionstack="normal"
Compare sharestack="percentage"
Values on the barsshow-labels
Stack totalsshow-totals
Explore a long serieszoom="inside | slider | both"
Radial barspolar (plus polar-hole, polar-sweep)
Click-throughclickable (plus mdBarClick)
Reduced motionno-animation, or animation="none"
Show code for each technology
<md-bar-chart layout="vertical"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Visits",
      data: [120, 200, 150, 80, 70, 110, 130]
    }
  ];

  const xAxis = {
    data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

Use it for long category names. Don’t rotate labels to 90° to make a vertical chart fit — turn the chart instead.

Show code for each technology
<md-bar-chart layout="horizontal"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Score",
      data: [89, 76, 92, 68, 81]
    }
  ];

  const xAxis = {
    data: ["Performance", "Accessibility", "Best practices", "SEO", "PWA"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

layout and stack are independent, so grouped series work sideways too. This is the layout for a ranked comparison of a few series across named categories — the labels get the room they need and each group still reads as one row.

Grouped bars, horizontal Open in Storybook
Show code for each technology
<md-bar-chart layout="horizontal" legend="bottom" show-labels></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "2023",
      data: [52, 61, 44, 38, 29]
    },
    {
      label: "2024",
      data: [64, 58, 51, 47, 33]
    }
  ];

  const xAxis = {
    data: ["North America", "Europe", "Asia Pacific", "Latin America", "Middle East"]
  };

  const yAxis = {
    label: "Revenue (€M)"
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis, yAxis });
</script>
Show code for each technology
<md-bar-chart></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Desktop",
      data: [120, 200, 150, 80]
    },
    {
      label: "Mobile",
      data: [220, 182, 191, 234]
    }
  ];

  const xAxis = {
    data: ["Q1", "Q2", "Q3", "Q4"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>
Stacked, with totals
Show code for each technology
<md-bar-chart stack="normal" show-totals></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Desktop",
      data: [120, 200, 150, 80]
    },
    {
      label: "Mobile",
      data: [220, 182, 191, 234]
    },
    {
      label: "Tablet",
      data: [150, 232, 201, 154]
    }
  ];

  const xAxis = {
    data: ["Q1", "Q2", "Q3", "Q4"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>
Share-of-total Open in Storybook
Show code for each technology
<md-bar-chart stack="percentage"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Desktop",
      data: [120, 200, 150, 80]
    },
    {
      label: "Mobile",
      data: [220, 182, 191, 234]
    },
    {
      label: "Tablet",
      data: [150, 232, 201, 154]
    }
  ];

  const xAxis = {
    data: ["Q1", "Q2", "Q3", "Q4"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

Stacks hide individual magnitudes. If readers need to compare per-series values, use grouped bars.

Percentage stacking sideways gives one full-width bar per category, which is the cleanest way to compare composition when the category names are long: every row starts and ends in the same place, so the eye compares segment boundaries rather than bar ends.

Share of traffic per channel Open in Storybook
Show code for each technology
<md-bar-chart layout="horizontal" stack="percentage" legend="bottom" corner-radius="4"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Direct",
      color: "primary",
      data: [42, 31, 25, 38]
    },
    {
      label: "Organic",
      color: "secondary",
      data: [33, 44, 51, 36]
    },
    {
      label: "Referral",
      color: "tertiary",
      data: [25, 25, 24, 26]
    }
  ];

  const xAxis = {
    data: ["Documentation", "Marketing site", "Blog", "Support centre"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

A series’ stack field is a group key, not a boolean. Series sharing a key stack together; different keys sit side by side. That gives you both comparisons at once — composition within each group, and totals between groups — which neither plain stacking nor plain grouping can show alone.

const series = [
{ label: 'John', stack: 'male', data: [5, 3, 4, 7, 2] },
{ label: 'Joe', stack: 'male', data: [3, 4, 4, 2, 5] },
{ label: 'Jane', stack: 'female', data: [2, 5, 6, 2, 1] },
{ label: 'Janet', stack: 'female', data: [3, 0, 4, 4, 3] },
];
Two stacks per category, side by side Open in Storybook
Show code for each technology
<md-bar-chart stack="normal" legend="bottom" corner-radius="4" show-labels></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "John",
      stack: "male",
      data: [5, 3, 4, 7, 2]
    },
    {
      label: "Joe",
      stack: "male",
      data: [3, 4, 4, 2, 5]
    },
    {
      label: "Jane",
      stack: "female",
      data: [2, 5, 6, 2, 1]
    },
    {
      label: "Janet",
      stack: "female",
      data: [3, 0, 4, 4, 3]
    }
  ];

  const xAxis = {
    data: ["Apples", "Oranges", "Pears", "Grapes", "Bananas"]
  };

  const yAxis = {
    label: "Units sold"
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis, yAxis });
</script>

show-totals prints one total per group, not per category, since each group is its own stack.

label is the chart’s title and subtitle the line under it — the place for a unit, a period or a source, so the axis label doesn’t have to carry it. title-align puts the pair at the start (default), center or end of the plot.

Title, subtitle and a formatted value axis Open in Storybook
Show code for each technology
<md-bar-chart label="Cereal production" subtitle="Source: FAOSTAT, 2023" legend="bottom"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Wheat",
      data: [808, 771, 761, 790]
    },
    {
      label: "Maize",
      data: [1163, 1210, 1148, 1232]
    },
    {
      label: "Rice",
      data: [520, 514, 522, 535]
    }
  ];

  const xAxis = {
    data: ["2020", "2021", "2022", "2023"]
  };

  const yAxis = {
    label: "Million tonnes"
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis, yAxis });
</script>

Centre the title when the chart is the whole story rather than one panel in a grid — a centred title over a share-of-total chart reads as a headline:

title-align=center over a percentage stack Open in Storybook
Show code for each technology
<md-bar-chart label="How people get to work" subtitle="Share of commuters by mode" title-align="center" stack="percentage" legend="bottom" corner-radius="2" category-gap="45%"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Car",
      color: "primary",
      data: [64, 51, 38, 29]
    },
    {
      label: "Public transport",
      color: "secondary",
      data: [21, 30, 39, 44]
    },
    {
      label: "Bicycle",
      color: "tertiary",
      data: [9, 13, 16, 20]
    },
    {
      label: "Walking",
      color: "surface-variant",
      data: [6, 6, 7, 7]
    }
  ];

  const xAxis = {
    data: ["2000", "2010", "2020", "2030 (proj.)"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

category-gap (between categories) and bar-gap (between sibling series) accept either a CSS-style percentage string ("30%") or a raw number. corner-radius tunes the bar shape — set corner-radius="0" in data-table contexts where rounded corners cost too many pixels.

Soft corners, MD3 Expressive Open in Storybook
Show code for each technology
<md-bar-chart corner-radius="12" category-gap="40%"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "value",
      data: [12, 18, 14, 22, 28, 24]
    }
  ];

  const xAxis = {
    data: ["A", "B", "C", "D", "E", "F"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

pointWidths maps a category index to a width, so a bar can carry two measures at once: its length is one variable, its thickness another, and its area is their product. A variwide chart of cost-per-hour against share of output shows at a glance where the money actually goes — a tall thin bar is expensive but marginal, a short wide one is cheap but everywhere.

const series = [{
label: 'Labour cost (€/h)',
data: [46.9, 43.7, 38.1, 29.2, 11.7], // bar LENGTH
pointWidths: { 0: 24, 1: 18, 2: 31, 3: 14, 4: 9 }, // bar THICKNESS
}];
Length = cost per hour, width = share of GDP Open in Storybook
Show code for each technology
<md-bar-chart legend="none" corner-radius="4" category-gap="12%" show-labels></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Labour cost (€/h)",
      color: "primary",
      data: [46.9, 43.7, 38.1, 29.2, 22.4, 11.7],
      pointWidths: {
        "0": 26,
        "1": 20,
        "2": 33,
        "3": 16,
        "4": 11,
        "5": 8
      }
    }
  ];

  const xAxis = {
    data: ["Denmark", "Belgium", "Germany", "Spain", "Poland", "Romania"],
    labelRotation: -45
  };

  const yAxis = {
    label: "Labour cost (€/h)"
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis, yAxis });
</script>

labelRotation on the category axis tilts the tick labels by a number of degrees. Negative angles lean the text up to the right with its end under the tick, which is the convention for names too long to sit upright. Without it, labels that don’t fit their own band are truncated with an ellipsis instead.

labelRotation: -45 — long city names leaning under their ticks Open in Storybook
Show code for each technology
<md-bar-chart legend="none" corner-radius="6" show-labels></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Population (M)",
      color: "primary",
      data: [37.4, 30.3, 27.1, 22, 21.8, 20.4]
    }
  ];

  const xAxis = {
    data: ["Tokyo, Japan", "Delhi, India", "Shanghai, China", "São Paulo, Brazil", "Mexico City, Mexico", "Cairo, Egypt"],
    labelRotation: -45
  };

  const yAxis = {
    label: "Population (M)"
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis, yAxis });
</script>

Prefer layout="horizontal" when every label is long — rotating is a fix for a few awkward names, not a substitute for turning the chart.

show-labels — the value printed on each bar Open in Storybook
Show code for each technology
<md-bar-chart show-labels></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Score",
      data: [82, 91, 67, 88, 75]
    }
  ];

  const xAxis = {
    data: ["Q1", "Q2", "Q3", "Q4", "Q1"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

show-totals adds one number above each stack — the column’s sum. It reads the whole column, so it only means anything on a stacked chart; on a grouped one the bars sit side by side and there is no total to print.

Pair it with show-labels when the segments are large enough to hold their own number, and drop show-labels when they aren’t: a total above a stack of thin slivers still tells the reader the thing they came for.

show-totals with a normal stack Open in Storybook
Show code for each technology
<md-bar-chart label="Sessions by device" stack="normal" show-totals legend="bottom"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Desktop",
      data: [24, 31, 28, 36]
    },
    {
      label: "Mobile",
      data: [38, 42, 47, 51]
    },
    {
      label: "Tablet",
      data: [9, 11, 10, 13]
    }
  ];

  const xAxis = {
    data: ["Q1", "Q2", "Q3", "Q4"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

color on a series takes an MD3 role name or any CSS colour. Prefer the role: it follows the theme and stays legible in dark mode, where a hardcoded hex will not.

Role names — primary, secondary, tertiary Open in Storybook
Show code for each technology
<md-bar-chart legend="bottom"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "primary",
      color: "primary",
      data: [24, 31, 28, 36]
    },
    {
      label: "secondary",
      color: "secondary",
      data: [38, 42, 47, 51]
    },
    {
      label: "tertiary",
      color: "tertiary",
      data: [9, 11, 10, 13]
    }
  ];

  const xAxis = {
    data: ["Q1", "Q2", "Q3", "Q4"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>
Explicit CSS colours — hex, rgb and named Open in Storybook
Show code for each technology
<md-bar-chart legend="bottom"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "hex",
      color: "#6750A4",
      data: [24, 31, 28, 36]
    },
    {
      label: "rgb",
      color: "rgb(125, 82, 96)",
      data: [38, 42, 47, 51]
    },
    {
      label: "named",
      color: "teal",
      data: [9, 11, 10, 13]
    }
  ];

  const xAxis = {
    data: ["Q1", "Q2", "Q3", "Q4"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

One outlier can flatten every other bar into a stub. breaks: 'auto' on the value axis detects the gap and folds it away, so the small bars get their range back while the outlier stays honestly larger.

The break is drawn on the axis, not hidden — the reader can see the scale was cut. Reach for it when a category is genuinely an order of magnitude out; if the spread is merely wide, a log scale or a second chart is the better answer.

breaks: 'auto' — Infra dwarfs the rest Open in Storybook
Show code for each technology
<md-bar-chart legend="none" show-labels></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Spend ($k)",
      color: "#6750A4",
      data: [42, 55, 38, 1200, 61, 47]
    }
  ];

  const xAxis = {
    data: ["Design", "Eng", "Sales", "Infra", "Support", "Ops"]
  };

  const yAxis = {
    label: "Spend ($k)",
    breaks: "auto"
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis, yAxis });
</script>

A series can carry range instead of data: an array of [low, high] pairs. Each bar then floats between its two values rather than growing from the baseline — the shape for a spread (daily temperature range, min/max latency, a salary band) where zero is not the interesting reference point.

range: [[low, high], …] — monthly temperature spread Open in Storybook
Show code for each technology
<md-bar-chart legend="none"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Temperature (°C)",
      range: [
        [-9.7, 9.4],
        [-8.7, 6.5],
        [-3.5, 9.4],
        [-1.4, 19.9],
        [0, 22.6],
        [2.9, 29.5],
        [9.2, 30.7],
        [7.3, 26.5]
      ]
    }
  ];

  const xAxis = {
    data: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

Bars that cross zero fill both ways, and the axis keeps the zero line where the data puts it.

Show code for each technology
<md-bar-chart show-labels legend="none"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Net change",
      data: [24, -12, 18, -6, 31, -19]
    }
  ];

  const xAxis = {
    data: ["Jan", "Feb", "Mar", "Apr", "May", "Jun"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

Give one series negative values and the other positive, lay the chart out horizontally, and the rows run out from a centre line in both directions. The sign is a direction here, not a quantity — so format the axis and tooltips to print magnitudes, or the left half reads as negative people.

mirrorLabels on the category axis repeats the tick labels in the opposite gutter. It exists for exactly this chart: with labels on one side only, a name sits a long way from half the data it names.

Age structure — mirrored labels, one side negated Open in Storybook
Show code for each technology
<md-bar-chart label="Population by age" layout="horizontal" stack="normal" legend="bottom" corner-radius="8"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Male",
      color: "#5fa8f5",
      data: [-1.3, -2.4, -2.9, -4, -4.1, -4.4, -3.4, -2.1, -1]
    },
    {
      label: "Female",
      color: "#2ee06a",
      data: [1.4, 2.5, 2.7, 3.6, 4, 4.2, 3.3, 1.9, 1]
    }
  ];

  const xAxis = {
    data: ["0–9", "10–19", "20–29", "30–39", "40–49", "50–59", "60–69", "70–79", "80+"],
    label: "Age group",
    mirrorLabels: true
  };

  const yAxis = {
    label: "Share of population (%)"
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis, yAxis });
</script>

Three series fields cover the “one chart, two units” case:

FieldEffect
yAxisIndex: 1Measure this series against yAxis2 instead of the primary axis
overlay: trueDraw it centred on the category band rather than taking a slot beside its siblings
widthRatioHow much of the band the overlaid series covers (0.34 = a third)

Overlay is what stops a second unit from stealing horizontal space: the narrow bar sits in front of the wide one, sharing the same category, so both are read against the same position. The engine zero-aligns the two axes, so the chart keeps one baseline and negative months open downward on both.

Rainfall on the left axis, temperature overlaid on the right Open in Storybook
Show code for each technology
<md-bar-chart label="Oslo climate" legend="bottom" corner-radius="4" category-gap="34%"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Rainfall (mm)",
      color: "#5fa8f5",
      data: [49, 36, 47, 41, 53, 65, 81, 89, 90, 84, 73, 55]
    },
    {
      label: "Temperature (°C)",
      color: "#f0a05a",
      yAxisIndex: 1,
      overlay: true,
      widthRatio: 0.34,
      data: [-4.3, -4, -0.2, 4.5, 10.8, 15.2, 16.4, 15.2, 10.8, 6.3, 0.7, -3.1]
    }
  ];

  const xAxis = {
    data: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
  };

  const yAxis = {
    label: "Rainfall (mm)"
  };

  const yAxis2 = {
    label: "Temperature (°C)"
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis, yAxis, yAxis2 });
</script>

The same overlay field does a second job: a translucent full-width column for the reference period with the current one drawn narrower in front. Both sit on the same category, so the comparison is a single glance rather than two bars to mentally pair up.

Keep the baseline translucent and behind. It’s a reference mark, not a reading competing for attention — give it equal weight and the chart stops having a subject.

2015 baseline behind, 2024 in front Open in Storybook
Show code for each technology
<md-bar-chart label="Renewable generation" legend="top-end" corner-radius="6" category-gap="30%"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "2015",
      color: "rgba(190, 180, 255, 0.28)",
      data: [396, 191, 122, 78, 44]
    },
    {
      label: "2024",
      color: "#b9a7ff",
      overlay: true,
      widthRatio: 0.5,
      data: [812, 344, 208, 96, 71]
    }
  ];

  const xAxis = {
    data: ["Wind", "Solar", "Hydro", "Biomass", "Geothermal"]
  };

  const yAxis = {
    label: "TWh"
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis, yAxis });
</script>

chevron draws the bars as arrowheads pointing away from the baseline instead of rectangles. Along a stack they interlock, each segment’s point sitting in the next one’s notch — so the contributions read as being handed along in a direction rather than merely piled up. It’s the shape for a force balance, where sign means “which way” and the total is what’s left over.

Strip the chrome that a single-category chart doesn’t need: hidden on the category axis, axisLine: false on both, hideTicks on the value axis.

Forces on a Mars lander — blue decelerates, red accelerates Open in Storybook
Show code for each technology
<md-bar-chart label="Force balance" layout="horizontal" stack="normal" chevron tooltip="item" legend="bottom" show-labels bar-width="36" style="block-size: 180px;"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Gravity",
      color: "#f4245e",
      data: [3730]
    },
    {
      label: "Drag",
      color: "#1668ff",
      data: [-2180]
    },
    {
      label: "Parachute",
      color: "#1668ff",
      data: [-980]
    },
    {
      label: "Retro thrust",
      color: "#1668ff",
      data: [-430]
    }
  ];

  const xAxis = {
    data: ["Force"],
    hidden: true,
    axisLine: false
  };

  const yAxis = {
    hideTicks: true,
    axisLine: false
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis, yAxis });
</script>

polar bends the category axis into a circle: every category becomes its own ring, and a bar’s length becomes the arc it sweeps. polar-hole sets the inner radius as a fraction of the outer (0 is a full disc), and polar-sweep the fraction of the circle the plot covers — leaving a gap makes the start and end of the scale distinguishable, which a closed ring cannot do.

One series — each ring is a category
Show code for each technology
<md-bar-chart polar polar-hole="0.25" legend="bottom"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Activity",
      data: [42, 68, 55, 91, 74, 38, 60]
    }
  ];

  const xAxis = {
    data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

Stacking works in polar exactly as it does in cartesian: stack="normal" divides each ring into segments that accumulate along the arc, so the ring’s total length is the category total and each segment’s share of it is visible at the same time.

corner-radius rounds the segment ends and category-gap sets the space between rings — both matter more here than on a bar chart, because adjacent rings are only separated by that gap.

Stacked rings — Olympic medals by nation Open in Storybook
Show code for each technology
<md-bar-chart label="All-time Olympic medals" polar stack="normal" polar-hole="0.24" polar-sweep="0.78" corner-radius="4" category-gap="26%" legend="bottom"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Gold",
      color: "#e0b400",
      data: [1061, 440, 285, 285, 262]
    },
    {
      label: "Silver",
      color: "#b6b8bd",
      data: [837, 357, 313, 316, 199]
    },
    {
      label: "Bronze",
      color: "#c07f4a",
      data: [738, 325, 344, 309, 173]
    }
  ];

  const xAxis = {
    data: ["United States", "Soviet Union", "Germany", "Great Britain", "China"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

drill(index, 'down' | 'up') plays the transition between two levels of your own data. It does not navigate for you: the chart has no idea your categories form a tree. You keep the hierarchy, you decide what the next level is, and you assign seriesdrill() only makes the swap read as a descent instead of a jump cut.

Descending, the incoming columns unfold out of the band the named column occupies right now; 'up' pivots on the whole plot, the same motion run inside out. It falls back to a plain swap when the index doesn’t resolve, or under prefers-reduced-motion.

const chart = document.querySelector('md-bar-chart');
chart.clickable = true; // hand cursor over a bar — the affordance
chart.addEventListener('mdBarClick', async (e) => {
const node = level[e.detail.dataIndex];
if (!node.children?.length) return; // a leaf — nothing to descend into
await chart.drill(e.detail.dataIndex); // animate, then hand over the new level
chart.series = [{ label: node.label, data: node.children.map((c) => c.value) }];
chart.xAxis = { data: node.children.map((c) => c.label) };
});

Set clickable only while a bar can actually drill — all it does is switch the cursor to a hand, so leaving it on over leaf columns promises an interaction that does nothing. (Clearing it doesn’t stop mdBarClick; the leaf guard above does.) Click a continent to descend, then use Back to come up again:

Click a bar to descend Open in Storybook
Back
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%;display:flex;flex-direction:column;gap:12px;">
  <div style="display:flex;align-items:center;gap:12px;">
    <md-button id="drill-up" variant="outlined" disabled>Back</md-button>
    <md-breadcrumbs id="drill-trail" separator="›" aria-label="Drill-down trail" style="min-block-size:32px;"></md-breadcrumbs>
  </div>
  <md-bar-chart id="drill-chart" legend="none" show-labels style="inline-size:100%;block-size:300px;"></md-bar-chart>
</div>

<script type="module">
  // The hierarchy is YOURS. The chart never walks it — drill() only animates
  // the swap between two levels you hand it.
  const TREE = [
    { label: 'Africa', value: 1460, children: [
      { label: 'Nigeria', value: 223 }, { label: 'Ethiopia', value: 126 },
      { label: 'Egypt', value: 112 },   { label: 'DR Congo', value: 102 },
      { label: 'Tanzania', value: 67 },
    ] },
    { label: 'Asia', value: 4753, children: [
      { label: 'China', value: 1425 }, { label: 'India', value: 1428 },
      { label: 'Indonesia', value: 277 }, { label: 'Pakistan', value: 240 },
      { label: 'Bangladesh', value: 173 },
    ] },
    { label: 'Europe', value: 748, children: [
      { label: 'Russia', value: 144 }, { label: 'Germany', value: 83 },
      { label: 'UK', value: 68 },      { label: 'France', value: 65 },
      { label: 'Italy', value: 59 },
    ] },
    { label: 'Americas', value: 1041, children: [
      { label: 'USA', value: 340 },   { label: 'Brazil', value: 216 },
      { label: 'Mexico', value: 128 }, { label: 'Colombia', value: 52 },
      { label: 'Argentina', value: 45 },
    ] },
    { label: 'Oceania', value: 45, children: [
      { label: 'Australia', value: 26 }, { label: 'Papua New Guinea', value: 10 },
      { label: 'New Zealand', value: 5 },
    ] },
  ];

  const chart = document.getElementById('drill-chart');
  const back  = document.getElementById('drill-up');
  const trail = document.getElementById('drill-trail');

  let level = TREE;
  let parentIndex = -1;

  function show(items, path) {
    chart.series = [{ label: 'Population (M)', color: 'primary', data: items.map((d) => d.value) }];
    chart.xAxis  = { data: items.map((d) => d.label) };
    // Only offer clicks while something below actually exists.
    chart.clickable = items.some((d) => d.children?.length);
    back.disabled = parentIndex < 0;
    // The LAST crumb is the level you are on, so it is marked current and is
    // not a link — a breadcrumb to where you already are is a dead control.
    trail.innerHTML = path
      .map((label, k) => '<md-breadcrumb-item href="#"' + (k === path.length - 1 ? ' current' : '') +
                         '>' + label + '</md-breadcrumb-item>')
      .join('');
  }

  // Crumbs navigate too: clicking "World" is the same move as Back.
  trail.addEventListener('click', (e) => {
    const crumb = e.target.closest?.('md-breadcrumb-item');
    if (!crumb || crumb.hasAttribute('current')) return;
    e.preventDefault();                       // href="#" would jump the page
    back.click();
  });

  chart.addEventListener('mdBarClick', async (e) => {
    const i = e.detail.dataIndex;
    const node = level[i];
    if (!node?.children?.length) return;      // a leaf — nothing to descend into
    await chart.drill(i, 'down');             // animate FROM the clicked column
    parentIndex = i;
    level = node.children;
    show(level, ['World', node.label]);
  });

  back.addEventListener('click', async () => {
    if (parentIndex < 0) return;
    await chart.drill(parentIndex, 'up');     // pivots on the whole plot
    parentIndex = -1;
    level = TREE;
    show(level, ['World']);
  });

  show(level, ['World']);
</script>

A bar race replays a ranking over time: reassign series on a timer and the engine tweens both the bar lengths and their reordering, so a category visibly overtakes another instead of teleporting.

The chart animates whatever you hand it — the loop is yours. Keep the category list stable across frames so a bar keeps its identity as it moves; a category that appears and disappears reads as a new bar each time.

let frame = 0;
const timer = setInterval(() => {
if (frame >= years.length) return clearInterval(timer);
chart.label = String(years[frame]);
chart.series = [{ label: 'Population', data: valuesFor(years[frame]) }];
frame++;
}, 600);
Press play — city populations, 1950 → 2020 Open in Storybook
2020
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="inline-size:100%;display:flex;flex-direction:column;gap:12px;">
  <div style="display:flex;align-items:center;gap:16px;min-inline-size:0;">
    <md-icon-button id="bar-race-play" variant="filled" icon="play_arrow" aria-label="Play or pause"></md-icon-button>
    <md-slider id="bar-race-slider" min="1950" max="2020" step="0.1" value="2020" style="flex:1; min-inline-size:0;" aria-label="Year"></md-slider>
    <span id="bar-race-year" style="font-size:1rem;font-weight:600;min-inline-size:5ch;text-align:end;">2020</span>
  </div>
  <md-bar-chart id="bar-race-chart" layout="horizontal" legend="none" show-labels no-animation style="inline-size:100%;min-inline-size:0;block-size:320px;"></md-bar-chart>
</div>

<script type="module">
  const CITIES = ['New York', 'Tokyo', 'Delhi', 'Shanghai', 'Mexico City', 'São Paulo'];
  const FRAMES = [
    { year: 1950, values: [12.3, 11.3, 1.4, 4.3, 3.4, 2.3] },
    { year: 1960, values: [14.2, 16.7, 2.3, 6.9, 5.4, 4.0] },
    { year: 1970, values: [16.2, 23.3, 3.5, 6.0, 8.8, 7.6] },
    { year: 1980, values: [15.6, 28.5, 5.6, 5.9, 13.0, 12.1] },
    { year: 1990, values: [16.1, 32.5, 9.4, 7.8, 15.6, 14.8] },
    { year: 2000, values: [17.8, 34.5, 15.7, 14.2, 18.5, 17.0] },
    { year: 2010, values: [18.4, 36.9, 21.9, 19.9, 20.1, 19.7] },
    { year: 2020, values: [18.8, 37.4, 30.3, 27.1, 21.8, 22.0] },
  ];

  const chart  = document.getElementById('bar-race-chart');
  const play   = document.getElementById('bar-race-play');
  const slider = document.getElementById('bar-race-slider');
  const yearEl = document.getElementById('bar-race-year');

  // The category list never changes, so a bar keeps its identity across frames
  // and the engine can tween the reorder instead of drawing a fresh chart.
  chart.xAxis = { data: CITIES };
  // Pin the value axis: letting it rescale every frame would make bars shrink
  // as the leader grows, which reads as the opposite of what happened.
  chart.yAxis = { min: 0, max: 40 };

  // Start on the LAST frame, not the first: a docs page that greets you with a
  // near-empty plot reads as broken.
  const FIRST = FRAMES[0].year;
  const LAST = FRAMES[FRAMES.length - 1].year;
  const STEP = 10;                              // years between samples
  const SPEED = (LAST - FIRST) / 9000;          // full sweep ≈ 9s

  // Start COMPLETE, not at 1950: a docs page that greets you with stub bars
  // reads as broken. Play rewinds and sweeps.
  let year = LAST, raf = 0, last = 0, dragging = false;

  // The data is decadal; the animation is not. Interpolating between the two
  // bracketing samples is what makes the bars glide instead of jumping once a
  // second — the chart's own tween can't do it, because every frame we hand it
  // is a finished value, which is also why the chart runs with no-animation.
  function valuesAt(y) {
    const t = (y - FIRST) / STEP;
    const i = Math.max(0, Math.min(FRAMES.length - 1, Math.floor(t)));
    const j = Math.min(FRAMES.length - 1, i + 1);
    const f = t - i;
    return FRAMES[i].values.map((v, k) => Math.round((v + (FRAMES[j].values[k] - v) * f) * 10) / 10);
  }

  function apply() {
    chart.series = [{ label: 'Population (M)', color: 'primary', data: valuesAt(year) }];
    yearEl.textContent = Math.round(year);
    if (!dragging) slider.value = year;          // don't fight a held thumb
  }

  function stop() {
    cancelAnimationFrame(raf);
    raf = 0;
    last = 0;
    play.icon = 'play_arrow';
  }

  function frame(ts) {
    if (!last) last = ts;
    year = Math.min(LAST, year + (ts - last) * SPEED);
    last = ts;
    apply();
    if (year >= LAST) return stop();
    raf = requestAnimationFrame(frame);
  }

  play.addEventListener('mdClick', () => {
    if (raf) return stop();
    if (year >= LAST) year = FIRST;              // parked at the end: play means replay
    play.icon = 'pause';
    last = 0;
    raf = requestAnimationFrame(frame);
  });

  // Scrubbing takes over: a running sweep would yank the thumb back.
  slider.addEventListener('mdInput', (e) => {
    dragging = true;
    stop();
    year = e.detail?.value ?? slider.value;
    apply();
  });
  slider.addEventListener('mdChange', () => { dragging = false; });

  apply();
</script>

zoom turns on two independent gestures: inside drags across the plot to select a range, slider adds a range band under the plot, both enables each. Zoom is a view — the window slices what the engine draws and leaves your series array untouched, so every event still reports absolute indices.

Long series with a zoom band Open in Storybook
Show code for each technology
<md-bar-chart zoom="both" legend="none"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Requests",
      data: [20, 36, 51, 66, 79, 70, 80, 87, 92, 94, 75, 73, 69, 63, 56, 28, 28, 43, 59, 73, 65, 76, 85, 92, 96, 78, 77, 74, 70, 64, 37, 29, 35, 51, 66, 59, 72, 82, 90, 96, 79, 80, 79, 76, 71, 45, 37, 29]
    }
  ];

  const xAxis = {
    data: ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8", "W9", "W10", "W11", "W12", "W13", "W14", "W15", "W16", "W17", "W18", "W19", "W20", "W21", "W22", "W23", "W24", "W25", "W26", "W27", "W28", "W29", "W30", "W31", "W32", "W33", "W34", "W35", "W36", "W37", "W38", "W39", "W40", "W41", "W42", "W43", "W44", "W45", "W46", "W47", "W48"],
    hideTicks: true
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>
await chart.setZoom(10, 40); // absolute indices
await chart.resetZoom();

loading covers the plot with an opaque overlay and marks the host aria-busy. It has to be opaque: the engine still draws a placeholder axis when there is no data, and a translucent loader would let that ghost grid read as real. An empty series shows the empty state instead — the two never stack, because “no data” is the wrong answer to “where is my data?” mid-fetch.

Default loader
Show code for each technology
<md-bar-chart label="Revenue by region" loading loading-label="Fetching revenue…"></md-bar-chart>

<script type="module">
  const series = [];

  const xAxis = {
    data: []
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

loading-label names the state for screen readers and prints under the spinner. The demos here hold loading on permanently; in practice you clear it when the fetch resolves:

const chart = document.querySelector('md-bar-chart');
chart.loading = true;
const rows = await fetch('/api/revenue').then((r) => r.json());
chart.series = rows;
chart.loading = false; // data first, then drop the overlay

Assign series before clearing loading, not after: the overlay is what hides the placeholder axis, so clearing it first flashes an empty grid between the spinner and the bars. On the way out the entry animation replays, so the bars grow in rather than appearing fully formed.

The default is a circular indicator plus the loading-label text. Slot your own into loader — the older name loading still works — to replace it entirely: a linear bar suits a wide tile, where a small circle floating in the middle reads as lost.

Slotted linear loader
Fetching revenue…
Show code for each technology
<md-bar-chart label="Revenue by region" loading><div slot="loading" style="inline-size: min(320px, 70%); display: grid; gap: 12px; justify-items: center;">
<md-progress-indicator variant="linear" indeterminate label="Fetching revenue" style="inline-size: 100%;"></md-progress-indicator>
<span>Fetching revenue…</span>
</div></md-bar-chart>

<script type="module">
  const series = [];

  const xAxis = {
    data: []
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

A circular md-progress-indicator in the loader slot keeps the default shape while letting you set its size, colour or label — the route to a branded mark or a skeleton is the same slot.

A circular md-progress-indicator slotted over the default loader
Show code for each technology
<md-bar-chart label="Revenue by region" loading loading-label="Fetching revenue…"><md-progress-indicator slot="loader" variant="circular" indeterminate size="40" label="Loading"></md-progress-indicator></md-bar-chart>

<script type="module">
  const series = [];

  const xAxis = {
    data: []
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

With no series at all, the chart paints an empty state instead of an axis with nothing under it. The trigger is strictly series.length === 0 (and not loading): hiding every series from the legend leaves series populated, so the plot stays up — empty, but not the empty state. Replace the message by slotting into empty, and style it with the --md-bar-chart-empty-* custom properties.

No data
Show code for each technology
<md-bar-chart label="Revenue by region" legend="none"></md-bar-chart>

<script type="module">
  const series = [];

  const xAxis = {
    data: []
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>
<md-bar-chart>
<div slot="empty">No revenue recorded for this period.</div>
</md-bar-chart>

The slot replaces the message, not the surface: a slotted empty state still sits on the chart’s own opaque background — the thing that masks the phantom 0–1 axis the engine draws with no data — and picks up the --md-bar-chart-empty-* type and colour. The slot is a centred column, so pass each line as its own element rather than one wrapper: a Material Symbols icon is auto-sized to --md-bar-chart-empty-icon-size by a ::slotted() rule, which only reaches direct children and would miss an icon nested inside a <div>.

A slotted empty state
No revenue recorded for this period. Pick a wider date range to see results.
Show code for each technology
<md-bar-chart label="Revenue by region" legend="none"><span class="material-symbols-outlined" slot="empty" aria-hidden="true">inbox</span>
<strong slot="empty">No revenue recorded for this period.</strong>
<span slot="empty" style="opacity:.7;">Pick a wider date range to see results.</span></md-bar-chart>

<script type="module">
  const series = [];

  const xAxis = {
    data: []
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

tooltip selects the trigger: axis (default) lists every series at the hovered category, item reports only the series under the pointer, none disables pointer hover — keyboard navigation and the live region keep working either way.

On a grouped chart item is often the better default: the bars are already separated, so the pointer really is on one of them, and listing the whole category buries it. On a stacked chart axis is usually right, since the segments only mean something relative to their column.

tooltipRenderer replaces the card’s content — the chart keeps owning the card itself: its surface, elevation, and placement. It’s a property, not an attribute, so it’s set in JS (or bound with .prop / :prop / [prop] in a framework).

Hover the chart below. The card leads with the column’s total, then gives each series its share of that column with a proportional bar — figures that only exist once you have every series at the hovered category, which is exactly what ctx.series is.

tooltipRenderer — column total and per-series share Open in Storybook
Show code for each technology
<md-bar-chart label="Sessions by device" stack="normal" tooltip="axis" legend="bottom"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Desktop",
      data: [24, 31, 28, 36]
    },
    {
      label: "Mobile",
      data: [38, 42, 47, 51]
    },
    {
      label: "Tablet",
      data: [9, 11, 10, 13]
    }
  ];

  const xAxis = {
    data: ["Q1", "Q2", "Q3", "Q4"]
  };

  const tooltipRenderer = (ctx) => {
    const el = (tag, css, text) => {
      const n = document.createElement(tag);
      if (css) n.style.cssText = css;
      if (text != null) n.textContent = text;
      return n;
    };

    const rows = ctx.series;
    const total = rows.reduce((sum, r) => sum + (r.value ?? 0), 0);
    // -1 means "nothing is emphasised", i.e. light every row — not "none".
    const lit = (r) => ctx.focusedSeriesIndex < 0 || r.focused;

    const card = el('div', 'min-width:196px;font-variant-numeric:tabular-nums');

    card.append(el('div',
      'font-size:0.68em;letter-spacing:0.1em;text-transform:uppercase;opacity:0.55',
      ctx.axisLabel));
    const head = el('div', 'display:flex;align-items:baseline;gap:5px;margin:1px 0 9px');
    head.append(el('span', 'font-size:1.5em;font-weight:700;line-height:1.05', total.toLocaleString()));
    head.append(el('span', 'font-size:0.76em;opacity:0.55', 'sessions'));
    card.append(head);

    rows.forEach((r) => {
      const pct = total ? (r.value ?? 0) / total : 0;
      const row = el('div',
        'display:grid;grid-template-columns:9px 1fr auto 38px;gap:9px;align-items:center;' +
        'padding:2.5px 0;opacity:' + (lit(r) ? '1' : '0.45'));
      row.append(el('span', 'width:9px;height:9px;border-radius:3px;background:' + r.color));
      row.append(el('span', null, r.label));
      row.append(el('span', 'font-weight:700', r.formattedValue));
      row.append(el('span', 'opacity:0.55;text-align:end', Math.round(pct * 100) + '%'));
      card.append(row);

      // A proportional rule under each row — the share, read as a length.
      const rule = el('div', 'display:grid;grid-template-columns:9px 1fr;gap:9px;margin-bottom:4px');
      rule.append(
        el('span', ''),
        el('div', 'height:3px;border-radius:999px;background:' + r.color +
                  ';width:' + (pct * 100).toFixed(1) + '%'),
      );
      card.append(rule);
    });

    return card;
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis, tooltipRenderer });
</script>

The context gives you dataIndex, axisValue, axisLabel, series (those with a value at this category), missing (those without) and focusedSeriesIndex. Return a Node, a string (set as text, so markup is shown rather than parsed), { unsafeHtml }, undefined to fall back to the built-in card, or null to draw none. See the area chart’s custom tooltip for the full field-by-field reference — the contract is identical across charts.

EventCancelableDetailFires
mdBarClicknoMdChartClickDetail<MdChartSeries>A bar is clicked, or activated from the keyboard
mdLegendClickno{ seriesIndex, seriesId?, selected }A legend chip toggles a series
mdHovernoMdChartHoverDetailThe hovered data index changes (rAF-throttled)
mdZoomno{ startIndex, endIndex, reset }The zoom window changes (drag, slider, setZoom, resetZoom)
mdReadynovoidThe engine has mounted and drawn

clickable does not gate mdBarClick. It is a cursor affordance only — it switches the pointer to a hand over a bar. The event fires on any click that lands inside a bar (and on Enter / Space) whether or not clickable is set, so set it whenever you listen, and gate the behaviour in your own handler rather than by clearing the prop.

MdChartClickDetail<MdChartSeries>mdBarClick

FieldTypeMeaning
seriesIndexnumber0-based index within the series array
seriesIdstring | undefinedThe series’ id, when one was given
dataIndexnumberIndex of the category — the value you pass to drill()
valuenumber | nullRaw value of the clicked bar — null for an empty slot
axisValuestring | number | Date | undefinedOriginal category value there
seriesMdChartSeriesThe original series object you passed in
nativeEventPointerEvent | MouseEvent | KeyboardEventA KeyboardEvent when the bar was activated from the keyboard rather than clicked

MdChartHoverDetailmdHover

FieldTypeMeaning
dataIndexnumberIndex of the highlighted category
axisValuestring | number | Date | undefinedCategory value there
seriesIndicesnumber[]Every visible series at that category — one entry per series the legend hasn’t hidden, whatever the tooltip trigger. It is not narrowed to the one series under the pointer

mdLegendClick carries { seriesIndex, seriesId?, selected }, and mdZoom carries { startIndex, endIndex, reset }absolute indices into your series array, since zoom is a view and never rewrites the data.

Every event is on the chart below. Click a bar (it is clickable), toggle a legend chip, hover the plot, drag the zoom slider — the readout names the event and the fields it carried. mdReady fires once, when the engine first draws, so it has already been and gone by the time you read this.

Every event, live — click a bar, toggle a legend chip, drag the zoom slider
mdHover — hover the plot
Click a bar, toggle a legend chip, or drag the zoom slider.
Show code for each technology
<md-bar-chart id="revenue" label="Revenue by region" legend="top-end" zoom="slider" clickable></md-bar-chart>

<script type="module">
const chart = document.getElementById('revenue');

chart.xAxis  = { data: ['North', 'South', 'East', 'West'] };
chart.series = [
  { label: '2025', color: 'primary', data: [12, 19, 7, 14] },
  { label: '2026', color: 'tertiary', data: [15, 17, 11, 16] },
];
chart.valueFormatter = (v) =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(v ?? 0);

chart.addEventListener('mdReady',       () => console.log('drawn'));
chart.addEventListener('mdBarClick',    (e) => console.log('bar', e.detail.seriesIndex, e.detail.dataIndex, e.detail.value));
chart.addEventListener('mdLegendClick', (e) => console.log('legend', e.detail.seriesIndex, e.detail.selected));
chart.addEventListener('mdHover',       (e) => console.log('hover', e.detail.dataIndex));
chart.addEventListener('mdZoom',        (e) => console.log('zoom', e.detail.startIndex, e.detail.endIndex));
</script>

Properties

PropertyAttributeTypeDefaultReflects
labellabelstring''
subtitlesubtitlestring | undefined
titleAligntitle-alignMdChartTitleAlign'start'
seriesJS onlyMdChartSeries[][]
xAxisJS onlyMdChartAxis | undefined
yAxisJS onlyMdChartAxis | undefined
yAxis2JS onlyMdChartAxis | undefined
layoutlayoutMdChartBarLayout'vertical'Yes
stackstackMdChartStackMode'none'
categoryGapcategory-gapstring | number'30%'
barGapbar-gapstring | number'20%'
barWidthbar-widthnumber
cornerRadiuscorner-radiusnumber6
legendlegendMdChartLegendPosition | 'none''top-end'Yes
tooltiptooltipMdChartTooltipTrigger'axis'
zoomzoom'none' | 'inside' | 'slider' | 'both''none'
localelocalestring''
valueFormatterJS only(value: number | null | undefined) => string
tooltipRendererJS onlyMdChartTooltipRenderer
heightPropheightstring
noAnimationno-animationbooleanfalse
animationanimationMdChartAnimation'expressive'
animationDurationanimation-durationnumber
showLabelsshow-labelsbooleanfalse
showTotalsshow-totalsbooleanfalse
chevronchevronbooleanfalse
polarpolarbooleanfalseYes
polarHolepolar-holenumber
polarSweeppolar-sweepnumber
axisTicksaxis-ticksbooleanfalse
clickableclickablebooleanfalse
labelZoomStartlabel-zoom-startstring'Zoom range start'
labelZoomEndlabel-zoom-endstring'Zoom range end'
labelPlotlabel-plotstring'Chart data. Use the arrow keys to move between points, Home and End for the first and last, Escape to leave.'
labelPointlabel-pointstring'%x%: %values%'
loadingloadingbooleanfalseYes
loadingLabelloading-labelstring'Loading chart…'
densitydensity0 | -1 | -2 | -3 | -40Yes

Methods

MethodParameters
refreshTheme()none
resize()none
replay()none
drill()index: number, direction: 'down' | 'up' = 'down'
toDataURL()none
getInstance()none
setZoom()startIndex: number, endIndex: number
resetZoom()none

Slots

SlotDescription
headerContent for the header row, replacing the default title block
emptyReplaces the empty-state message when there is no data
loader
loading
footerContent for the footer row below the plot

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-bar-chart-block-sizeExplicit chart height, overriding the aspect ratio (default: auto)
--md-bar-chart-min-block-sizeFloor the chart height never drops below (density-aware)
--md-bar-chart-aspect-ratioWidth:height ratio used when no block-size is set (default: 16 / 9)
--md-bar-chart-backgroundChart surface fill (default: surface-container-low)
--md-bar-chart-paddingInset between the host edge and the plot canvas (density-aware)
--md-bar-chart-shapeCorner radius of the chart surface (density-aware)
--md-bar-chart-empty-colorEmpty-state text colour (default: on-surface-variant)
--md-bar-chart-empty-backgroundEmpty-state overlay fill (defaults to the chart background)
--md-bar-chart-empty-fontEmpty-state font family (default: body-medium)
--md-bar-chart-empty-font-sizeEmpty-state font size (default: body-medium, 14px)
--md-bar-chart-empty-icon-sizeSize of an icon slotted into the empty state (default: 40px)
--md-bar-chart-zoom-sizeHeight reserved for the zoom slider (default: 28px)
--md-bar-chart-zoom-track-colorZoom slider track colour
--md-bar-chart-zoom-window-colorZoom slider selected-window colour
--md-bar-chart-zoom-handle-colorZoom slider thumb + drag-band edge colour
--md-bar-chart-zoom-band-colorDrag-to-zoom selection fill

CSS Shadow Parts

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

PartDescription
zoomWrapper around the zoom slider
zoom-sliderThe zoom range slider itself
zoom-panGrip that drags the whole zoom window along the track
headerHeader row above the plot (hosts the `header` slot)
canvasThe plot surface the chart draws into
emptyEmpty-state overlay, shown when there is no data
loading
zoom-bandLive selection band drawn while dragging across the plot
footerFooter row below the plot (hosts the `footer` slot)
zoom-trackZoom slider's inactive track (forwarded from md-slider)
zoom-windowZoom slider's selected window (forwarded from md-slider)
zoom-handleZoom slider's thumbs (forwarded from md-slider)
  • The canvas is opaque to assistive tech. label, label-plot and label-point build the accessible description and the per-point announcements — they are the chart’s accessibility, not decoration.
  • Provide the same data as a table for anything users must read precisely; the chart is a summary view.
  • Don’t rely on colour alone to distinguish series — the legend, direct labels (show-labels) and a meaningful order all help.
  • Honour prefers-reduced-motion with no-animation (or animation="none").
  • Zoom controls are md-sliders and need label-zoom-start / label-zoom-end translated.

The host is role="figure". Its accessible name is a summary the component generates — label, then the chart type, series count and category range, e.g. “Revenue by region, Bar chart, with 2 series, from Q1 to Q4.” — so label is the first fragment of what a screen reader reads, not the whole of it. It also carries a screen-reader-only data table of the same numbers. The plot itself is a single tab stop (role="application", tabindex="0") whose accessible name is label-plot — the instructions. Once inside, / walk the categories, Home / End jump to the ends, Enter or Space activates the focused bar (the same mdBarClick a mouse fires), and Escape leaves the data without leaving the page. Each move is spoken through a polite live region, formatted by label-point.

Tab into the plot, then walk it with the arrow keys
Show code for each technology
<md-bar-chart label="Revenue by region" clickable show-labels legend="bottom" label-point="%x% — %values%" label-plot="Revenue by region. Arrow keys move between regions, Home and End jump to the ends, Enter opens a region, Escape leaves."></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "2025",
      color: "primary",
      data: [12, 19, 7, 14]
    },
    {
      label: "2026",
      color: "tertiary",
      data: [15, 17, 11, 16]
    }
  ];

  const xAxis = {
    data: ["North", "South", "East", "West"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

Under dir="rtl" the engine mirrors the finished frame about the canvas’ centre: the value axis and its gutter move to the right, the category axis runs right-to-left, the title and labels anchor from the other side, and a top-end legend resolves to the other physical corner. Keyboard arrows follow the reading direction too — advances in RTL.

<div dir="rtl">
<md-bar-chart label="الإيرادات حسب المنطقة" locale="ar-EG"></md-bar-chart>
</div>
Same markup, dir=ltr vs dir=rtl
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>
  <md-bar-chart id="dir-ltr" dir="ltr" label="Revenue by region" legend="top-end" style="inline-size:100%;block-size:220px;"></md-bar-chart>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">rtl</span>
  <md-bar-chart id="dir-rtl" dir="rtl" label="Revenue by region" legend="top-end" style="inline-size:100%;block-size:220px;"></md-bar-chart>
</div>

<script type="module">
  const fixture = {
  xAxis: { data: ['North', 'South', 'East', 'West'] },
  series: [{ label: 'Revenue', color: 'primary', data: [12, 19, 7, 14] }],
  };
  for (const id of ['#dir-ltr', '#dir-rtl']) {
  const chart = document.querySelector(id);
  if (chart) Object.assign(chart, fixture);
  }
</script>

Text is repositioned, never reversed — the glyphs themselves stay the browser’s business. So an RTL chart whose values still read 1,234 is not a mirroring bug: nothing has told it which numbering system to use. locale does, for the axis ticks, the data labels and the tooltip alike, because they all fall back to toLocaleString(locale).

dir=rtl with locale=ar-EG vs dir=rtl alone
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>
  <md-bar-chart id="loc-right" dir="rtl" locale="ar-EG" label="الإيرادات حسب المنطقة" legend="none" show-labels style="inline-size:100%;block-size:220px;"></md-bar-chart>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">wrong</span>
  <md-bar-chart id="loc-wrong" dir="rtl" label="الإيرادات حسب المنطقة" legend="none" show-labels style="inline-size:100%;block-size:220px;"></md-bar-chart>
</div>

<script type="module">
  const fixture = {
  xAxis: { data: ['الشمال', 'الجنوب', 'الشرق', 'الغرب'] },
  series: [{ label: 'الإيرادات', color: 'primary', data: [1240, 1930, 760, 1410] }],
  };
  for (const id of ['#loc-right', '#loc-wrong']) {
  const chart = document.querySelector(id);
  if (chart) Object.assign(chart, fixture);
  }
</script>

density="-1…-4" drives the same --md-sys-density-scale signal a global data-density ancestor sets, so a local value simply overrides the inherited one. It tightens the chart’s padding, its corner radius and the floor its height never drops below — the plot keeps its proportions while the chrome around it gives ground.

Density 0 through -4 — padding, shape and the height floor all taper
0 -1 -2 -3 -4
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-bar-chart class="rung" density="0" legend="none" show-labels style="inline-size:100%;block-size:150px;"></md-bar-chart>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">-1</span>
  <md-bar-chart class="rung" density="-1" legend="none" show-labels style="inline-size:100%;block-size:150px;"></md-bar-chart>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">-2</span>
  <md-bar-chart class="rung" density="-2" legend="none" show-labels style="inline-size:100%;block-size:150px;"></md-bar-chart>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">-3</span>
  <md-bar-chart class="rung" density="-3" legend="none" show-labels style="inline-size:100%;block-size:150px;"></md-bar-chart>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">-4</span>
  <md-bar-chart class="rung" density="-4" legend="none" show-labels style="inline-size:100%;block-size:150px;"></md-bar-chart>
</div>

<script type="module">
  const fixture = {
  xAxis: { data: ['Q1', 'Q2', 'Q3', 'Q4'] },
  series: [{ label: 'Revenue', color: 'primary', data: [12, 19, 7, 14] }],
  };
  for (const chart of document.querySelectorAll('.rung')) Object.assign(chart, fixture);
</script>

The two are independent signals: dir decides which way the frame is mirrored, density how tight it is. A compact RTL dashboard tile sets both.

dir=rtl at density 0 and -4
rtl / 0 rtl / -4
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;" dir="rtl">
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">rtl / 0</span>
  <md-bar-chart class="rtl-rung" density="0" locale="ar-EG" label="الإيرادات" legend="none" show-labels style="inline-size:100%;block-size:170px;"></md-bar-chart>
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">rtl / -4</span>
  <md-bar-chart class="rtl-rung" density="-4" locale="ar-EG" label="الإيرادات" legend="none" show-labels style="inline-size:100%;block-size:170px;"></md-bar-chart>
</div>

<script type="module">
  const fixture = {
  xAxis: { data: ['الشمال', 'الجنوب', 'الشرق', 'الغرب'] },
  series: [{ label: 'الإيرادات', color: 'primary', data: [1240, 1930, 760, 1410] }],
  };
  for (const chart of document.querySelectorAll('.rtl-rung')) Object.assign(chart, fixture);
</script>

Density — the rung is inherited from any data-density ancestor unless the chart sets its own. See Density.

i18n — set locale and use Intl inside valueFormatter. Translate label, subtitle, label-plot, label-point and the zoom labels (label-zoom-start / label-zoom-end). label-point uses the %x% / %values% percent tokens — keep them when translating; they interpolate the values. See RTL.

Custom propertyPurposeDefault
--md-bar-chart-block-sizeExplicit height, overriding the aspect ratioauto
--md-bar-chart-min-block-sizeFloor the height never drops belowmax(120px, 160px + density × 8px)
--md-bar-chart-aspect-ratioWidth : height when no block-size is set16 / 9
--md-bar-chart-backgroundChart surface fillsurface-container-low
--md-bar-chart-paddingInset between the host edge and the plotmax(8px, 16px + density × 2px)
--md-bar-chart-shapeCorner radius of the surfacemax(8px, 16px + density × 2px)
--md-bar-chart-empty-colorEmpty-state text colouron-surface-variant
--md-bar-chart-empty-backgroundEmpty-state overlay fillthe chart background
--md-bar-chart-empty-font / -empty-font-sizeEmpty-state typebody-medium / 14px
--md-bar-chart-empty-icon-sizeIcon slotted into the empty state40px
--md-bar-chart-zoom-sizeHeight reserved for the zoom slider28px
--md-bar-chart-zoom-track-colorZoom slider tracksurface-container-highest
--md-bar-chart-zoom-window-colorSelected window in the zoom slidersecondary-container
--md-bar-chart-zoom-handle-colorZoom thumb and drag-band edgeprimary
--md-bar-chart-zoom-band-colorDrag-to-zoom selection fillprimary at 16%
md-bar-chart.dashboard {
--md-bar-chart-background: var(--md-sys-color-surface-container);
--md-bar-chart-padding: 16px;
--md-bar-chart-shape: 16px;
--md-bar-chart-aspect-ratio: 16 / 7;
}
Custom surface via CSS custom properties Open in Storybook
Show code for each technology
<md-bar-chart label="Themed surface" legend="bottom" corner-radius="8" style="--md-bar-chart-background: var(--md-sys-color-surface-container-highest); --md-bar-chart-shape: 20px; --md-bar-chart-padding: 20px;"></md-bar-chart>

<script type="module">
  const series = [
    {
      label: "Desktop",
      color: "primary",
      data: [120, 200, 150, 80]
    },
    {
      label: "Mobile",
      color: "tertiary",
      data: [220, 182, 191, 234]
    }
  ];

  const xAxis = {
    data: ["Q1", "Q2", "Q3", "Q4"]
  };

  const el = document.querySelector("md-bar-chart");
  Object.assign(el, { series, xAxis });
</script>

CSS partsheader, canvas, footer, empty, loading, zoom, zoom-slider, zoom-pan, zoom-track, zoom-window, zoom-handle, zoom-band, plus three the engine builds at runtime inside canvas: plot-canvas (the <canvas> element itself), legend (the chip strip) and tooltip. Slotsheader, footer, empty, loader (and its older name, loading).

Parts are the hook for the boxes the custom properties don’t reach. canvas is the plot surface itself — an absolutely positioned box inset by --md-bar-chart-padding — and zoom the band reserved under it:

md-bar-chart.framed::part(canvas) {
outline: 1px dashed var(--md-sys-color-outline-variant);
outline-offset: 4px;
border-radius: 8px;
}
::part(canvas) — the plot box, outlined
Show code for each technology
<style>
md-bar-chart.framed::part(canvas) {
  outline: 1px dashed var(--md-sys-color-outline-variant);
  outline-offset: 4px;
  border-radius: 8px;
}
</style>

<md-bar-chart id="framed" class="framed" label="Sessions by device" legend="bottom"></md-bar-chart>

<script type="module">
Object.assign(document.getElementById('framed'), {
  xAxis: { data: ['Q1', 'Q2', 'Q3', 'Q4'] },
  series: [
    { label: 'Desktop', color: 'primary', data: [120, 200, 150, 80] },
    { label: 'Mobile', color: 'tertiary', data: [220, 182, 191, 234] },
  ],
});
</script>

The chart reads its colours from the MD3 token layer, so it follows the page’s light / dark mode with no per-chart configuration — series given a role name (primary, tertiary, …) re-resolve on theme change, while a hardcoded hex does not. See the dark theme story for the same chart under both schemes.

md-line-chart · md-area-chart · md-pie-chart · md-sparkline · md-slider

For AI Agents — md-bar-chart

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-bar-chart 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-bar-chart readme.md

# md-bar-chart

<!-- llm:meta
tag: md-bar-chart
category: charts
status: custom
m3-guidelines: none — M3 has no chart components
form-associated: false
depends-on: md-slider, md-progress-indicator
used-by: none
engine: in-house Canvas2D + DOM overlay (utils/charts/engine)
-->

**Compare values across categories.** Vertical or horizontal bars, grouped or
stacked, with legend, tooltips, zoom, drill-down, a polar (radial) mode and an
expressive entrance animation.

> ⚠️ **Not a Material Design 3 component.** M3 ships no charts. The rendering
> engine is **in-house Canvas2D with a DOM overlay** (`utils/charts/engine`) —
> not a third-party charting library, whatever the dependency tree suggests.

> Setup, theming, density and i18n are configured once for the whole library —
> see [`main-llm.md`](../../../../../main-llm.md), the library-wide guide that ships
> alongside these component docs.

---

## When to use

- **Comparing magnitudes across categories**: revenue by region, count by
  status.
- Part-to-whole across categories (`stack="normal"` / `"percentage"`).
- Ranked comparisons with long category names (`layout="horizontal"`).

## When NOT to use

| Situation | Use instead |
|---|---|
| A trend over time | `md-line-chart` |
| Trend plus cumulative volume | `md-area-chart` |
| Parts of a single whole (≤ ~6) | `md-pie-chart` |
| An inline micro-trend | `md-sparkline` |
| Two or three numbers | Plain text |
| A hierarchy of nodes | `md-organization-chart` |

## Decision cues

| Need | Setting |
|---|---|
| Long category labels | `layout="horizontal"` |
| Compare composition | `stack="normal"` |
| Compare share | `stack="percentage"` |
| Values on the bars | `show-labels` |
| Stack totals past the column end | `show-totals` (stacked only) |
| Explore a long series | `zoom="inside\|slider\|both"` |
| Radial bars | `polar` (+ `polar-hole`, `polar-sweep`) |
| A second value scale | `yAxis2` (JS property) + `series[].yAxisIndex = 1` |
| One colour per bar, not per series | `series[].pointColors` (JS property) |
| Column width carries a second variable | `series[].pointWidths` (JS property) |
| Click-through | `clickable` (+ `mdBarClick`) |
| Data still loading | `loading` (+ `loading-label`) |
| Reduced motion | `no-animation`, or `animation="none"` |

## API contract

```html
<md-bar-chart
  label="Revenue by region"
  subtitle="FY26"
  title-align="start|center|end"                 <!-- default: start -->
  layout="vertical|horizontal"                   <!-- default: vertical -->
  stack="none|normal|percentage"                 <!-- default: none -->
  category-gap="30%"                             <!-- default: 30% -->
  bar-gap="20%"                                  <!-- default: 20% -->
  bar-width="24"                                 <!-- default: auto (fills the slot) -->
  corner-radius="6"                              <!-- default: 6 -->
  legend="top|bottom|left|right|top-start|top-end|bottom-start|bottom-end|none"
                                                 <!-- default: top-end -->
  tooltip="axis|item|none"                       <!-- default: axis; only "none" differs — see below -->
  zoom="none|inside|slider|both"                 <!-- default: none -->
  show-labels                                    <!-- default: off -->
  show-totals                                    <!-- default: off -->
  axis-ticks                                     <!-- default: off -->
  chevron                                        <!-- default: off -->
  clickable                                      <!-- default: off -->
  polar                                          <!-- default: off -->
  polar-hole="0.22"                              <!-- default: 0.22, clamped 0–0.9 -->
  polar-sweep="0.75"                             <!-- default: 0.75, clamped 0.1–1 -->
  locale="en-US"                                 <!-- default: "" (browser locale) -->
  height="320px"                                 <!-- default: aspect-ratio driven -->
  animation="expressive|grow|fade|draw|stagger|none"   <!-- default: expressive -->
  animation-duration="700"                       <!-- default: engine default -->
  no-animation                                   <!-- default: off -->
  loading                                        <!-- default: off -->
  loading-label="Loading chart…"
  label-plot="Chart data. Use the arrow keys to move between points, Home and End for the first and last, Escape to leave."
  label-point="%x%: %values%"
  label-zoom-start="Zoom range start"
  label-zoom-end="Zoom range end"
  density="-1|-2|-3|-4"                          <!-- default: 0 (uncompacted) -->
></md-bar-chart>
```

Objects, arrays and functions have no attribute form — set them as JS
properties:

```js
const chart = document.querySelector('md-bar-chart');

chart.series = [{ label: 'Revenue', data: [12, 19, 7] }];   // MdChartSeries[]
chart.xAxis  = { data: ['North', 'South', 'East'] };        // category axis
chart.yAxis  = { label: 'USD (m)', min: 0 };                // value axis
chart.yAxis2 = { label: 'Rate', position: 'right' };        // second value axis
chart.valueFormatter = (v) =>
  new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(v ?? 0);
chart.tooltipRenderer = (ctx) => `${ctx.axisLabel}: ${ctx.series.length} series`;
```

**Events** — `mdBarClick` (`MdChartClickDetail`), `mdLegendClick`
(`{ seriesIndex, seriesId?, selected }`), `mdHover` (`MdChartHoverDetail`,
throttled to one per frame), `mdZoom` (`{ startIndex, endIndex, reset }`),
`mdReady` (fires once the first frame has drawn). All are the Stencil default —
they bubble and cross shadow boundaries.

**Methods** — `resize()`, `replay()`, `drill(index, direction?)`,
`toDataURL()`, `getInstance()`, `setZoom(startIndex, endIndex)`, `resetZoom()`.
All are async.

**Slots** — `header` (above the plot), `footer` (below it), `empty` (replaces
the built-in "No data to display"), and `loader` (replaces the built-in
spinner; `loading` is the older alias for the same slot — `loader` wins if both
are filled).

**Parts** — `header`, `canvas`, `empty`, `loading`, `footer`, `zoom`,
`zoom-slider`, `zoom-pan`, `zoom-band`, plus `zoom-track` / `zoom-window` /
`zoom-handle` forwarded from the inner `md-slider`. The engine additionally
marks the `<canvas>` as `plot-canvas` and the DOM overlay's legend and hover
card as `legend` and `tooltip`.

### Behavioral contract worth knowing

- **`series`, `xAxis`, `yAxis`, `yAxis2`, `valueFormatter` and
  `tooltipRenderer` are JS properties** — objects and functions never cross the
  attribute boundary.
- **Prop changes are batched.** A burst of assignments (`series` + `xAxis` +
  `yAxis` in one go) rebuilds and repaints once, on the next microtask, not once
  per property.
- **`bar-width` is auto by default** — the bar fills its slot (the band minus
  the category/bar gaps). A fixed width is centred in the slot and clamped to
  it, so it can never overrun a neighbour.
- **`show-totals` is stacked-only** — an unstacked bar already is its total.
- **`tooltip` is a two-state switch here.** `none` turns the hover card off;
  `axis` and `item` both render the axis-style card, because this component
  never forwards the trigger to the engine. (`md-line-chart` and
  `md-area-chart` *do* — there `item` gives a single-series card.)
- **Zoom is a view, not a mutation.** `series` / `xAxis` are never rewritten;
  the engine is fed the sliced window and every index reported by `mdHover`,
  `mdBarClick` and the keyboard cursor is rebased to your **absolute** data
  index. `setZoom()` takes absolute **indices**, not fractions. Zoom gestures
  are ignored while `polar` is set (a ring has no linear span). For a
  *value*-range zoom set `yAxis.min` / `yAxis.max` instead.
- **Legend toggles survive a data re-feed.** A series hidden by the reader stays
  hidden when `series` is reassigned, keyed by `id`, else `label`, else
  position. An explicit `series[i].hidden` still wins.
- **`drill()` must be awaited before the swap.** Like every Stencil `@Method` it
  resolves through a microtask, so a bare call lands *after* an assignment on the
  next line and the swap renders un-animated:
  `await chart.drill(i); chart.series = next;`
- **`loading` covers the plot** with the loader and sets `aria-busy="true"`;
  clearing it replays the entry animation so the arriving data draws itself in.
- `mdReady` fires when the chart has drawn — wait for it before `toDataURL()`.
- `getInstance()` returns the underlying engine. It is an escape hatch; anything
  done through it is outside this component's contract.
- The plot is focusable (`role="application"`) so the arrow keys walk the data;
  `label-plot` is what announces that, and `label-point` templates each move.
  Both **`%x%` and `%values%`** must survive translation.
- Charts re-read the theme tokens and repaint on their own when
  `prefers-color-scheme` changes. Any *other* theme swap (a manual
  light/dark class, a brand-token change) needs a nudge: reassign `series`.
  `resize()` will not do it — it returns early when the box has not changed.

---

## Do / Don't

House rules — M3 ships no chart component, so the guidance below is this
library's own.

| ✅ Do | ❌ Don't |
|---|---|
| Start the value axis at zero | Don't truncate the axis — bar length must be proportional |
| Use `layout="horizontal"` for long category names | Don't rotate labels to 90° to make them fit |
| Keep the category count readable | Don't render 200 bars — aggregate, or add `zoom` |
| Use `stack="percentage"` when share matters | Don't stack when readers need absolute per-series values |
| Set `valueFormatter` with units | Don't show bare numbers where currency or percent is meant |
| Provide a data table alternative | Don't make the chart the only access to the numbers |
| Honour reduced motion | Don't force the entrance animation on everyone |
| Order categories meaningfully (by value or natural order) | Don't leave an arbitrary order |
| Keep the series count small | Don't put eight series in a grouped bar chart |

---

## Patterns

```html
<md-bar-chart id="c" label="Revenue by region" height="320px" legend="top-end"></md-bar-chart>

<script type="module">
  const c = document.getElementById('c');
  c.xAxis  = { data: ['North', 'South', 'East', 'West'] };
  c.series = [
    { label: '2025', data: [12, 19, 7, 14] },
    { label: '2026', data: [15, 17, 11, 16] },
  ];
  c.valueFormatter = (v) =>
    new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(v ?? 0);

  c.addEventListener('mdReady', () => console.log('drawn'));
</script>
```

```html
<!-- Drill down into a clicked column -->
<md-bar-chart id="d" clickable label="Sales by region"></md-bar-chart>

<script type="module">
  const d = document.getElementById('d');
  d.xAxis  = { data: ['North', 'South', 'East'] };
  d.series = [{ label: 'Sales', data: [120, 90, 140] }];

  d.addEventListener('mdBarClick', async (e) => {
    await d.drill(e.detail.dataIndex);          // await, THEN swap
    d.xAxis  = { data: ['Q1', 'Q2', 'Q3', 'Q4'] };
    d.series = [{ label: 'Sales', data: [30, 35, 25, 30] }];
  });
</script>
```

```html
<!-- Horizontal, stacked share, values printed on the bars -->
<md-bar-chart layout="horizontal" stack="percentage" show-labels></md-bar-chart>

<!-- Long series with a zoom band, localised thumb labels -->
<md-bar-chart zoom="slider" label-zoom-start="Début" label-zoom-end="Fin"></md-bar-chart>

<!-- Radial bars -->
<md-bar-chart polar polar-hole="0.3" polar-sweep="0.8"></md-bar-chart>

<!-- Async data -->
<md-bar-chart id="async" loading loading-label="Loading revenue…"></md-bar-chart>
<script type="module">
  const el = document.getElementById('async');
  const rows = await fetch('/api/revenue').then((r) => r.json());
  el.xAxis  = { data: rows.map((r) => r.region) };
  el.series = [{ label: 'Revenue', data: rows.map((r) => r.value) }];
  el.loading = false;                            // entry animation replays here
</script>

<!-- Resize after revealing a hidden container -->
<script type="module">
  document.getElementById('tabs')
    .addEventListener('mdTabChange', () => document.getElementById('c').resize());
</script>
```

## Anti-patterns

| ❌ Wrong | ✅ Right | Why |
|---|---|---|
| `<md-bar-chart series='[…]'>` | `chart.series = [...]` | Arrays don't cross the attribute boundary. |
| `value-formatter` as an attribute | `chart.valueFormatter = fn` | Function prop — no attribute form. |
| `stack="percent"` | `stack="percentage"` | `percent` is not a value the prop accepts. |
| `tooltip="item"` to get a single-series card | Nothing — bar charts only honour `none` | The trigger isn't forwarded here; `item` renders the same axis card as the default. |
| `chart.setZoom(0.6, 1)` | `chart.setZoom(24, 40)` | Zoom takes absolute data indices, not fractions. |
| `chart.drill(i); chart.series = next;` | `await chart.drill(i)` first | The method resolves a microtask later, so the swap renders un-animated. |
| Rebasing `mdBarClick.dataIndex` for the zoom window | Use it as-is | Indices are already absolute. |
| `show-totals` on an unstacked chart | Use `show-labels` | An unstacked bar is its own total. |
| `zoom="inside"` together with `polar` | Drop one | Zoom gestures are ignored on a polar chart. |
| `toDataURL()` immediately after setting data | Wait for `mdReady` | Nothing has drawn yet. |
| A non-zero baseline | Start at zero | Bar length encodes magnitude. |
| 200 bars | Aggregate, or add `zoom` | Unreadable. |
| Stacked bars when per-series values must be compared | Grouped bars | Stacks hide individual magnitudes. |
| Chart in a hidden container with no `resize()` | Call it on reveal | It renders at zero size. |
| Translating `label-point` and dropping `%x%` | Keep the tokens | They interpolate the values. |
| Driving layout through `getInstance()` | Use props | Outside the contract; it will break. |

## Accessibility, RTL, density, i18n

**Accessibility**
- The host is `role="figure"` with a generated `aria-label` summary, and the
  plot is a focusable `role="application"` region whose name is `label-plot`.
  Arrow keys move a keyboard cursor, Home/End jump to the ends, Escape leaves;
  each move is announced through a polite live region built from `label-point`.
- A screen-reader-only data table of the series is rendered inside the
  component, so the numbers are reachable without the canvas.
- **Provide the same data as a visible table** for anything users must read
  precisely; the chart is a summary view.
- Don't rely on colour alone to distinguish series — the legend, direct labels
  (`show-labels`) and ordering all help.
- `loading` sets `aria-busy="true"` and names the overlay with `loading-label`.
- Honour `prefers-reduced-motion` (which the engine already respects) and offer
  `no-animation` where you need it off outright.
- The zoom slider is an `md-slider`; its thumbs need `label-zoom-start` /
  `label-zoom-end` translated.

**RTL** — the finished frame is mirrored under `dir="rtl"`: the value axis and
its gutter move to the right, the categories run right-to-left, and the legend's
**logical** anchors (`top-start`, `top-end`, `bottom-start`, `bottom-end`) swap
sides. `legend="left"` and `legend="right"` are physical by name and do **not**
swap — they stay on the side they name. Glyphs are left to the browser, never
reversed.

**Density** — `density="-1"` through `density="-4"` tighten the padding, the
corner radius and the minimum height. Rung `0` is the uncompacted default and
has no rule of its own. To step *out* of an inherited `data-density` rung, set
`style="--md-sys-density-scale: 0"` on the chart — `density="0"` will not do it.

**i18n** — set `locale` for the default number/date formatting, or take it over
entirely with `valueFormatter` / `xAxis.valueFormatter`, which always win.
Translate `label`, `subtitle`, `loading-label`, `label-plot`, `label-point`
(keeping `%x%` / `%values%`), and the zoom labels.

## Related components

`md-line-chart` · `md-area-chart` · `md-pie-chart` · `md-sparkline` ·
`md-organization-chart` · `md-slider` · `md-progress-indicator`

## Theming

| Custom property | Purpose | Default |
|---|---|---|
| `--md-bar-chart-block-size` | Explicit chart height | `auto` |
| `--md-bar-chart-min-block-size` | Floor the height never drops below | `max(120px, 160px + density × 8px)` |
| `--md-bar-chart-aspect-ratio` | Ratio used when no block-size is set | `16 / 9` |
| `--md-bar-chart-background` | Chart surface fill | `--md-sys-color-surface-container-low` |
| `--md-bar-chart-padding` | Inset between host edge and canvas | `max(8px, 16px + density × 2px)` |
| `--md-bar-chart-shape` | Corner radius of the chart surface | `max(8px, 16px + density × 2px)` |
| `--md-bar-chart-empty-color` | Empty-state text colour | `--md-sys-color-on-surface-variant` |
| `--md-bar-chart-empty-background` | Empty-state overlay fill | the chart background |
| `--md-bar-chart-empty-font` | Empty-state font family | body-medium family |
| `--md-bar-chart-empty-font-size` | Empty-state font size | body-medium size (14px) |
| `--md-bar-chart-empty-icon-size` | Icon slotted into the empty state | `40px` |
| `--md-bar-chart-zoom-size` | Height reserved for the zoom slider | `28px` |
| `--md-bar-chart-zoom-track-color` | Zoom slider track | `--md-sys-color-surface-container-highest` |
| `--md-bar-chart-zoom-window-color` | Zoom slider selected window | `--md-sys-color-secondary-container` |
| `--md-bar-chart-zoom-handle-color` | Zoom thumb + drag-band edge | `--md-sys-color-primary` |
| `--md-bar-chart-zoom-band-color` | Drag-to-zoom selection fill | 16% `--md-sys-color-primary` |

Series colours come from the MD3 palette, not from these properties: set
`series[].color` to an MD3 role (`'primary'`, `'tertiary'`, `'success'`, …) or
any CSS colour, and it re-themes with the tokens.

**CSS parts** — `header`, `canvas`, `empty`, `loading`, `footer`, `zoom`,
`zoom-slider`, `zoom-pan`, `zoom-band`, `zoom-track`, `zoom-window`,
`zoom-handle`, plus the engine-set `plot-canvas`, `legend` and `tooltip`.

```css
md-bar-chart {
  --md-bar-chart-background: transparent;
  --md-bar-chart-aspect-ratio: 21 / 9;
}
md-bar-chart::part(tooltip) {
  border-radius: 8px;
}
```

<!-- Auto Generated Below -->


## Overview

md-bar-chart — Material Design 3 Expressive bar chart, rendered
by the in-house engine (Canvas2D + a DOM text overlay). No
ECharts.

  • vertical (default) and horizontal layouts
  • grouped / stacked / percentage modes
  • category + bar gap configuration
  • rounded outer corners (top for vertical, end for horizontal)
  • legend toggle, hover tooltip, click events
  • role="figure" + screen-reader-only data table

## Properties

| Property            | Attribute            | Description                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | Type                                                                                                             | Default                                                                                                          |
| ------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `animation`         | `animation`          | Entry-animation variant: `expressive` (default), `grow`, `fade`, `draw`, or `none`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | `"draw" \| "expressive" \| "fade" \| "grow" \| "none" \| "stagger"`                                              | `'expressive'`                                                                                                   |
| `animationDuration` | `animation-duration` | Entry-animation duration override in ms (≤ 0 disables).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | `number \| undefined`                                                                                            | `undefined`                                                                                                      |
| `axisTicks`         | `axis-ticks`         | Draw small perpendicular tick marks on the axes.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | `boolean`                                                                                                        | `false`                                                                                                          |
| `barGap`            | `bar-gap`            | Bar gap between sibling grouped bars (`"20%"` or `20`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | `number \| string`                                                                                               | `'20%'`                                                                                                          |
| `barWidth`          | `bar-width`          | Fixed bar WIDTH in px, centred in each slot. Default: auto — the bar fills its slot (the band minus the category/bar gaps). Clamped to the slot so it can't overrun a neighbour.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                | `number \| undefined`                                                                                            | `undefined`                                                                                                      |
| `categoryGap`       | `category-gap`       | Category gap as a percentage of the band width (`"30%"` or `30`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | `number \| string`                                                                                               | `'30%'`                                                                                                          |
| `chevron`           | `chevron`            | Draw the bars as CHEVRONS pointing away from the baseline instead of as rectangles. Along a stack they interlock, each segment's point sitting in the next one's notch — a force diagram, where the contributions are handed along in a direction rather than merely piled up.                                                                                                                                                                                                                                                                                                                                                                                                  | `boolean`                                                                                                        | `false`                                                                                                          |
| `clickable`         | `clickable`          | Show a hand (`pointer`) cursor over the bars, signalling they're actionable — e.g. clickable to drill into. The chart can't know an interaction is wired, so set this when a `mdBarClick` handler does something (like `drill`). The cursor tracks the bar itself (hit-tested like a click), not the empty column above a short bar.                                                                                                                                                                                                                                                                                                                                            | `boolean`                                                                                                        | `false`                                                                                                          |
| `cornerRadius`      | `corner-radius`      | Bar corner radius in pixels.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | `number`                                                                                                         | `6`                                                                                                              |
| `density`           | `density`            | Local density rung. Drives the same `--md-sys-density-scale` signal that a global `data-density` ancestor sets, so a local value simply overrides the inherited one. 0 = default, -4 = ultra-compact.                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | `-1 \| -2 \| -3 \| -4 \| 0`                                                                                      | `0`                                                                                                              |
| `heightProp`        | `height`             |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `string \| undefined`                                                                                            | `undefined`                                                                                                      |
| `label`             | `label`              |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `string`                                                                                                         | `''`                                                                                                             |
| `labelPlot`         | `label-plot`         | Instructions announced when the plot receives keyboard focus. The plot is focusable so a keyboard user can walk the data with the arrow keys; this is what tells them so.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | `string`                                                                                                         | `'Chart data. Use the arrow keys to move between points, Home and End for the first and last, Escape to leave.'` |
| `labelPoint`        | `label-point`        | Template for the live announcement as keyboard focus moves. `%x%` is the axis value, `%values%` the series readings at it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `string`                                                                                                         | `'%x%: %values%'`                                                                                                |
| `labelZoomEnd`      | `label-zoom-end`     | Accessible label for the zoom slider's end thumb.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | `string`                                                                                                         | `'Zoom range end'`                                                                                               |
| `labelZoomStart`    | `label-zoom-start`   | Accessible label for the zoom slider's start thumb.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | `string`                                                                                                         | `'Zoom range start'`                                                                                             |
| `layout`            | `layout`             | Bar layout — `'horizontal'` swaps categories onto the y axis.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                   | `"horizontal" \| "vertical"`                                                                                     | `'vertical'`                                                                                                     |
| `legend`            | `legend`             |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `"bottom" \| "bottom-end" \| "bottom-start" \| "left" \| "none" \| "right" \| "top" \| "top-end" \| "top-start"` | `'top-end'`                                                                                                      |
| `loading`           | `loading`            | Data is still on its way — the chart covers the plot with a loader instead of drawing an empty (or stale) axis, and marks itself `aria-busy`.  Set it while the fetch is in flight and clear it when `series` arrives; the entry animation replays on the way out, so the bars draw themselves in rather than appearing fully formed. Slot `loading` to replace the default indicator with your own skeleton.                                                                                                                                                                                                                                                                   | `boolean`                                                                                                        | `false`                                                                                                          |
| `loadingLabel`      | `loading-label`      | Accessible + visible text under the loader.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | `string`                                                                                                         | `'Loading chart…'`                                                                                               |
| `locale`            | `locale`             | BCP-47 locale for the DEFAULT number / date formatting (axis ticks, tooltip values, screen-reader table). Empty follows the browser; an explicit `valueFormatter` always wins.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | `string`                                                                                                         | `''`                                                                                                             |
| `noAnimation`       | `no-animation`       | Disable all animation (shorthand for `animation="none"`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | `boolean`                                                                                                        | `false`                                                                                                          |
| `polar`             | `polar`              | Wrap the bars around a circle: one concentric RING per category, with the value axis running as an ANGLE instead of a length. A radial (polar) bar chart.  Stacking, legend hiding and percentage normalisation all work unchanged — only the step from a value to geometry differs, and the value axis deliberately spans less than the full circle, since a ring that closes on itself has no readable start or end. The quarter it gives up is where the category names go; `polar-sweep` changes how much that is.                                                                                                                                                          | `boolean`                                                                                                        | `false`                                                                                                          |
| `polarHole`         | `polar-hole`         | Radius of the hole at the centre of a polar chart, 0..0.9. Default 0.22.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `number \| undefined`                                                                                            | `undefined`                                                                                                      |
| `polarSweep`        | `polar-sweep`        | Fraction of the circle the value axis spans on a polar chart. Default 0.75.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     | `number \| undefined`                                                                                            | `undefined`                                                                                                      |
| `series`            | --                   |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `MdChartSeries[]`                                                                                                | `[]`                                                                                                             |
| `showLabels`        | `show-labels`        | Show numeric data labels on each bar.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           | `boolean`                                                                                                        | `false`                                                                                                          |
| `showTotals`        | `show-totals`        | Print each stack's TOTAL past the end of the column, alongside the per-segment values `show-labels` prints inside it. Stacked charts only — an unstacked bar already IS its total.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | `boolean`                                                                                                        | `false`                                                                                                          |
| `stack`             | `stack`              | Stacking strategy: none (grouped) / normal / percentage.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `"none" \| "normal" \| "percentage" \| "silhouette" \| "wiggle"`                                                 | `'none'`                                                                                                         |
| `subtitle`          | `subtitle`           | A quieter line under the title — a source, a unit, the caveat a title should not have to carry.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `string \| undefined`                                                                                            | `undefined`                                                                                                      |
| `titleAlign`        | `title-align`        | Title alignment over the plot: `start` (left), `center`, or `end` (right).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `"center" \| "end" \| "start"`                                                                                   | `'start'`                                                                                                        |
| `tooltip`           | `tooltip`            |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `"axis" \| "item" \| "none"`                                                                                     | `'axis'`                                                                                                         |
| `tooltipRenderer`   | --                   | Replace the tooltip's content. See `md-line-chart`'s `tooltipRenderer`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                         | `((context: MdChartTooltipContext) => MdChartTooltipContent) \| undefined`                                       | `undefined`                                                                                                      |
| `valueFormatter`    | --                   |                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `((value: number \| null \| undefined) => string) \| undefined`                                                  | `undefined`                                                                                                      |
| `xAxis`             | --                   | Category axis configuration.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | `MdChartAxis \| undefined`                                                                                       | `undefined`                                                                                                      |
| `yAxis`             | --                   | Value axis configuration.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | `MdChartAxis \| undefined`                                                                                       | `undefined`                                                                                                      |
| `yAxis2`            | --                   | A SECOND value axis, drawn in the opposite gutter and used by any series whose `yAxisIndex` is 1 — two quantities on one category axis that share no scale, like a count and a rate. Vertical charts.  It draws no gridlines of its own: two sets at two spacings turn the plot into a mesh nobody can read a value off.                                                                                                                                                                                                                                                                                                                                                        | `MdChartAxis \| undefined`                                                                                       | `undefined`                                                                                                      |
| `zoom`              | `zoom`               | Interactive zoom over the CATEGORY range — the same "zoom is a view" model as `md-line-chart`:   • `inside` — drag horizontally across the plot to zoom into that span of     bars; double-click the plot to reset.   • `slider` — a range slider under the plot; drag its thumbs to resize the     window, or the grip between them to pan it.   • `both` — both gestures.  `series` / `xAxis` are never mutated: the engine is simply fed the sliced window, and hover / click / keyboard indices are rebased so consumers still see ABSOLUTE data indices. Ignored on a `polar` chart (a ring has no span to zoom). For a *value*-range zoom, set `yAxis.min` / `yAxis.max`. | `"both" \| "inside" \| "none" \| "slider"`                                                                       | `'none'`                                                                                                         |


## Events

| Event           | Description                                                                  | Type                                                                                       |
| --------------- | ---------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `mdBarClick`    |                                                                              | `CustomEvent<MdChartClickDetail<MdChartSeries>>`                                           |
| `mdHover`       |                                                                              | `CustomEvent<MdChartHoverDetail>`                                                          |
| `mdLegendClick` |                                                                              | `CustomEvent<{ seriesIndex: number; seriesId?: string \| undefined; selected: boolean; }>` |
| `mdReady`       |                                                                              | `CustomEvent<void>`                                                                        |
| `mdZoom`        | Fires when the zoom window changes (drag, slider, or `setZoom`/`resetZoom`). | `CustomEvent<{ startIndex: number; endIndex: number; reset: boolean; }>`                   |


## Methods

### `drill(index: number, direction?: "down" | "up") => Promise<void>`

Animate the next data change as a drill through one column, so the levels
of a hierarchy read as connected rather than as unrelated charts.

AWAIT it, then assign the new `series` / `xAxis`. Like every Stencil
`@Method` this one resolves through a microtask, so a bare call lands AFTER
an assignment on the next line — the swap renders un-animated and the drill
arms itself for whatever change comes after.

```ts
await chart.drill(e.detail.dataIndex);   // descend into the clicked column
chart.series = childrenOf(clicked);

await chart.drill(0, 'up');              // …and back out
chart.series = parentLevel;
```

Descending, the incoming columns unfold out of the band the named column
occupies right now. `'up'` pivots on the whole plot instead, which is the
same motion run inside out — the level being returned to opens out of
everything the level being left was filling. Falls back to a plain swap
when the index doesn't resolve, or under `prefers-reduced-motion`.

#### Parameters

| Name        | Type             | Description |
| ----------- | ---------------- | ----------- |
| `index`     | `number`         |             |
| `direction` | `"up" \| "down"` |             |

#### Returns

Type: `Promise<void>`



### `getInstance() => Promise<BarChartEngine | null>`



#### Returns

Type: `Promise<BarChartEngine | null>`



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

Replay the entry animation from the start (uses the current `animation`).

#### Returns

Type: `Promise<void>`



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

Drop the zoom window and show the full range.

#### Returns

Type: `Promise<void>`



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



#### Returns

Type: `Promise<void>`



### `setZoom(startIndex: number, endIndex: number) => Promise<void>`

Zoom to an absolute category range.

#### Parameters

| Name         | Type     | Description |
| ------------ | -------- | ----------- |
| `startIndex` | `number` |             |
| `endIndex`   | `number` |             |

#### Returns

Type: `Promise<void>`



### `toDataURL() => Promise<string>`



#### Returns

Type: `Promise<string>`




## Shadow Parts

| Part            | Description |
| --------------- | ----------- |
| `"canvas"`      |             |
| `"empty"`       |             |
| `"footer"`      |             |
| `"header"`      |             |
| `"loading"`     |             |
| `"zoom"`        |             |
| `"zoom-band"`   |             |
| `"zoom-pan"`    |             |
| `"zoom-slider"` |             |


## Dependencies

### Depends on

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

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

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

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

AWC UI Operator’s Manual

# main-llm.md — AWC UI build director

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

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

Your job, in order:

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

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

---

## §1 — Interview the user

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

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

### 1.1 Scope and shape

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

### 1.2 Look and feel

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

### 1.3 Internationalization

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

### 1.4 Data and forms

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

### 1.5 Constraints

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

---

## §2 — Map answers to configuration

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

### 2.1 Global switches — the complete set

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

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

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

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

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

---

## §3 — Install and bootstrap

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

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

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

defineCustomElements(window);
```

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

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

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

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

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

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

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

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

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

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

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

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

---

## §4 — Global configuration reference

### 4.1 The token system

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

#### Color roles

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

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

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

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

#### Shape tokens

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

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

#### Elevation tokens

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

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

#### Motion tokens

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

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

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

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

#### State-layer opacities

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

#### Typography, spacing and layering

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

### 4.2 Theming and rebranding

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

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

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

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

### 4.3 Density

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

Two details that bite:

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

### 4.4 Dark mode

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

### 4.5 RTL

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

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

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

### 4.6 Internationalization

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

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

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

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

### 4.7 Forms and validation

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

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

Rules:

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

---

## §5 — Component decision matrix

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

### 5.1 Actions

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

### 5.2 Text input

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

### 5.3 Selection

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

### 5.4 Navigation

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

### 5.5 Containment and feedback

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

### 5.6 Data

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

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

### 5.7 Choosing the variant

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

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

### 5.8 Not in the library

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

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

---

## §6 — Component inventory

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

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

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

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

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

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

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

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

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

---

## §7 — Composition rules

### 7.1 What nests inside what

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

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

### 7.2 Pairs that belong together

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

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

### 7.3 Nesting that is always wrong

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

---

## §8 — Page recipes

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

### 8.1 Login screen

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

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

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

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

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

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

### 8.2 Settings page (mobile)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

### 8.4 Dashboard with a FAB

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

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

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

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

### 8.5 Destructive confirmation dialog

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

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

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

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

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

### 8.6 Tabbed content page

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

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

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

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

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

### 8.7 The full recipe library

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

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

---

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

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

### 9.1 API and styling

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

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

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

### 9.2 Content and hierarchy

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

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

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

### 9.3 Accessibility contract

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

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

---

## §10 — Before you ship

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