Skip to content

Line Chart

A trend over a continuous axis. One or many series, six curve types, category / time / value / log scales, multiple y-axes, mark lines, drag-zoom and a slider band, plus a built-in loading state. Series colours resolve to MD3 colour roles, so dark theme and brand-token overrides re-tint automatically.

Live preview Open in Storybook
Show code for each technology
<md-line-chart label="Revenue" legend="top-end"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "2024",
      data: [12, 18, 14, 22, 28, 24, 30, 35, 32, 38, 42, 47]
    },
    {
      label: "2025",
      data: [16, 24, 21, 30, 38, 34, 42, 49, 47, 56, 62, 71]
    }
  ];

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

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

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

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


<md-line-chart></md-line-chart>
  • Change over time, or over any continuous axis.
  • Comparing the shape of several series.
  • Dense series the user needs to zoom into.
SituationUse instead
Comparing discrete categoriesmd-bar-chart
Emphasising cumulative volumemd-area-chart
Parts of a wholemd-pie-chart
An inline micro-trend in a cellmd-sparkline
Exact values users must readmd-table
Very few points (2–3)Plain text, or a bar chart
Unordered categoriesmd-bar-chart — lines imply continuity
NeedSetting
Curve stylecurve="linear | smooth | monotone | step | step-before | step-middle"
Stack the seriesstack="normal | percentage | silhouette | wiggle"
Fill under the linearea (or use md-area-chart)
Points visibleshow-marks
Bridge gaps in dataconnect-nulls
A second scaleyAxes (array property)
Thresholds and dividersmarkLines (array property)
Zoomzoom="inside | slider | both"
Async dataloading (plus loading-label)
Swap the axesinverted

smooth (the default) feels MD3 Expressive. linear and monotone suit financial and scientific precision — monotone won’t overshoot. The step variants encode “value holds, then jumps”, which is what finance and DevOps dashboards usually mean.

CurveBehaviour
linearStraight segments
smoothCatmull-Rom spline — default
monotoneMonotone cubic — won’t overshoot
stepStep-after (value holds, then jumps)
step-beforeStep-before (value jumps, then holds)
step-middleStep-middle (value jumps at the midpoint between points)
Smooth curve
Show code for each technology
<md-line-chart curve="smooth" show-marks></md-line-chart>

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

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

  const el = document.querySelector("md-line-chart");
  Object.assign(el, { series, xAxis });
</script>
Step curve
Show code for each technology
<md-line-chart curve="step" show-marks></md-line-chart>

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

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

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

Keep the count to about five. Beyond that it becomes a plate of spaghetti — split the chart, or highlight one series and mute the rest.

Sales by region
Show code for each technology
<md-line-chart label="Sales by region" legend="top-end" grid="horizontal"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "EMEA",
      data: [400, 320, 510, 480, 600, 690]
    },
    {
      label: "AMER",
      data: [320, 410, 380, 450, 580, 640]
    },
    {
      label: "APAC",
      data: [180, 240, 290, 320, 410, 470]
    }
  ];

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

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

area fills under the line for a one-off emphasis on volume. When the fill is the point, use md-area-chart — the same family, with stacking defaults tuned for it.

Filled gradient
Show code for each technology
<md-line-chart area label="Server load"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "CPU %",
      data: [40, 52, 48, 60, 65, 72, 68, 80, 74, 82],
      color: "tertiary"
    }
  ];

  const xAxis = {
    data: ["t1", "t2", "t3", "t4", "t5", "t6", "t7", "t8", "t9", "t10"],
    hideTicks: true
  };

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

stack adds the series together instead of overlaying them. Five modes:

ModeBehaviour
noneSeries overlay one another — default
normalAdditive stacking
percentageEach x-tick rescaled to total 100%
silhouetteSymmetric around zero (streamgraph)
wiggleSlope changes minimised (streamgraph)

Stacking only reads as stacking when the bands are filled, so pair it with area.

Stacked (percentage) Open in Storybook
Show code for each technology
<md-line-chart area stack="percentage" label="Device share" legend="bottom"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Mobile",
      data: [40, 45, 48, 55, 62, 64]
    },
    {
      label: "Desktop",
      data: [45, 40, 35, 30, 25, 20]
    },
    {
      label: "Tablet",
      data: [15, 15, 17, 15, 13, 16]
    }
  ];

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

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

show-line="false" combined with show-marks gives a scatter-like plot. Note the quoted string: showLine defaults to true, so the attribute has to be present and set to "false" to switch the line off.

Marks only (scatter)
Show code for each technology
<md-line-chart show-line="false" show-marks grid="both" legend="top-end"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Cohort A",
      data: [12, 30, 18, 42, 25, 51, 33, 47],
      symbol: "circle"
    },
    {
      label: "Cohort B",
      data: [40, 22, 35, 15, 48, 28, 55, 20],
      symbol: "diamond"
    }
  ];

  const xAxis = {
    data: ["1", "2", "3", "4", "5", "6", "7", "8"]
  };

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

markLines draws labelled vertical dividers at an x position — a release date, “now”, a policy change. Each entry is { value, label?, color?, dash? }, where value is an axis value or a (possibly fractional) category index. Use them instead of free-floating annotation text.

A 'now' divider
Show code for each technology
<md-line-chart curve="smooth" show-marks legend="none"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Temperature",
      data: [8, 9, 11, 14, 16, 15, 13, 11]
    }
  ];

  const xAxis = {
    data: ["06", "08", "10", "12", "14", "16", "18", "20"]
  };

  const markLines = [
    {
      value: 4,
      label: "Now",
      color: "secondary",
      dash: "dashed"
    }
  ];

  const el = document.querySelector("md-line-chart");
  Object.assign(el, { series, xAxis, markLines });
</script>
const yAxes = [
{ label: 'Sessions', position: 'left' },
{ label: 'Revenue', position: 'right', valueFormatter: (v) => '$' + v },
];
const series = [
{ label: 'Sessions', yAxisIndex: 0, data: sessions },
{ label: 'Revenue', yAxisIndex: 1, data: revenue },
];
Sessions against revenue
Show code for each technology
<md-line-chart curve="smooth" legend="bottom" grid="horizontal"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Sessions",
      yAxisIndex: 0,
      data: [400, 460, 520, 610, 580, 700]
    },
    {
      label: "Revenue",
      yAxisIndex: 1,
      data: [12, 15, 14, 21, 19, 26],
      color: "tertiary"
    }
  ];

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

  const yAxes = [
    {
      position: "left",
      label: "Sessions"
    },
    {
      position: "right",
      label: "Revenue (k)"
    }
  ];

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

By default a null is a gap, not a zero: the line breaks at it, so a hole in the record reads as a hole. connect-nulls bridges it with a straight segment — right for a dropped sensor reading, wrong for a period a series genuinely did not exist, where bridging fabricates data.

connect-nulls
Show code for each technology
<md-line-chart connect-nulls show-marks label="Bridged gaps"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "value",
      data: [10, 18, null, null, 22, 28, null, 36]
    }
  ];

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

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

Zoom has two independent mechanisms: drag-to-zoom inside the plot (zoom="inside") and the slider band below it (zoom="slider"). both enables each. setZoom() / resetZoom() drive it programmatically.

Zoom is a view. The window slices what the engine draws and leaves your series array untouched, so every event still reports absolute indices into the data you supplied.

Drag the plot, or use the slider Open in Storybook
Show code for each technology
<md-line-chart zoom="both" label="Dense series"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "value",
      data: [80, 87.97338661590122, 95.788366846173, 91.29284946790071, 98.34712181799046, 104.82941969615793, 98.64078171934452, 103.7089945997692, 107.9914720608301, 99.47695261756391, 102.18594853651364, 104.1699280763918, 93.50926361102302, 94.31002743642928, 94.69976300311811, 82.82240016119735, 82.8325171314484, 82.88917795946338, 71.14959113410295, 71.76284218114563, 72.86395009384144, 62.56848455172823, 64.96795852220967, 68.12617992733071, 60.076707823283186, 64.82151450673723, 70.33090688559693, 64.54471024888025, 71.37466724255357, 78.70795641172485, 74.41169003602148, 82.33821194365007, 90.33098409700987, 86.23082727026755, 93.88226702277217, 101.13973197437578, 95.87335727698306, 101.97416191623253, 107.35839344062973, 99.9708669074921, 103.78716493246763, 106.81461113359546, 97.09197816176561, 98.68794195748227, 99.69834385783523, 88.24236970483513, 88.45779828200496, 88.49550850906715, 76.51346437554041, 76.67041741496143]
    }
  ];

  const xAxis = {
    data: ["T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8", "T9", "T10", "T11", "T12", "T13", "T14", "T15", "T16", "T17", "T18", "T19", "T20", "T21", "T22", "T23", "T24", "T25", "T26", "T27", "T28", "T29", "T30", "T31", "T32", "T33", "T34", "T35", "T36", "T37", "T38", "T39", "T40", "T41", "T42", "T43", "T44", "T45", "T46", "T47", "T48", "T49", "T50"]
  };

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

loading shows an opaque overlay with a progress indicator over the plot — it has to be opaque, because the engine still draws a placeholder axis when there is no data. An empty series shows label-empty instead. The two never stack: “no data” is the wrong answer to “where is my data?” mid-load. Both accept slotted content (slot="loading", slot="empty") when text is not enough.

Empty state
Show code for each technology
<md-line-chart label-empty="No sessions recorded yet"></md-line-chart>

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

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

loading shows a circular indicator and the loading-label text. When that is not the right shape — a wide dashboard tile, a skeleton that mirrors the chart’s own layout, a branded mark — slot your own into loader (the older name loading still works); it replaces the default entirely.

Default — circular indicator + loading-label Open in Storybook
Show code for each technology
<md-line-chart label="Monthly revenue" loading loading-label="Fetching revenue…"></md-line-chart>

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

  const xAxis = {
    data: []
  };

  const yAxis = {
    label: "EUR"
  };

  const el = document.querySelector("md-line-chart");
  Object.assign(el, { series, xAxis, yAxis });
</script>
Slotted — a linear indicator across the tile
Fetching revenue…
Show code for each technology
<md-line-chart label="Monthly revenue" 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-line-chart>

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

  const xAxis = {
    data: []
  };

  const yAxis = {
    label: "EUR"
  };

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

A circular md-progress-indicator in the loader slot keeps the default’s shape while letting you set its size and label.

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

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

  const xAxis = {
    data: []
  };

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

legend takes an anchor — top, bottom, left, right, top-start, top-end, bottom-start, bottom-end — or none. The side anchors (left / right) reserve a gutter beside the plot; the top/bottom anchors reserve a row above or below it. Pick the edge the reader arrives from, and drop it entirely when the series are labelled on the plot.

legend=top-end
Show code for each technology
<md-line-chart legend="top-end" label="Revenue"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "EMEA",
      data: [400, 320, 510, 480, 600, 690]
    },
    {
      label: "AMER",
      data: [320, 410, 380, 450, 580, 640]
    }
  ];

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

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

color 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. On a filled chart the same colour drives the gradient under the line.

Role names and explicit colours
Show code for each technology
<md-line-chart legend="bottom" show-marks></md-line-chart>

<script type="module">
  const series = [
    {
      label: "primary",
      color: "primary",
      data: [12, 18, 14, 22, 28, 24]
    },
    {
      label: "tertiary",
      color: "tertiary",
      data: [8, 12, 10, 16, 20, 18]
    },
    {
      label: "hex",
      color: "#e0b400",
      data: [4, 7, 6, 10, 13, 12]
    }
  ];

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

  const el = document.querySelector("md-line-chart");
  Object.assign(el, { series, xAxis });
</script>
Area fills take the series colour
Show code for each technology
<md-line-chart area legend="bottom"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "rgb",
      color: "rgb(125, 82, 96)",
      data: [20, 28, 24, 34, 41, 38]
    },
    {
      label: "named",
      color: "teal",
      data: [10, 14, 12, 19, 24, 22]
    }
  ];

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

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

valueFormatter owns the value text — axis ticks, tooltip rows and the screen-reader table. Set locale for the default Intl formatting; once you pass a formatter, it wins outright, so localise inside it.

const valueFormatter = (v) =>
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(v ?? 0);
Currency formatting
Show code for each technology
<md-line-chart label="Revenue" legend="none" show-marks></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Revenue",
      data: [1200, 1850, 1640, 2310, 2780, 2450]
    }
  ];

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

  const yAxis = {
    label: "EUR"
  };

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

show-labels prints each point’s value on the plot. Use it for a handful of points; on a dense series the numbers collide and the line stops being readable. A label with no room above it flips below the point rather than overflowing the plot.

show-labels
Show code for each technology
<md-line-chart show-labels show-marks legend="none" label="Weekly signups"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Signups",
      color: "primary",
      data: [42, 61, 55, 78, 96, 88]
    }
  ];

  const xAxis = {
    data: ["W1", "W2", "W3", "W4", "W5", "W6"]
  };

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

symbol on a series picks the point marker. Five shapes are drawn on the canvas — circle (default), square, diamond, triangle, triangle-down — and none suppresses that series’ markers even when marks are on.

Any other string is not an error: it is drawn as a text glyph at every point. That is the emoji case below — but it also means a typo (cross, say) silently prints the word rather than falling back to a shape.

Emoji markers
Show code for each technology
<md-line-chart show-marks curve="smooth" legend="bottom" grid="horizontal"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Sunny",
      symbol: "☀️",
      color: "tertiary",
      data: [12, 16, 21, 25, 28, 24]
    },
    {
      label: "Rain",
      symbol: "🌧️",
      color: "primary",
      data: [8, 6, 9, 5, 3, 7]
    }
  ];

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

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

scale: 'log' on the value axis turns equal ratios into equal distances, so a series spanning orders of magnitude stays readable instead of flattening into the baseline. Ticks snap to powers of ten.

scale: 'log'
Show code for each technology
<md-line-chart curve="linear" legend="bottom" grid="horizontal" show-marks></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Requests",
      color: "primary",
      data: [12, 140, 1800, 24000, 310000, 4200000]
    },
    {
      label: "Errors",
      color: "error",
      data: [2, 9, 40, 180, 900, 4100]
    }
  ];

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

  const yAxis = {
    scale: "log",
    label: "Count"
  };

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

inverted transposes the plot: the category axis runs down the side and values run across the bottom. Reach for it when the independent variable reads as depth — altitude, a drill core — or when the category names are too long to sit under a horizontal axis.

inverted with a smooth curve
Show code for each technology
<md-line-chart inverted curve="smooth" grid="both" legend="bottom" show-marks></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Temperature (°C)",
      color: "primary",
      data: [15, 9, 2, -6, -18, -35]
    }
  ];

  const xAxis = {
    data: ["0 km", "2 km", "4 km", "6 km", "8 km", "10 km"],
    label: "Altitude"
  };

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

bands on an axis shades a range and labels it — a forecast half, a weekend, an outage window, a target zone. It reads as context behind the data rather than another series competing with it, so keep the fill faint.

A shaded weekend
Show code for each technology
<md-line-chart curve="smooth" grid="horizontal" legend="none" label="Daily active users"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "DAU",
      color: "primary",
      data: [820, 910, 880, 940, 1010, 520, 480]
    }
  ];

  const xAxis = {
    data: ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"],
    bands: [
      {
        from: 5,
        to: 6,
        color: "color-mix(in srgb, var(--md-sys-color-primary) 8%, transparent)",
        label: "Weekend"
      }
    ]
  };

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

One outlier flattens everything else. breaks: 'auto' on the value axis detects the gap and folds it away, so the small values get their range back while the outlier stays honestly larger. The cut is drawn on the axis — the reader can see the scale was interrupted.

breaks: 'auto'
Show code for each technology
<md-line-chart curve="linear" legend="none" show-marks label="Monthly active users"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "MAU (k)",
      color: "primary",
      data: [8, 12, 18, 25, 1150, 1320, 1280, 1400]
    }
  ];

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

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

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

Series need not share a length or a sampling rhythm. A series that starts later simply has no point before its first sample — that is different from a null, which is a measured gap. On a time scale the x positions come from the values themselves, so irregular intervals are spaced truthfully rather than evenly.

A series that starts halfway
Show code for each technology
<md-line-chart curve="smooth" grid="horizontal" legend="bottom" area></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Legacy",
      color: "tertiary",
      data: [40, 38, 35, 30, 24, 18]
    },
    {
      label: "Rewrite",
      color: "primary",
      data: [null, null, 5, 14, 28, 46]
    }
  ];

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

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

Three separate dash settings, easy to confuse:

WhereFieldStyles
On a seriesdashthat series’ own drawn line
On an axisdashthe axis line and its tick marks
On an axisgridDashthe gridlines that axis draws across the plot

A dotted series is the conventional way to mark a line that isn’t a measurement — a target, an average, a forecast — so it reads as a reference without needing a legend entry to explain it.

A dotted average against solid actuals
Show code for each technology
<md-line-chart curve="smooth" grid="horizontal" legend="bottom" show-marks label="Response time"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "p50",
      color: "primary",
      data: [120, 138, 129, 152, 141, 133, 147]
    },
    {
      label: "p95",
      color: "tertiary",
      data: [280, 310, 295, 360, 332, 318, 351]
    },
    {
      label: "Target",
      color: "error",
      dash: "dotted",
      data: [300, 300, 300, 300, 300, 300, 300]
    }
  ];

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

  const yAxis = {
    label: "ms"
  };

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

Dotted or dashed gridlines let the grid recede behind the data instead of competing with it.

Dashed gridlines
Show code for each technology
<md-line-chart curve="smooth" grid="both" legend="none" show-marks></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Load",
      color: "primary",
      data: [40, 52, 48, 60, 65, 72, 68]
    }
  ];

  const xAxis = {
    data: ["t1", "t2", "t3", "t4", "t5", "t6", "t7"],
    gridDash: "dotted"
  };

  const yAxis = {
    gridDash: "dashed",
    label: "CPU %"
  };

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

animation picks the entrance: expressive (default), grow, fade, draw (a left-to-right reveal, the line drawing itself), stagger (the same, one series at a time) or none. animation-duration overrides the timing, and every variant honours prefers-reduced-motion.

Every variant — press Replay all to run them again Open in Storybook
Replay all

Picking a variant is declarative — animation is a plain string attribute and animation-duration overrides the timing in milliseconds.

<md-line-chart id="trend" animation="draw" animation-duration="900"
             curve="smooth" grid="both" legend="none"></md-line-chart>

<script type="module">
const chart = document.getElementById('trend');
chart.series = [{ label: 'Users', color: 'primary', data: [40, 62, 55, 78, 70, 96, 88, 110, 102, 128] }];
chart.xAxis = { data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct'] };
</script>

stagger is draw run one series at a time — line 1 completes, then line 2 starts — so the chart reads as a sequence rather than a race. animation-duration is the time for each series, not the total, so four lines at 700ms take about 2.8 seconds end to end. Keep it for a handful of lines; past that the last one arrives long after the reader has moved on.

Press replay to watch the lines draw in turn Open in Storybook
Replay

The entry animation plays once on first render, so watching it again means calling the replay() method — which needs a handle on the element rather than a prop.

<md-line-chart id="stagger" animation="stagger" animation-duration="700"
             curve="smooth" grid="horizontal" legend="bottom"
             label="Units sold by region"></md-line-chart>
<md-button id="replay" variant="filled" trailing-icon="replay">Replay</md-button>

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

chart.xAxis = { data: [0, 1, 2, 3, 4, 5, 6, 7], scale: 'value', label: 'Quarter' };
chart.yAxis = { label: 'Units (k)', min: 0 };
chart.series = [
{ label: 'North', color: 'primary', data: [12, 20, 18, 32, 28, 44, 40, 56] },
{ label: 'South', color: 'tertiary', data: [30, 26, 34, 28, 40, 36, 48, 44] },
{ label: 'East', color: 'secondary', data: [8, 14, 22, 18, 30, 34, 42, 52] },
{ label: 'West', color: 'error', data: [40, 44, 38, 46, 42, 50, 46, 58] },
];

// replay() re-runs the entrance without touching the data.
document.getElementById('replay').addEventListener('click', () => chart.replay());
</script>

The engine decimates before it draws: at more points than the plot has pixels it keeps the min and max of each column, so spikes survive but the path stays cheap. You do not opt in — hand it the whole series.

2,000 points Open in Storybook
Show code for each technology
<md-line-chart curve="linear" legend="none" zoom="both" label="Sensor trace"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Signal",
      color: "primary",
      data: [50, 51.9, 53.8, 55.6, 57.3, 59, 60.5, 62, 63.2, 64.4, 65.3, 66.1, 66.8, 67.3, 67.6, 67.7, 67.7, 67.6, 67.4, 67, 66.6, 66.2, 65.7, 65.2, 64.7, 64.2, 63.8, 63.5, 63.3, 63.2, 63.2, 63.3, 63.6, 64, 64.6, 65.4, 66.2, 67.2, 68.4, 69.6, 70.9, 72.3, 73.8, 75.3, 76.8, 78.2, 79.7, 81, 82.3, 83.5, 84.5, 85.4, 86.2, 86.8, 87.2, 87.4, 87.5, 87.4, 87, 86.6, 86, 85.2, 84.3, 83.3, 82.2, 81.1, 79.9, 78.7, 77.5, 76.3, 75.2, 74.1, 73.1, 72.3, 71.5, 70.9, 70.5, 70.1, 70, 69.9, 70, 70.2, 70.6, 71.1, 71.6, 72.2, 72.9, 73.6, 74.3, 75, 75.6, 76.2, 76.7, 77.1, 77.4, 77.6, 77.6, 77.4, 77.1, 76.5, 75.9, 75, 74, 72.8, 71.5, 70, 68.4, 66.8, 65, 63.2, 61.4, 59.6, 57.7, 56, 54.3, 52.6, 51.1, 49.7, 48.4, 47.3, 46.3, 45.5, 44.8, 44.3, 44, 43.8, 43.7, 43.8, 44, 44.2, 44.6, 44.9, 45.3, 45.7, 46.1, 46.4, 46.7, 46.9, 47, 46.9, 46.8, 46.5, 46, 45.4, 44.6, 43.7, 42.6, 41.4, 40.1, 38.7, 37.1, 35.5, 33.8, 32.1, 30.4, 28.7, 27, 25.4, 23.9, 22.5, 21.2, 20, 19, 18.2, 17.5, 17, 16.7, 16.6, 16.6, 16.8, 17.2, 17.6, 18.3, 19, 19.8, 20.6, 21.5, 22.4, 23.4, 24.2, 25.1, 25.8, 26.5, 27.1, 27.5, 27.8, 28, 28, 27.9, 27.7, 27.3, 26.7, 26.1, 25.4, 24.5, 23.6, 22.7, 21.7, 20.8, 19.8, 18.9, 18, 17.3, 16.6, 16.1, 15.7, 15.5, 15.5, 15.6, 15.9, 16.3, 17, 17.8, 18.8, 19.9, 21.2, 22.6, 24.1, 25.6, 27.3, 28.9, 30.6, 32.3, 33.9, 35.5, 37, 38.4, 39.6, 40.8, 41.8, 42.7, 43.4, 44, 44.4, 44.6, 44.8, 44.8, 44.6, 44.4, 44.1, 43.8, 43.4, 43, 42.6, 42.2, 41.8, 41.6, 41.4, 41.4, 41.5, 41.7, 42, 42.6, 43.3, 44.1, 45.1, 46.3, 47.6, 49, 50.6, 52.2, 54, 55.8, 57.6, 59.5, 61.3, 63.1, 64.9, 66.6, 68.2, 69.6, 71, 72.2, 73.2, 74, 74.7, 75.3, 75.6, 75.8, 75.8, 75.7, 75.4, 75, 74.6, 74, 73.4, 72.7, 72.1, 71.4, 70.8, 70.3, 69.8, 69.4, 69.1, 68.9, 68.9, 69, 69.3, 69.7, 70.2, 70.9, 71.7, 72.7, 73.7, 74.8, 76, 77.3, 78.5, 79.8, 81, 82.2, 83.4, 84.4, 85.3, 86.1, 86.8, 87.3, 87.6, 87.8, 87.8, 87.6, 87.2, 86.6, 85.9, 85, 84, 82.9, 81.7, 80.3, 78.9, 77.5, 76.1, 74.7, 73.3, 71.9, 70.7, 69.5, 68.4, 67.5, 66.7, 66, 65.5, 65.1, 64.9, 64.8, 64.9, 65, 65.3, 65.7, 66.1, 66.6, 67.2, 67.7, 68.2, 68.7, 69.1, 69.5, 69.7, 69.9, 69.9, 69.7, 69.4, 68.9, 68.3, 67.5, 66.5, 65.3, 64.1, 62.6, 61.1, 59.4, 57.6, 55.8, 54, 52.1, 50.2, 48.3, 46.5, 44.8, 43.1, 41.6, 40.2, 38.9, 37.8, 36.8, 36, 35.4, 34.9, 34.6, 34.5, 34.5, 34.6, 34.8, 35.2, 35.5, 36, 36.5, 36.9, 37.4, 37.8, 38.2, 38.4, 38.6, 38.7, 38.6, 38.4, 38, 37.5, 36.9, 36.1, 35.2, 34.1, 32.9, 31.6, 30.2, 28.7, 27.2, 25.7, 24.2, 22.6, 21.2, 19.8, 18.4, 17.2, 16.1, 15.2, 14.4, 13.8, 13.4, 13.1, 13, 13.1, 13.4, 13.8, 14.4, 15.1, 16, 16.9, 18, 19.1, 20.2, 21.4, 22.5, 23.6, 24.7, 25.6, 26.5, 27.3, 28, 28.5, 28.9, 29.2, 29.3, 29.2, 29.1, 28.7, 28.3, 27.8, 27.2, 26.5, 25.8, 25, 24.3, 23.5, 22.9, 22.2, 21.7, 21.3, 21, 20.8, 20.8, 20.9, 21.2, 21.7, 22.4, 23.3, 24.3, 25.4, 26.8, 28.2, 29.8, 31.4, 33.1, 34.9, 36.7, 38.5, 40.3, 42.1, 43.8, 45.4, 46.9, 48.2, 49.5, 50.6, 51.5, 52.3, 52.9, 53.4, 53.7, 53.9, 53.9, 53.8, 53.6, 53.4, 53, 52.7, 52.3, 51.9, 51.5, 51.2, 51, 50.8, 50.7, 50.8, 51, 51.4, 51.9, 52.5, 53.3, 54.3, 55.4, 56.7, 58.1, 59.6, 61.1, 62.8, 64.5, 66.3, 68, 69.7, 71.4, 73.1, 74.6, 76.1, 77.4, 78.6, 79.6, 80.5, 81.1, 81.7, 82, 82.2, 82.1, 82, 81.7, 81.2, 80.6, 79.9, 79.2, 78.4, 77.5, 76.7, 75.8, 75, 74.2, 73.5, 72.9, 72.5, 72.1, 71.9, 71.8, 71.8, 72, 72.3, 72.8, 73.4, 74.1, 74.9, 75.8, 76.8, 77.8, 78.8, 79.9, 80.9, 81.8, 82.7, 83.5, 84.2, 84.8, 85.2, 85.4, 85.5, 85.4, 85.2, 84.7, 84.1, 83.3, 82.3, 81.2, 80, 78.6, 77.2, 75.6, 74, 72.4, 70.8, 69.2, 67.6, 66.1, 64.6, 63.3, 62.1, 61, 60, 59.2, 58.5, 58, 57.6, 57.4, 57.4, 57.4, 57.6, 57.8, 58.1, 58.5, 58.9, 59.4, 59.8, 60.2, 60.5, 60.8, 60.9, 60.9, 60.9, 60.6, 60.2, 59.7, 59, 58.1, 57.1, 55.9, 54.6, 53.1, 51.5, 49.8, 48.1, 46.2, 44.4, 42.5, 40.7, 38.8, 37.1, 35.4, 33.8, 32.3, 31, 29.8, 28.7, 27.9, 27.2, 26.7, 26.3, 26.1, 26.1, 26.2, 26.4, 26.8, 27.2, 27.8, 28.3, 28.9, 29.6, 30.2, 30.7, 31.2, 31.7, 32, 32.2, 32.3, 32.2, 32.1, 31.7, 31.2, 30.6, 29.9, 29, 28, 26.9, 25.7, 24.4, 23.1, 21.8, 20.5, 19.2, 17.9, 16.7, 15.7, 14.7, 13.8, 13.1, 12.6, 12.2, 12, 12, 12.2, 12.6, 13.1, 13.8, 14.6, 15.6, 16.7, 17.9, 19.2, 20.5, 21.9, 23.2, 24.6, 25.9, 27.2, 28.4, 29.5, 30.5, 31.4, 32.1, 32.7, 33.2, 33.5, 33.6, 33.6, 33.5, 33.3, 33, 32.5, 32, 31.5, 30.9, 30.3, 29.8, 29.3, 28.8, 28.5, 28.2, 28.1, 28.1, 28.2, 28.5, 29, 29.6, 30.5, 31.5, 32.6, 33.9, 35.3, 36.9, 38.6, 40.3, 42.1, 44, 45.9, 47.8, 49.6, 51.4, 53.1, 54.8, 56.3, 57.7, 58.9, 60, 61, 61.7, 62.4, 62.8, 63.1, 63.2, 63.2, 63.1, 62.9, 62.6, 62.2, 61.8, 61.3, 60.9, 60.5, 60.1, 59.8, 59.5, 59.4, 59.4, 59.5, 59.8, 60.2, 60.8, 61.5, 62.3, 63.3, 64.5, 65.7, 67.1, 68.5, 70, 71.6, 73.2, 74.8, 76.3, 77.9, 79.3, 80.7, 81.9, 83, 84, 84.8, 85.5, 85.9, 86.2, 86.3, 86.3, 86, 85.6, 85.1, 84.4, 83.6, 82.7, 81.7, 80.6, 79.6, 78.5, 77.4, 76.3, 75.4, 74.4, 73.6, 72.9, 72.3, 71.9, 71.6, 71.4, 71.4, 71.5, 71.7, 72.1, 72.6, 73.2, 73.9, 74.6, 75.4, 76.2, 77, 77.8, 78.5, 79.2, 79.7, 80.2, 80.5, 80.7, 80.8, 80.6, 80.3, 79.8, 79.2, 78.4, 77.4, 76.2, 74.9, 73.5, 71.9, 70.3, 68.6, 66.8, 65.1, 63.3, 61.5, 59.8, 58.2, 56.6, 55.1, 53.8, 52.6, 51.5, 50.6, 49.9, 49.3, 48.9, 48.6, 48.5, 48.4, 48.5, 48.7, 49, 49.4, 49.7, 50.1, 50.5, 50.9, 51.2, 51.4, 51.5, 51.6, 51.5, 51.2, 50.8, 50.3, 49.6, 48.7, 47.7, 46.6, 45.3, 43.8, 42.3, 40.7, 39, 37.2, 35.5, 33.7, 31.9, 30.2, 28.5, 27, 25.5, 24.2, 23, 21.9, 21, 20.3, 19.8, 19.4, 19.3, 19.2, 19.4, 19.7, 20.1, 20.7, 21.3, 22, 22.8, 23.6, 24.4, 25.2, 25.9, 26.6, 27.2, 27.8, 28.2, 28.5, 28.6, 28.6, 28.5, 28.2, 27.8, 27.3, 26.6, 25.8, 24.9, 24, 22.9, 21.9, 20.8, 19.7, 18.6, 17.6, 16.7, 15.8, 15.1, 14.5, 14.1, 13.8, 13.7, 13.7, 14, 14.4, 15, 15.7, 16.7, 17.8, 19, 20.3, 21.7, 23.2, 24.8, 26.3, 27.9, 29.5, 31, 32.5, 33.9, 35.2, 36.3, 37.4, 38.3, 39, 39.6, 40.1, 40.4, 40.6, 40.6, 40.5, 40.3, 40, 39.6, 39.2, 38.8, 38.3, 37.9, 37.5, 37.2, 36.9, 36.8, 36.7, 36.8, 37.1, 37.5, 38.1, 38.8, 39.7, 40.7, 41.9, 43.3, 44.8, 46.4, 48.1, 49.9, 51.7, 53.6, 55.5, 57.3, 59.2, 60.9, 62.6, 64.2, 65.7, 67, 68.2, 69.3, 70.1, 70.8, 71.4, 71.7, 71.9, 71.9, 71.8, 71.6, 71.3, 70.9, 70.4, 69.8, 69.2, 68.7, 68.1, 67.6, 67.2, 66.8, 66.5, 66.4, 66.4, 66.5, 66.7, 67.1, 67.7, 68.4, 69.2, 70.2, 71.2, 72.4, 73.7, 75, 76.4, 77.7, 79.1, 80.5, 81.8, 83, 84.1, 85.1, 86, 86.7, 87.3, 87.7, 87.9, 88, 87.8, 87.5, 87, 86.4, 85.6, 84.6, 83.6, 82.4, 81.2, 79.9, 78.6, 77.3, 76, 74.7, 73.5, 72.3, 71.3, 70.4, 69.6, 68.9, 68.4, 68, 67.8, 67.7, 67.8, 67.9, 68.2, 68.6, 69.1, 69.7, 70.3, 70.9, 71.5, 72.1, 72.6, 73.1, 73.5, 73.8, 73.9, 73.9, 73.8, 73.5, 73, 72.3, 71.5, 70.5, 69.4, 68.1, 66.7, 65.1, 63.4, 61.7, 59.9, 58, 56.2, 54.3, 52.5, 50.7, 49, 47.4, 45.9, 44.5, 43.3, 42.2, 41.3, 40.5, 39.9, 39.5, 39.2, 39.1, 39.1, 39.2, 39.4, 39.7, 40.1, 40.5, 40.9, 41.4, 41.7, 42.1, 42.4, 42.6, 42.6, 42.6, 42.4, 42.1, 41.6, 41, 40.3, 39.3, 38.3, 37.1, 35.8, 34.4, 32.9, 31.3, 29.7, 28.1, 26.4, 24.8, 23.3, 21.8, 20.4, 19.1, 18, 17, 16.1, 15.4, 14.9, 14.6, 14.5, 14.5, 14.7, 15.1, 15.6, 16.3, 17, 17.9, 18.8, 19.8, 20.9, 21.9, 22.9, 23.9, 24.8, 25.6, 26.4, 27, 27.5, 27.9, 28.1, 28.2, 28.2, 28, 27.7, 27.2, 26.6, 26, 25.2, 24.4, 23.6, 22.7, 21.9, 21, 20.3, 19.6, 19, 18.5, 18.1, 17.9, 17.8, 17.9, 18.2, 18.7, 19.3, 20.1, 21.1, 22.3, 23.5, 25, 26.5, 28.1, 29.8, 31.5, 33.2, 35, 36.7, 38.4, 40, 41.5, 42.9, 44.2, 45.4, 46.4, 47.2, 47.9, 48.5, 48.9, 49.1, 49.2, 49.2, 49.1, 48.9, 48.6, 48.2, 47.8, 47.4, 47.1, 46.7, 46.4, 46.2, 46.1, 46.1, 46.2, 46.5, 46.9, 47.5, 48.2, 49.1, 50.2, 51.4, 52.7, 54.2, 55.8, 57.4, 59.2, 60.9, 62.7, 64.5, 66.3, 68.1, 69.8, 71.4, 72.8, 74.2, 75.4, 76.5, 77.4, 78.1, 78.6, 79, 79.2, 79.2, 79.1, 78.8, 78.4, 77.9, 77.3, 76.7, 75.9, 75.2, 74.4, 73.7, 73, 72.4, 71.8, 71.4, 71, 70.8, 70.7, 70.8, 71, 71.4, 71.8, 72.5, 73.2, 74.1, 75, 76.1, 77.2, 78.3, 79.5, 80.6, 81.7, 82.8, 83.7, 84.6, 85.4, 86, 86.5, 86.8, 87, 86.9, 86.7, 86.3, 85.8, 85, 84.1, 83.1, 81.9, 80.6, 79.2, 77.8, 76.3, 74.7, 73.2, 71.7, 70.2, 68.8, 67.5, 66.3, 65.1, 64.2, 63.3, 62.6, 62.1, 61.7, 61.4, 61.3, 61.4, 61.5, 61.8, 62.1, 62.5, 62.9, 63.4, 63.9, 64.3, 64.7, 65.1, 65.3, 65.5, 65.5, 65.4, 65.2, 64.7, 64.2, 63.4, 62.5, 61.4, 60.2, 58.8, 57.3, 55.7, 54, 52.2, 50.4, 48.5, 46.6, 44.7, 42.9, 41.1, 39.4, 37.8, 36.4, 35, 33.8, 32.8, 31.9, 31.2, 30.7, 30.4, 30.2, 30.1, 30.2, 30.4, 30.7, 31.1, 31.6, 32.1, 32.7, 33.2, 33.7, 34.2, 34.6, 34.9, 35.1, 35.2, 35.1, 35, 34.6, 34.2, 33.5, 32.8, 31.9, 30.8, 29.7, 28.4, 27.1, 25.7, 24.3, 22.9, 21.5, 20.1, 18.7, 17.5, 16.3, 15.2, 14.3, 13.6, 12.9, 12.5, 12.3, 12.2, 12.3, 12.6, 13, 13.7, 14.4, 15.3, 16.3, 17.4, 18.6, 19.8, 21.1, 22.4, 23.6, 24.8, 26, 27, 28, 28.9, 29.6, 30.2, 30.6, 30.9, 31.1, 31.1, 31, 30.7, 30.4, 29.9, 29.4, 28.8, 28.1, 27.5, 26.8, 26.2, 25.6, 25.1, 24.7, 24.4, 24.2, 24.2, 24.3, 24.6, 25.1, 25.7, 26.5, 27.5, 28.7, 30, 31.4, 33, 34.6, 36.3, 38.2, 40, 41.8, 43.7, 45.5, 47.3, 49, 50.5, 52, 53.4, 54.6, 55.6, 56.5, 57.2, 57.8, 58.2, 58.5, 58.6, 58.6, 58.5, 58.2, 57.9, 57.6, 57.2, 56.8, 56.3, 56, 55.7, 55.4, 55.3, 55.2, 55.3, 55.5, 55.9, 56.4, 57.1, 57.9, 58.9, 60, 61.3, 62.6, 64.1, 65.6, 67.2, 68.9, 70.6, 72.2, 73.9, 75.5, 77, 78.4, 79.7, 80.9, 81.9, 82.8, 83.5, 84, 84.4, 84.5, 84.5, 84.3, 84, 83.5, 82.9, 82.2, 81.4, 80.5, 79.5, 78.6, 77.6, 76.6, 75.7, 74.9, 74.1, 73.4, 72.9, 72.4, 72.2, 72, 72, 72.1, 72.4, 72.8, 73.3, 74, 74.7, 75.5, 76.4, 77.3, 78.2, 79.1, 80, 80.8, 81.5, 82.2, 82.7, 83.1, 83.4, 83.4, 83.3, 83.1, 82.6, 82, 81.2, 80.3, 79.2, 77.9, 76.5, 75, 73.4, 71.8, 70.1, 68.4, 66.7, 65, 63.3, 61.8, 60.3, 58.9, 57.7, 56.6, 55.6, 54.8, 54.1, 53.6, 53.3, 53.1, 53, 53.1, 53.2, 53.5, 53.8, 54.2, 54.6, 55, 55.3, 55.7, 56, 56.2, 56.3, 56.2, 56.1, 55.8, 55.3, 54.7, 53.9, 53, 51.9, 50.7, 49.3, 47.8, 46.2, 44.5, 42.8, 41, 39.1, 37.3, 35.5, 33.7, 32, 30.4, 28.9, 27.6, 26.3, 25.3, 24.4, 23.6, 23.1, 22.7, 22.5, 22.4, 22.5, 22.8, 23.1, 23.6, 24.2, 24.8, 25.5, 26.2, 26.9, 27.6, 28.2, 28.8, 29.3, 29.7, 29.9, 30.1, 30.1, 29.9, 29.6, 29.2, 28.6, 27.9, 27.1, 26.2, 25.1, 24, 22.9, 21.7, 20.5, 19.3, 18.1, 17, 16, 15.1, 14.3, 13.6, 13.1, 12.7, 12.5, 12.5, 12.7, 13.1, 13.6, 14.3, 15.2, 16.2, 17.3, 18.6, 19.9, 21.4, 22.8, 24.3, 25.8, 27.2, 28.7, 30, 31.3, 32.4, 33.5, 34.4, 35.2, 35.8, 36.3, 36.6, 36.8, 36.9, 36.8, 36.6, 36.3, 35.9, 35.5, 35, 34.5, 34, 33.5, 33.1, 32.7, 32.5, 32.3, 32.3, 32.4, 32.6, 33.1, 33.7, 34.4, 35.3, 36.4, 37.7, 39, 40.6, 42.2, 43.9, 45.7, 47.6, 49.4, 51.3, 53.2, 55, 56.8, 58.5, 60.1, 61.6, 62.9, 64.1, 65.1, 65.9, 66.6, 67.1, 67.5, 67.7, 67.7, 67.7, 67.4, 67.1, 66.8, 66.3, 65.8, 65.3, 64.8, 64.3, 63.9, 63.6, 63.3, 63.2, 63.2, 63.3, 63.5, 63.9, 64.4, 65.1, 66, 66.9, 68, 69.2, 70.5, 71.9, 73.4, 74.8, 76.3, 77.8, 79.2, 80.6, 81.9, 83.1, 84.2, 85.2, 86, 86.6, 87.1, 87.4, 87.5, 87.4, 87.2, 86.7, 86.2, 85.4, 84.6, 83.6, 82.5, 81.4, 80.2, 79, 77.8, 76.6, 75.5, 74.4, 73.4, 72.5, 71.8, 71.1, 70.6, 70.2, 70, 69.9, 70, 70.2, 70.5, 70.9, 71.4, 72, 72.7, 73.4, 74.1, 74.8, 75.5, 76.1, 76.6, 77, 77.3, 77.5, 77.6, 77.5, 77.2, 76.7, 76.1, 75.3, 74.3, 73.2, 71.9, 70.5, 68.9, 67.3, 65.5, 63.8, 61.9, 60.1, 58.3, 56.5, 54.7, 53.1, 51.5, 50.1, 48.8, 47.6, 46.6, 45.7, 45, 44.5, 44.1, 43.8, 43.7, 43.8, 43.9, 44.1, 44.5, 44.8, 45.2, 45.6, 46, 46.3, 46.6, 46.9, 47, 47, 46.8, 46.6, 46.2, 45.6, 44.9, 44, 43, 41.8, 40.5, 39.1, 37.6, 36, 34.3, 32.6, 30.9, 29.2, 27.5, 25.9, 24.4, 22.9, 21.6, 20.4, 19.3, 18.4, 17.7, 17.2, 16.8, 16.6, 16.6, 16.7, 17, 17.5, 18.1, 18.8, 19.5, 20.4, 21.3, 22.2, 23.1, 24, 24.8, 25.6, 26.3, 26.9, 27.4, 27.7, 28, 28, 28, 27.7, 27.4, 26.9, 26.3, 25.6, 24.8, 23.9, 23, 22, 21, 20.1, 19.1, 18.3, 17.5, 16.8, 16.3, 15.8, 15.6, 15.5, 15.5, 15.8, 16.2, 16.8, 17.5, 18.5, 19.6, 20.8, 22.2, 23.6, 25.2, 26.8, 28.4, 30.1, 31.8, 33.4, 35, 36.5, 38, 39.3, 40.5, 41.5, 42.4, 43.2, 43.8, 44.3, 44.6, 44.7, 44.8, 44.7, 44.5, 44.2, 43.9]
    }
  ];

  const xAxis = {
    data: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", "60", "61", "62", "63", "64", "65", "66", "67", "68", "69", "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", "90", "91", "92", "93", "94", "95", "96", "97", "98", "99", "100", "101", "102", "103", "104", "105", "106", "107", "108", "109", "110", "111", "112", "113", "114", "115", "116", "117", "118", "119", "120", "121", "122", "123", "124", "125", "126", "127", "128", "129", "130", "131", "132", "133", "134", "135", "136", "137", "138", "139", "140", "141", "142", "143", "144", "145", "146", "147", "148", "149", "150", "151", "152", "153", "154", "155", "156", "157", "158", "159", "160", "161", "162", "163", "164", "165", "166", "167", "168", "169", "170", "171", "172", "173", "174", "175", "176", "177", "178", "179", "180", "181", "182", "183", "184", "185", "186", "187", "188", "189", "190", "191", "192", "193", "194", "195", "196", "197", "198", "199", "200", "201", "202", "203", "204", "205", "206", "207", "208", "209", "210", "211", "212", "213", "214", "215", "216", "217", "218", "219", "220", "221", "222", "223", "224", "225", "226", "227", "228", "229", "230", "231", "232", "233", "234", "235", "236", "237", "238", "239", "240", "241", "242", "243", "244", "245", "246", "247", "248", "249", "250", "251", "252", "253", "254", "255", "256", "257", "258", "259", "260", "261", "262", "263", "264", "265", "266", "267", "268", "269", "270", "271", "272", "273", "274", "275", "276", "277", "278", "279", "280", "281", "282", "283", "284", "285", "286", "287", "288", "289", "290", "291", "292", "293", "294", "295", "296", "297", "298", "299", "300", "301", "302", "303", "304", "305", "306", "307", "308", "309", "310", "311", "312", "313", "314", "315", "316", "317", "318", "319", "320", "321", "322", "323", "324", "325", "326", "327", "328", "329", "330", "331", "332", "333", "334", "335", "336", "337", "338", "339", "340", "341", "342", "343", "344", "345", "346", "347", "348", "349", "350", "351", "352", "353", "354", "355", "356", "357", "358", "359", "360", "361", "362", "363", "364", "365", "366", "367", "368", "369", "370", "371", "372", "373", "374", "375", "376", "377", "378", "379", "380", "381", "382", "383", "384", "385", "386", "387", "388", "389", "390", "391", "392", "393", "394", "395", "396", "397", "398", "399", "400", "401", "402", "403", "404", "405", "406", "407", "408", "409", "410", "411", "412", "413", "414", "415", "416", "417", "418", "419", "420", "421", "422", "423", "424", "425", "426", "427", "428", "429", "430", "431", "432", "433", "434", "435", "436", "437", "438", "439", "440", "441", "442", "443", "444", "445", "446", "447", "448", "449", "450", "451", "452", "453", "454", "455", "456", "457", "458", "459", "460", "461", "462", "463", "464", "465", "466", "467", "468", "469", "470", "471", "472", "473", "474", "475", "476", "477", "478", "479", "480", "481", "482", "483", "484", "485", "486", "487", "488", "489", "490", "491", "492", "493", "494", "495", "496", "497", "498", "499", "500", "501", "502", "503", "504", "505", "506", "507", "508", "509", "510", "511", "512", "513", "514", "515", "516", "517", "518", "519", "520", "521", "522", "523", "524", "525", "526", "527", "528", "529", "530", "531", "532", "533", "534", "535", "536", "537", "538", "539", "540", "541", "542", "543", "544", "545", "546", "547", "548", "549", "550", "551", "552", "553", "554", "555", "556", "557", "558", "559", "560", "561", "562", "563", "564", "565", "566", "567", "568", "569", "570", "571", "572", "573", "574", "575", "576", "577", "578", "579", "580", "581", "582", "583", "584", "585", "586", "587", "588", "589", "590", "591", "592", "593", "594", "595", "596", "597", "598", "599", "600", "601", "602", "603", "604", "605", "606", "607", "608", "609", "610", "611", "612", "613", "614", "615", "616", "617", "618", "619", "620", "621", "622", "623", "624", "625", "626", "627", "628", "629", "630", "631", "632", "633", "634", "635", "636", "637", "638", "639", "640", "641", "642", "643", "644", "645", "646", "647", "648", "649", "650", "651", "652", "653", "654", "655", "656", "657", "658", "659", "660", "661", "662", "663", "664", "665", "666", "667", "668", "669", "670", "671", "672", "673", "674", "675", "676", "677", "678", "679", "680", "681", "682", "683", "684", "685", "686", "687", "688", "689", "690", "691", "692", "693", "694", "695", "696", "697", "698", "699", "700", "701", "702", "703", "704", "705", "706", "707", "708", "709", "710", "711", "712", "713", "714", "715", "716", "717", "718", "719", "720", "721", "722", "723", "724", "725", "726", "727", "728", "729", "730", "731", "732", "733", "734", "735", "736", "737", "738", "739", "740", "741", "742", "743", "744", "745", "746", "747", "748", "749", "750", "751", "752", "753", "754", "755", "756", "757", "758", "759", "760", "761", "762", "763", "764", "765", "766", "767", "768", "769", "770", "771", "772", "773", "774", "775", "776", "777", "778", "779", "780", "781", "782", "783", "784", "785", "786", "787", "788", "789", "790", "791", "792", "793", "794", "795", "796", "797", "798", "799", "800", "801", "802", "803", "804", "805", "806", "807", "808", "809", "810", "811", "812", "813", "814", "815", "816", "817", "818", "819", "820", "821", "822", "823", "824", "825", "826", "827", "828", "829", "830", "831", "832", "833", "834", "835", "836", "837", "838", "839", "840", "841", "842", "843", "844", "845", "846", "847", "848", "849", "850", "851", "852", "853", "854", "855", "856", "857", "858", "859", "860", "861", "862", "863", "864", "865", "866", "867", "868", "869", "870", "871", "872", "873", "874", "875", "876", "877", "878", "879", "880", "881", "882", "883", "884", "885", "886", "887", "888", "889", "890", "891", "892", "893", "894", "895", "896", "897", "898", "899", "900", "901", "902", "903", "904", "905", "906", "907", "908", "909", "910", "911", "912", "913", "914", "915", "916", "917", "918", "919", "920", "921", "922", "923", "924", "925", "926", "927", "928", "929", "930", "931", "932", "933", "934", "935", "936", "937", "938", "939", "940", "941", "942", "943", "944", "945", "946", "947", "948", "949", "950", "951", "952", "953", "954", "955", "956", "957", "958", "959", "960", "961", "962", "963", "964", "965", "966", "967", "968", "969", "970", "971", "972", "973", "974", "975", "976", "977", "978", "979", "980", "981", "982", "983", "984", "985", "986", "987", "988", "989", "990", "991", "992", "993", "994", "995", "996", "997", "998", "999", "1000", "1001", "1002", "1003", "1004", "1005", "1006", "1007", "1008", "1009", "1010", "1011", "1012", "1013", "1014", "1015", "1016", "1017", "1018", "1019", "1020", "1021", "1022", "1023", "1024", "1025", "1026", "1027", "1028", "1029", "1030", "1031", "1032", "1033", "1034", "1035", "1036", "1037", "1038", "1039", "1040", "1041", "1042", "1043", "1044", "1045", "1046", "1047", "1048", "1049", "1050", "1051", "1052", "1053", "1054", "1055", "1056", "1057", "1058", "1059", "1060", "1061", "1062", "1063", "1064", "1065", "1066", "1067", "1068", "1069", "1070", "1071", "1072", "1073", "1074", "1075", "1076", "1077", "1078", "1079", "1080", "1081", "1082", "1083", "1084", "1085", "1086", "1087", "1088", "1089", "1090", "1091", "1092", "1093", "1094", "1095", "1096", "1097", "1098", "1099", "1100", "1101", "1102", "1103", "1104", "1105", "1106", "1107", "1108", "1109", "1110", "1111", "1112", "1113", "1114", "1115", "1116", "1117", "1118", "1119", "1120", "1121", "1122", "1123", "1124", "1125", "1126", "1127", "1128", "1129", "1130", "1131", "1132", "1133", "1134", "1135", "1136", "1137", "1138", "1139", "1140", "1141", "1142", "1143", "1144", "1145", "1146", "1147", "1148", "1149", "1150", "1151", "1152", "1153", "1154", "1155", "1156", "1157", "1158", "1159", "1160", "1161", "1162", "1163", "1164", "1165", "1166", "1167", "1168", "1169", "1170", "1171", "1172", "1173", "1174", "1175", "1176", "1177", "1178", "1179", "1180", "1181", "1182", "1183", "1184", "1185", "1186", "1187", "1188", "1189", "1190", "1191", "1192", "1193", "1194", "1195", "1196", "1197", "1198", "1199", "1200", "1201", "1202", "1203", "1204", "1205", "1206", "1207", "1208", "1209", "1210", "1211", "1212", "1213", "1214", "1215", "1216", "1217", "1218", "1219", "1220", "1221", "1222", "1223", "1224", "1225", "1226", "1227", "1228", "1229", "1230", "1231", "1232", "1233", "1234", "1235", "1236", "1237", "1238", "1239", "1240", "1241", "1242", "1243", "1244", "1245", "1246", "1247", "1248", "1249", "1250", "1251", "1252", "1253", "1254", "1255", "1256", "1257", "1258", "1259", "1260", "1261", "1262", "1263", "1264", "1265", "1266", "1267", "1268", "1269", "1270", "1271", "1272", "1273", "1274", "1275", "1276", "1277", "1278", "1279", "1280", "1281", "1282", "1283", "1284", "1285", "1286", "1287", "1288", "1289", "1290", "1291", "1292", "1293", "1294", "1295", "1296", "1297", "1298", "1299", "1300", "1301", "1302", "1303", "1304", "1305", "1306", "1307", "1308", "1309", "1310", "1311", "1312", "1313", "1314", "1315", "1316", "1317", "1318", "1319", "1320", "1321", "1322", "1323", "1324", "1325", "1326", "1327", "1328", "1329", "1330", "1331", "1332", "1333", "1334", "1335", "1336", "1337", "1338", "1339", "1340", "1341", "1342", "1343", "1344", "1345", "1346", "1347", "1348", "1349", "1350", "1351", "1352", "1353", "1354", "1355", "1356", "1357", "1358", "1359", "1360", "1361", "1362", "1363", "1364", "1365", "1366", "1367", "1368", "1369", "1370", "1371", "1372", "1373", "1374", "1375", "1376", "1377", "1378", "1379", "1380", "1381", "1382", "1383", "1384", "1385", "1386", "1387", "1388", "1389", "1390", "1391", "1392", "1393", "1394", "1395", "1396", "1397", "1398", "1399", "1400", "1401", "1402", "1403", "1404", "1405", "1406", "1407", "1408", "1409", "1410", "1411", "1412", "1413", "1414", "1415", "1416", "1417", "1418", "1419", "1420", "1421", "1422", "1423", "1424", "1425", "1426", "1427", "1428", "1429", "1430", "1431", "1432", "1433", "1434", "1435", "1436", "1437", "1438", "1439", "1440", "1441", "1442", "1443", "1444", "1445", "1446", "1447", "1448", "1449", "1450", "1451", "1452", "1453", "1454", "1455", "1456", "1457", "1458", "1459", "1460", "1461", "1462", "1463", "1464", "1465", "1466", "1467", "1468", "1469", "1470", "1471", "1472", "1473", "1474", "1475", "1476", "1477", "1478", "1479", "1480", "1481", "1482", "1483", "1484", "1485", "1486", "1487", "1488", "1489", "1490", "1491", "1492", "1493", "1494", "1495", "1496", "1497", "1498", "1499", "1500", "1501", "1502", "1503", "1504", "1505", "1506", "1507", "1508", "1509", "1510", "1511", "1512", "1513", "1514", "1515", "1516", "1517", "1518", "1519", "1520", "1521", "1522", "1523", "1524", "1525", "1526", "1527", "1528", "1529", "1530", "1531", "1532", "1533", "1534", "1535", "1536", "1537", "1538", "1539", "1540", "1541", "1542", "1543", "1544", "1545", "1546", "1547", "1548", "1549", "1550", "1551", "1552", "1553", "1554", "1555", "1556", "1557", "1558", "1559", "1560", "1561", "1562", "1563", "1564", "1565", "1566", "1567", "1568", "1569", "1570", "1571", "1572", "1573", "1574", "1575", "1576", "1577", "1578", "1579", "1580", "1581", "1582", "1583", "1584", "1585", "1586", "1587", "1588", "1589", "1590", "1591", "1592", "1593", "1594", "1595", "1596", "1597", "1598", "1599", "1600", "1601", "1602", "1603", "1604", "1605", "1606", "1607", "1608", "1609", "1610", "1611", "1612", "1613", "1614", "1615", "1616", "1617", "1618", "1619", "1620", "1621", "1622", "1623", "1624", "1625", "1626", "1627", "1628", "1629", "1630", "1631", "1632", "1633", "1634", "1635", "1636", "1637", "1638", "1639", "1640", "1641", "1642", "1643", "1644", "1645", "1646", "1647", "1648", "1649", "1650", "1651", "1652", "1653", "1654", "1655", "1656", "1657", "1658", "1659", "1660", "1661", "1662", "1663", "1664", "1665", "1666", "1667", "1668", "1669", "1670", "1671", "1672", "1673", "1674", "1675", "1676", "1677", "1678", "1679", "1680", "1681", "1682", "1683", "1684", "1685", "1686", "1687", "1688", "1689", "1690", "1691", "1692", "1693", "1694", "1695", "1696", "1697", "1698", "1699", "1700", "1701", "1702", "1703", "1704", "1705", "1706", "1707", "1708", "1709", "1710", "1711", "1712", "1713", "1714", "1715", "1716", "1717", "1718", "1719", "1720", "1721", "1722", "1723", "1724", "1725", "1726", "1727", "1728", "1729", "1730", "1731", "1732", "1733", "1734", "1735", "1736", "1737", "1738", "1739", "1740", "1741", "1742", "1743", "1744", "1745", "1746", "1747", "1748", "1749", "1750", "1751", "1752", "1753", "1754", "1755", "1756", "1757", "1758", "1759", "1760", "1761", "1762", "1763", "1764", "1765", "1766", "1767", "1768", "1769", "1770", "1771", "1772", "1773", "1774", "1775", "1776", "1777", "1778", "1779", "1780", "1781", "1782", "1783", "1784", "1785", "1786", "1787", "1788", "1789", "1790", "1791", "1792", "1793", "1794", "1795", "1796", "1797", "1798", "1799", "1800", "1801", "1802", "1803", "1804", "1805", "1806", "1807", "1808", "1809", "1810", "1811", "1812", "1813", "1814", "1815", "1816", "1817", "1818", "1819", "1820", "1821", "1822", "1823", "1824", "1825", "1826", "1827", "1828", "1829", "1830", "1831", "1832", "1833", "1834", "1835", "1836", "1837", "1838", "1839", "1840", "1841", "1842", "1843", "1844", "1845", "1846", "1847", "1848", "1849", "1850", "1851", "1852", "1853", "1854", "1855", "1856", "1857", "1858", "1859", "1860", "1861", "1862", "1863", "1864", "1865", "1866", "1867", "1868", "1869", "1870", "1871", "1872", "1873", "1874", "1875", "1876", "1877", "1878", "1879", "1880", "1881", "1882", "1883", "1884", "1885", "1886", "1887", "1888", "1889", "1890", "1891", "1892", "1893", "1894", "1895", "1896", "1897", "1898", "1899", "1900", "1901", "1902", "1903", "1904", "1905", "1906", "1907", "1908", "1909", "1910", "1911", "1912", "1913", "1914", "1915", "1916", "1917", "1918", "1919", "1920", "1921", "1922", "1923", "1924", "1925", "1926", "1927", "1928", "1929", "1930", "1931", "1932", "1933", "1934", "1935", "1936", "1937", "1938", "1939", "1940", "1941", "1942", "1943", "1944", "1945", "1946", "1947", "1948", "1949", "1950", "1951", "1952", "1953", "1954", "1955", "1956", "1957", "1958", "1959", "1960", "1961", "1962", "1963", "1964", "1965", "1966", "1967", "1968", "1969", "1970", "1971", "1972", "1973", "1974", "1975", "1976", "1977", "1978", "1979", "1980", "1981", "1982", "1983", "1984", "1985", "1986", "1987", "1988", "1989", "1990", "1991", "1992", "1993", "1994", "1995", "1996", "1997", "1998", "1999"],
    hideTicks: true
  };

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

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.

The context describes the hovered x: dataIndex, axisValue, axisLabel, series (those with a value there), 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 field-by-field reference — the contract is identical across charts.

tooltipRenderer — delta against the previous point Open in Storybook
Show code for each technology
<md-line-chart label="Sessions" curve="smooth" grid="horizontal" legend="bottom" show-marks></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Sessions",
      color: "primary",
      data: [1200, 1850, 1640, 2310, 2780, 2450]
    },
    {
      label: "Signups",
      color: "tertiary",
      data: [180, 260, 240, 390, 480, 410]
    }
  ];

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

  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 card = el('div', 'min-width:190px;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));

    ctx.series.forEach((r) => {
      const row = el('div',
        'display:grid;grid-template-columns:9px 1fr auto;gap:9px;align-items:center;padding:2.5px 0');
      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));
      card.append(row);
    });

    // Series with no reading here get their own dimmed row rather than
    // vanishing — an absence the reader can see beats a silent omission.
    ctx.missing.forEach((r) => {
      const row = el('div',
        'display:grid;grid-template-columns:9px 1fr auto;gap:9px;align-items:center;padding:2.5px 0;opacity: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', null, 'no reading'));
      card.append(row);
    });

    return card;
  };

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

A race sweeps the plot from the first sample to the last, revealing the line as it goes. There is no race prop — you reassign series on a timer and the chart redraws. Keep the x axis fixed across frames so it doesn’t rescale under the animation.

Press play, or drag the slider Open in Storybook

The shape of it in every technology — a fixed x axis, a progress value driven by requestAnimationFrame, and a series array rebuilt from it on each frame. Two rules carry over unchanged: keep the whole axis present from the start (only the data grows, with null for the rounds that have not happened yet), and stop the loop when the element goes away.

<md-line-chart id="race" label="Championship" curve="linear" series-labels
             no-animation legend="bottom" grid="horizontal"></md-line-chart>
<md-button id="race-play" variant="filled">Play</md-button>

<script type="module">
const ROUNDS = 10;
const DRIVERS = [
{ label: 'Antonelli', color: 'primary', cum: [0, 18, 47, 72, 100, 131, 156, 156, 171, 179, 204] },
{ label: 'Hamilton', color: 'error', cum: [0, 12, 33, 41, 51, 72, 90, 115, 125, 147, 159] },
{ label: 'Russell', color: 'tertiary', cum: [0, 25, 51, 63, 80, 88, 88, 106, 131, 154, 154] },
];

const chart = document.getElementById('race');
const xs = Array.from({ length: ROUNDS + 1 }, (_, r) => r);

// The FULL axis is set once and never changes — if it grew with the data the
// scale would rescale under the animation and the lines would slide sideways.
chart.xAxis = { data: xs, scale: 'value', label: 'Round' };

let progress = ROUNDS, raf = 0, last = 0;

function apply() {
chart.series = DRIVERS.map((d) => ({
  label: d.label,
  color: d.color,
  data: xs.map((r) => (r <= progress ? d.cum[r] : null)),
}));
}

function frame(now) {
// A rAF loop outlives the element unless you stop it.
if (!chart.isConnected) return cancelAnimationFrame(raf);
if (!last) last = now;
progress = Math.min(ROUNDS, progress + (now - last) * (ROUNDS / 14000));
last = now;
apply();
raf = progress < ROUNDS ? requestAnimationFrame(frame) : 0;
}

document.getElementById('race-play').addEventListener('click', () => {
progress = 0;
last = 0;
raf = requestAnimationFrame(frame);
});

apply();
</script>
EventCancelableDetailFires
mdMarkerClicknoMdChartClickDetail<MdChartXYSeries>A data-point marker is clicked
mdLineClicknoMdChartClickDetail<MdChartXYSeries>A series line is clicked between its points
mdAreaClicknoMdChartClickDetail<MdChartXYSeries>A filled area is clicked
mdAxisClicknoMdChartAxisClickDetailThe plot background, or Enter / Space 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

The three click events share one payload, so one handler can serve markers, lines and areas. Every field is already in your coordinates — the series object you passed in, the index within it, the original axis value — so drill-down and selection need no re-mapping to chart internals.

// `T` defaults to `unknown`; md-line-chart emits it as
// `MdChartClickDetail<MdChartXYSeries>`, so name the argument to get a typed
// `series` back.
interface MdChartClickDetail<T = unknown> {
seriesIndex: number; // 0-based index into `series`
seriesId?: string; // the series' `id`, when one was given
dataIndex: number; // index of the point within that series
value: number | null; // raw value — `null` for an empty slot
axisValue?: string | number | Date; // original x value at that column
series: T; // the series object you passed in
nativeEvent: PointerEvent | MouseEvent | KeyboardEvent;
}
interface MdChartAxisClickDetail {
dataIndex: number; // nearest x position
axisValue?: string | number | Date;
// One row per VISIBLE series at that x — `dataIndex` is -1 where a series
// has no point there. Series hidden by a legend toggle are left out.
seriesValues: MdChartAxisSeriesValue[];
nativeEvent: PointerEvent | MouseEvent | KeyboardEvent;
}
interface MdChartAxisSeriesValue {
seriesIndex: number;
seriesId?: string;
label: string;
dataIndex: number;
value: number | null;
}
interface MdChartHoverDetail {
dataIndex: number; // highlighted x position
axisValue?: string | number | Date;
seriesIndices: number[]; // one entry under tooltip="item",
} // every series at that x under "axis"

This is the chart the snippets below wire up. Click a marker, click the plot background, toggle a legend chip or drag the zoom slider — each payload lands in the footer under the plot.

Live preview — click, toggle and zoom to fire the events Open in Storybook
No events yet — click a point, the plot background, a legend chip, or drag the zoom slider.
<md-line-chart id="sessions" label="Sessions" zoom="slider" grid="horizontal"
             legend="top-end" show-marks></md-line-chart>

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

chart.xAxis = { data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'] };
chart.series = [
{ label: 'Sessions', color: 'primary', data: [1200, 1850, 1640, 2310, 2780, 2450, 2900, 3120] },
{ label: 'Signups', color: 'tertiary', data: [180, 260, 240, 390, 480, 410, 520, 610] },
];
chart.markLines = [{ value: 4, label: 'Launch' }];
chart.valueFormatter = (v) => new Intl.NumberFormat('en-US').format(v ?? 0);

chart.addEventListener('mdMarkerClick', (e) =>
console.log(e.detail.series.label, e.detail.axisValue, e.detail.value));
chart.addEventListener('mdLineClick', (e) =>
console.log('line', e.detail.seriesIndex, e.detail.dataIndex));
chart.addEventListener('mdAxisClick', (e) =>
console.log('axis', e.detail.axisValue, e.detail.seriesValues));
chart.addEventListener('mdLegendClick', (e) =>
console.log('legend', e.detail.seriesIndex, e.detail.selected));
chart.addEventListener('mdZoom', (e) =>
console.log(e.detail.reset ? 'reset' : [e.detail.startIndex, e.detail.endIndex]));
</script>

Properties

PropertyAttributeTypeDefaultReflects
labellabelstring''
subtitlesubtitlestring | undefined
markLinesJS onlyMdChartMarkLine[] | undefined
titleAligntitle-alignMdChartTitleAlign'start'
seriesJS onlyMdChartXYSeries[][]
xAxisJS onlyMdChartAxis | undefined
yAxisJS onlyMdChartAxis | undefined
yAxesJS onlyMdChartAxis[] | undefined
curvecurve'linear' | 'smooth' | 'monotone' | 'step' | 'step-before' | 'step-middle''smooth'
stackstackMdChartStackMode'none'
connectNullsconnect-nullsbooleanfalse
showMarksshow-marksbooleanfalse
lineWidthline-widthnumber
markSizemark-sizenumber
areaareabooleanfalse
showLineshow-linebooleantrue
legendlegendMdChartLegendPosition | 'none''top-end'Yes
tooltiptooltipMdChartTooltipTrigger'axis'
gridgrid'none' | 'horizontal' | 'vertical' | 'both''horizontal'
axisTicksaxis-ticksbooleanfalse
invertedinvertedbooleanfalse
showLabelsshow-labelsbooleanfalse
seriesLabelsseries-labelsbooleanfalse
zoomzoom'none' | 'inside' | 'slider' | 'both''none'
localelocalestring''
labelEmptylabel-emptystring'No data to display'
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%'
labelZoomStartlabel-zoom-startstring'Zoom range start'
labelZoomEndlabel-zoom-endstring'Zoom range end'
summarysummarystring''
tableLabelsJS only{ x?: string
valueFormatterJS only(value: number | null | undefined) => string
tooltipRendererJS onlyMdChartTooltipRenderer
heightPropheightstring
noAnimationno-animationbooleanfalse
animationanimationMdChartAnimation'expressive'
animationDurationanimation-durationnumber
loadingloadingbooleanfalseYes
loadingLabelloading-labelstring'Loading chart…'
densitydensity0 | -1 | -2 | -3 | -40Yes

Methods

MethodParameters
refreshTheme()none
resize()none
replay()none
toDataURL()none
getInstance()none
setZoom()startIndex: number, endIndex: number
resetZoom()none

Slots

SlotDescription
headerRenders above the plot
emptyEmpty-state content (overrides default text)
loader
loading
footerRenders below the plot

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-line-chart-block-sizeChart block-size (default: aspect-ratio driven)
--md-line-chart-min-block-sizeFloor for the chart's height (default: 160px)
--md-line-chart-aspect-ratioWidth:height ratio (default: 16/9)
--md-line-chart-backgroundSurface behind the chart (default: --md-sys-color-surface-container-low;
--md-line-chart-paddingInset padding around the canvas (default: 16px)
--md-line-chart-shapeContainer corner radius (default: --md-sys-shape-corner-large)
--md-line-chart-empty-colorEmpty-state text colour
--md-line-chart-empty-backgroundEmpty-state surface (default: the chart's own background)
--md-line-chart-empty-fontEmpty-state font FAMILY — not a font shorthand
--md-line-chart-empty-font-sizeEmpty-state font size (default: 14px)
--md-line-chart-empty-icon-sizeSlotted Material Symbols icon size (default: 40px)
--md-line-chart-zoom-sizeHeight reserved for the zoom slider (default: 28px)
--md-line-chart-zoom-track-colorZoom slider track colour (md-slider inactive track)
--md-line-chart-zoom-window-colorZoom slider selected-window colour (md-slider active track)
--md-line-chart-zoom-handle-colorZoom slider thumb + drag-band edge colour
--md-line-chart-zoom-band-colorDrag-to-zoom selection fill

CSS Shadow Parts

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

PartDescription
zoomZoom slider container
zoom-sliderThe md-slider itself
zoom-panDrag-to-pan grip over the window
headerHeader slot wrapper (title, toolbar)
canvasPlot canvas container
emptyEmpty-state container
loading
zoom-bandDrag-to-zoom selection band
footerFooter slot wrapper (caption, legend)
zoom-trackZoom slider track (forwarded)
zoom-windowSelected window / active track (forwarded)
zoom-handleZoom slider thumb (forwarded)
  • The host gets role="figure" and an aria-label summary such as “Revenue, line chart with 2 series, from Jan to Jun.” label, summary, label-plot and label-point carry that description; tableLabels names the underlying data dimensions. Treat them as required, not optional.
  • A visually-hidden <table> mirrors every data point. Provide the numbers as a visible table wherever precision matters.
  • Keyboard: the plot is a focus stop. Arrow keys walk the data points, Home / End jump to the ends, Escape leaves, and Enter / Space emits mdAxisClick for the focused position. Arrows follow the reading direction, so in RTL the left arrow advances.
  • Each move drives the same crosshair and tooltip a mouse gets, and writes a polite aria-live announcement.
  • Legend chips are toggle buttons carrying aria-pressed, so a screen reader says whether a series is shown or hidden.
  • Don’t distinguish series by colour alone — legends, series-labels, and differing curve or mark styles all help.
  • loading plus loading-label makes the fetch state perceivable.
  • Respects prefers-reduced-motion; no-animation forces the static path. Inherits Windows high-contrast colours via forced-color-adjust: auto.
Keyboard — tab into the plot, then arrow keys, Home / End, Enter, Escape
Show code for each technology
<md-line-chart label="Sessions per week" summary="Sessions per week, line chart, 8 points from W1 to W8" legend="bottom" grid="horizontal" show-marks></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Sessions",
      color: "primary",
      data: [1200, 1850, 1640, 2310, 2780, 2450, 2900, 3120]
    }
  ];

  const xAxis = {
    data: ["W1", "W2", "W3", "W4", "W5", "W6", "W7", "W8"]
  };

  const yAxis = {
    label: "Sessions"
  };

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

RTL — inside a dir="rtl" parent the whole plot mirrors: the value axis and its labels move to the right, the category axis runs right-to-left, and the tooltip opens along the reading direction. dataIndex, axisValue and every event payload are identical in both directions — only pixels move. See RTL.

<div dir="rtl">
<md-line-chart label="Sessions" legend="top-end"></md-line-chart>
</div>
Same markup and the same data, dir=ltr vs dir=rtl Open in Storybook
ltr rtl

Direction, density and locale are all declarative, and none of them touches the data: the arrays stay in source order in every technology.

<md-line-chart id="sessions" dir="rtl" density="-2" locale="ar-EG"
             label="الجلسات" legend="top-end" grid="horizontal" show-marks></md-line-chart>

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

// Source order, never pre-reversed — the engine mirrors the scene, not the data.
chart.xAxis = { data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'] };
chart.series = [{ label: 'Sessions', color: 'primary', data: [400, 460, 520, 610, 580, 700] }];
</script>

Mirroring is the chart’s job, not the data’s

Section titled “Mirroring is the chart’s job, not the data’s”

The engine mirrors the scene — it never touches your arrays. So keep series and xAxis.data in their natural order and let the direction do the work. Reversing them by hand for an RTL page mirrors twice: the axis runs right-to-left as it should, but the values are laid onto it backwards, so the trend reads inverted and every dataIndex in an event payload points at the wrong month.

Both in dir=rtl — source order vs hand-reversed arrays
correct
wrong
Density 0 through -4 — padding, axis labels and the legend all taper
dir=rtl at density -2
Show code for each technology
<md-line-chart dir="rtl" density="-2" label="Sessions" legend="top-end" grid="horizontal" show-marks></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Sessions",
      color: "primary",
      data: [400, 460, 520, 610, 580, 700]
    }
  ];

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

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

Densitydensity="-1…-4" tightens padding and label sizes. See Density.

i18n — number and date formatting follows the locale prop, not the document’s lang: Intl with no locale resolves to the browser’s, which is rarely what a localised page wants.

<md-line-chart locale="de-DE"></md-line-chart>
<!-- the axis reads 1.234,5 rather than 1,234.5 -->
locale=de-DE — the axis and tooltip switch separators Open in Storybook
Show code for each technology
<md-line-chart locale="de-DE" label="Umsatz" subtitle="Erstes Halbjahr" legend="bottom" grid="horizontal" show-marks label-point="%x%: %values%"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Umsatz",
      color: "primary",
      data: [1234.5, 1810.25, 1640.75, 2310.5, 2780.4, 2450.9]
    }
  ];

  const xAxis = {
    data: ["Jan", "Feb", "Mär", "Apr", "Mai", "Jun"]
  };

  const yAxis = {
    label: "EUR"
  };

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

Every string the chart renders itself is a prop, so it can be handed over from whatever i18n engine the app already uses: label, subtitle, summary (replaces the generated aria-label), label-empty, loading-label, label-plot, label-point, label-zoom-start / label-zoom-end, and tableLabels (a property — it takes an object). label-point uses the %x% / %values% percent tokens; keep them when translating.

Two of these are not namespaced to the component. The axis and gridlines are drawn by the shared chart engine, so they read family-wide properties that every chart type honours:

Custom propertyDrawsDefault
--md-chart-axis-colorThe axis line and its ticks--md-sys-color-outline-variant
--md-chart-grid-colorThe gridlines across the plot--md-sys-color-outline-variant at 50% alpha

Both fall back to the same role — MD3 data-viz keeps chart chrome light — but the gridlines are dimmed to half alpha so they read as faint guides beneath a slightly more present axis line. An explicit override is used verbatim, not dimmed.

They’re read off the element’s computed style, so setting them on any ancestor tints every chart beneath it — a dashboard section, or the whole page — without touching each component.

.dashboard {
--md-chart-axis-color: #7c6fd6;
--md-chart-grid-color: rgba(124, 111, 214, 0.22);
}
Tinted axis and grid, set on the wrapper
Show code for each technology
<md-line-chart label="Signups" grid="both" legend="none" show-marks></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Signups",
      color: "#7c6fd6",
      data: [12, 19, 15, 24, 28, 22, 30]
    }
  ];

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

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

The rest are namespaced to the component:

Custom propertyPurposeDefault
--md-line-chart-block-sizeChart box heightauto (the aspect ratio drives it)
--md-line-chart-min-block-sizeHeight floormax(120px, 160px + density × 8px)
--md-line-chart-aspect-ratioWidth : height16 / 9
--md-line-chart-backgroundSurface behind the chart--md-sys-color-surface-container-low
--md-line-chart-paddingInset around the canvasmax(8px, 16px + density × 2px)
--md-line-chart-shapeContainer corner radiusmax(8px, 16px + density × 2px)
--md-line-chart-empty-colorEmpty-state text--md-sys-color-on-surface-variant
--md-line-chart-empty-backgroundEmpty-state surfacethe chart’s own background
--md-line-chart-empty-fontEmpty-state font family (not a shorthand)--md-sys-typescale-body-medium-font-family
--md-line-chart-empty-font-sizeEmpty-state font size--md-sys-typescale-body-medium-font-size (14px)
--md-line-chart-empty-icon-sizeSlotted empty-state icon40px
--md-line-chart-zoom-sizeHeight reserved for the zoom slider28px
--md-line-chart-zoom-track-colorSlider track--md-sys-color-surface-container-highest
--md-line-chart-zoom-window-colorSelected window--md-sys-color-secondary-container
--md-line-chart-zoom-handle-colorThumbs and drag-band edges--md-sys-color-primary
--md-line-chart-zoom-band-colorDrag-to-zoom selection fill--md-sys-color-primary at 16%
md-line-chart.dashboard {
--md-line-chart-background: var(--md-sys-color-surface-container);
--md-line-chart-padding: 16px;
--md-line-chart-shape: 16px;
--md-line-chart-aspect-ratio: 16 / 6;
}
A themed instance — surface, padding, corner and aspect ratio
Show code for each technology
<md-line-chart label="Sessions" legend="top-end" grid="horizontal" show-marks style="--md-line-chart-background: var(--md-sys-color-surface-container); --md-line-chart-padding: 16px; --md-line-chart-shape: 16px; --md-line-chart-aspect-ratio: 16 / 6;"></md-line-chart>

<script type="module">
  const series = [
    {
      label: "Sessions",
      color: "primary",
      data: [400, 460, 520, 610, 580, 700]
    },
    {
      label: "Signups",
      color: "tertiary",
      data: [120, 150, 140, 210, 190, 260]
    }
  ];

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

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

Series colours are best customised with the per-series color field, which accepts an MD3 role (primary, tertiary, …) or any CSS colour.

CSS partsheader, canvas, footer, empty, loading, zoom, zoom-slider, zoom-pan, zoom-band, plus three forwarded from the zoom slider: zoom-track, zoom-window, zoom-handle. Slotsheader, footer, empty, loader (older alias loading).

md-line-chart.framed::part(canvas) {
border: 1px dashed var(--md-sys-color-outline-variant);
border-radius: 12px;
}
md-line-chart.framed::part(footer) {
border-block-start: 1px solid var(--md-sys-color-outline-variant);
padding-block-start: 8px;
}
::part(canvas) and ::part(footer) overridden from outside the shadow root Open in Storybook
Source: internal analytics, sampled hourly

The parts are addressed from a stylesheet, so the wiring is the same everywhere — give the element a class, style ::part() outside the shadow root, and put the caption in the footer slot.

<style>
.framed::part(canvas) {
  border: 1px dashed var(--md-sys-color-outline-variant);
  border-radius: 12px;
}
.framed::part(footer) {
  border-block-start: 1px solid var(--md-sys-color-outline-variant);
  padding-block-start: 8px;
}
</style>

<md-line-chart id="framed" class="framed" label="Sessions" legend="top-end"
             grid="horizontal" show-marks>
<span slot="footer">Source: internal analytics, sampled hourly</span>
</md-line-chart>

<script type="module">
const chart = document.getElementById('framed');
chart.series = [{ label: 'Sessions', color: 'primary', data: [400, 460, 520, 610, 580, 700] }];
chart.xAxis = { data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'] };
</script>

md-area-chart · md-bar-chart · md-pie-chart · md-sparkline · md-slider · md-progress-indicator

For AI Agents — md-line-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-line-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-line-chart readme.md

# md-line-chart

<!-- llm:meta
tag: md-line-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)
-->

**A trend over a continuous axis.** One or many series, six curve types,
multiple y-axes, mark lines, drag-zoom and a slider band, plus a built-in
loading state.

> ⚠️ **Not a Material Design 3 component.** M3 ships no charts. The engine is
> **in-house Canvas2D with a DOM overlay** (`utils/charts/engine`).

> 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

- **Change over time** or over any continuous axis.
- Comparing the **shape** of several series.
- Dense series the user needs to zoom into.
- Irregular sampling — series whose points carry their own x values.

## When NOT to use

| Situation | Use instead |
|---|---|
| Comparing discrete categories | `md-bar-chart` |
| Emphasising cumulative volume | `md-area-chart` |
| Parts of a whole | `md-pie-chart` |
| An inline micro-trend in a cell | `md-sparkline` |
| Very few points (2–3) | Plain text or a bar chart |
| Unordered categories | `md-bar-chart` — lines imply continuity |

## Decision cues

| Need | Setting |
|---|---|
| Curve style | `curve="linear\|smooth\|monotone\|step\|step-before\|step-middle"` |
| Stack the series | `stack="normal\|percentage"` |
| Streamgraph baseline | `stack="silhouette"` or `stack="wiggle"` |
| Fill under the line | `area` (or use `md-area-chart`) |
| Points visible | `show-marks` |
| Scatter-like plot | `show-line="false"` + `show-marks` |
| Bridge gaps in data | `connect-nulls` |
| A second scale | `yAxes` (JS property) + `series[].yAxisIndex` |
| Thresholds / event markers | `markLines` (JS property) |
| Zoom | `zoom="inside\|slider\|both"` |
| Async data | `loading` (+ `loading-label`) |
| Swap the axes | `inverted` |
| Name each line at its end | `series-labels` |

## API contract

```html
<md-line-chart
  label="Sessions"
  subtitle="Last 30 days"
  title-align="start|center|end"                 <!-- default: start -->
  curve="linear|smooth|monotone|step|step-before|step-middle"   <!-- default: smooth -->
  stack="none|normal|percentage|silhouette|wiggle"   <!-- default: none -->
  connect-nulls                                  <!-- default: off -->
  show-marks                                     <!-- default: off -->
  area                                           <!-- default: off -->
  show-line                                      <!-- default: ON; set show-line="false" to drop the stroke -->
  inverted                                       <!-- default: off -->
  line-width="2.5"                               <!-- default: 2.5 -->
  mark-size="4"                                  <!-- default: per-symbol -->
  grid="none|horizontal|vertical|both"           <!-- default: horizontal -->
  axis-ticks                                     <!-- default: off -->
  show-labels                                    <!-- default: off -->
  series-labels                                  <!-- default: off -->
  legend="top|bottom|left|right|top-start|top-end|bottom-start|bottom-end|none"
                                                 <!-- default: top-end -->
  tooltip="axis|item|none"                       <!-- default: axis -->
  zoom="none|inside|slider|both"                 <!-- default: none -->
  locale="en-US"                                 <!-- default: "" (browser locale) -->
  height="320px"                                 <!-- default: aspect-ratio driven -->
  summary=""                                     <!-- replaces the generated aria-label -->
  loading                                        <!-- default: off -->
  loading-label="Loading chart…"
  label-empty="No data to display"
  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"
  animation="expressive|grow|fade|draw|stagger|none"   <!-- default: expressive -->
  animation-duration="700"                       <!-- default: engine default -->
  no-animation                                   <!-- default: off -->
  density="-1|-2|-3|-4"                          <!-- default: 0 (uncompacted) -->
></md-line-chart>
```

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

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

// Bare y values, positioned by index against xAxis.data:
chart.xAxis  = { data: ['Jan', 'Feb', 'Mar'], scale: 'category' };
chart.series = [{ label: 'Sessions', data: [10, 14, 9] }];

// …or points that carry their own x (irregular sampling):
chart.xAxis  = { scale: 'time' };
chart.series = [{
  label: 'Sessions',
  data: [{ x: '2026-01-04', y: 10 }, { x: '2026-01-19', y: 14 }],
}];

chart.yAxis     = { label: 'Sessions', min: 0 };
chart.yAxes     = [{ label: 'Sessions' }, { label: 'Revenue', position: 'right' }];
chart.markLines = [{ value: '2026-03-01', label: 'Launch', dash: 'dashed' }];
chart.tableLabels    = { x: 'Date', series: 'Metric' };
chart.valueFormatter = (v) => new Intl.NumberFormat('en-US').format(v ?? 0);
chart.tooltipRenderer = (ctx) => `${ctx.axisLabel} — ${ctx.series.length} series`;
```

**Events** — `mdMarkerClick`, `mdLineClick`, `mdAreaClick` (all
`MdChartClickDetail`), `mdAxisClick` (`MdChartAxisClickDetail` — the nearest x
plus every visible series' value there), `mdLegendClick`
(`{ seriesIndex, seriesId?, selected }`), `mdHover` (throttled to one per
frame), `mdZoom` (`{ startIndex, endIndex, reset }`), `mdReady`. All are the
Stencil default — they bubble and cross shadow boundaries.

**Methods** — `resize()`, `replay()`, `toDataURL()`, `getInstance()`,
`setZoom(startIndex, endIndex)`, `resetZoom()`. All are async. There is **no
`drill()`** here — that lives on `md-bar-chart` and `md-pie-chart`.

**Slots** — `header` (above the plot), `footer` (below it), `empty` (replaces
`label-empty`), 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`, `yAxes`, `markLines`, `tableLabels`,
  `valueFormatter` and `tooltipRenderer` are JS properties.**
- **The axis field is `scale`, not `type`**: `{ scale: 'category' | 'time' |
  'value' | 'log' }`. A `type` key is silently ignored.
- **A series is either all bare values or all points.** Mixing them drops the
  bare ones, which have no x to sit at. A gap is `null` (or `{ x, y: null }`).
- `yAxes` (plural, array) is how you get a **second scale**, with
  `series[].yAxisIndex` selecting one; it supersedes the single `yAxis`. Don't
  set both.
- `show-line="false"` with `show-marks` gives a scatter-like plot. A per-series
  `series[].stroke` still wins over the chart-level prop.
- **`mdMarkerClick.dataIndex` addresses the clicked series' OWN `data` array**,
  which differs from the merged axis index when series carry their own x.
  `mdAxisClick` reports the axis `dataIndex` plus each series' own index.
- **Zoom is a view, not a mutation.** `series` / `xAxis` are untouched and every
  index reported back to you stays **absolute**. `setZoom()` takes absolute
  indices, not fractions; `zoom="inside"` also resets on a double-click in the
  plot.
- **Legend toggles survive a data re-feed** — a series the reader hid stays
  hidden when `series` is reassigned, keyed by `id`, else `label`, else
  position. An explicit `series[i].hidden` still wins.
- **`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.
- Call **`resize()`** when the container changes size in a way the layout
  doesn't propagate (e.g. a revealed tab panel).
- `mdReady` fires after the first frame is drawn — gate `toDataURL()` on it.
- `getInstance()` returns the underlying engine. It is an escape hatch; anything
  done through it is outside this component's contract.
- `label-point` uses **`%x%` / `%values%`** percent tokens — keep both when
  translating. `summary` replaces the whole generated `aria-label` sentence.
- 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 |
|---|---|
| Use lines only for ordered, continuous data | Don't connect unordered categories — it implies continuity that isn't there |
| Keep the series count small (≤ ~5) | Don't draw a plate of spaghetti |
| Show gaps in the data as gaps | Don't `connect-nulls` when missing data is meaningful |
| Label the axes and provide units | Don't leave a bare numeric axis |
| Use `markLines` for targets and thresholds | Don't annotate with free-floating text |
| Use `zoom` for dense series | Don't downsample silently |
| 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 |
| Start the y-axis at zero when magnitude matters | Don't truncate the axis to exaggerate a trend |
| Show `loading` while fetching | Don't render an empty plot that looks like zero data |

---

## Patterns

```html
<md-line-chart id="c" label="Sessions" height="320px" zoom="both" grid="horizontal"></md-line-chart>

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

  c.xAxis  = { scale: 'time', label: 'Date' };
  c.series = [
    { label: 'Sessions', data: [{ x: '2026-01-01', y: 120 }, { x: '2026-01-08', y: 168 }] },
    { label: 'Signups',  data: [{ x: '2026-01-01', y: 12 },  { x: '2026-01-08', y: 21 }] },
  ];
  c.markLines = [{ value: '2026-01-05', label: 'Launch' }];
  c.valueFormatter = (v) => new Intl.NumberFormat('en-US').format(v ?? 0);

  // setZoom takes absolute data INDICES — show points 10 through 40.
  c.addEventListener('mdReady', () => c.setZoom(10, 40));
  c.addEventListener('mdZoom',  (e) => syncOtherChart(e.detail.startIndex, e.detail.endIndex));
  c.addEventListener('mdMarkerClick', (e) => inspect(e.detail.seriesIndex, e.detail.dataIndex));
</script>
```

```html
<!-- Two independent scales -->
<md-line-chart id="dual" label="Sessions vs revenue"></md-line-chart>
<script type="module">
  const dual = document.getElementById('dual');
  dual.yAxes  = [{ label: 'Sessions' }, { label: 'Revenue', position: 'right' }];
  dual.xAxis  = { data: ['Mon', 'Tue', 'Wed'] };
  dual.series = [
    { label: 'Sessions', data: [120, 168, 140] },
    { label: 'Revenue',  data: [1.2, 1.9, 1.5], yAxisIndex: 1 },
  ];
</script>
```

```html
<!-- Scatter-like: marks, no stroke -->
<md-line-chart show-line="false" show-marks curve="linear"></md-line-chart>

<!-- Async -->
<md-line-chart loading loading-label="Loading sessions…"></md-line-chart>

<!-- Stepped, no animation -->
<md-line-chart curve="step-before" no-animation></md-line-chart>
```

## Anti-patterns

| ❌ Wrong | ✅ Right | Why |
|---|---|---|
| `chart.xAxis = { type: 'time' }` | `chart.xAxis = { scale: 'time' }` | The field is `scale`; `type` is ignored. |
| `series` / `markLines` as attributes | Assign them in JS | Arrays don't cross the attribute boundary. |
| `stack="percent"` | `stack="percentage"` | `percent` is not a value the prop accepts. |
| `chart.setZoom(0.6, 1)` | `chart.setZoom(24, 40)` | Zoom takes absolute data indices, not fractions. |
| Setting both `yAxis` and `yAxes` | Pick one | `yAxes` supersedes the single axis. |
| Mixing `[1, 2, null]` and `{ x, y }` in one series | Use one form throughout | The bare values are dropped — they have no x. |
| `chart.drill(...)` | Use `md-bar-chart` / `md-pie-chart` | The line chart has no `drill()`. |
| `connect-nulls` on genuinely missing data | Leave the gap | It fabricates data. |
| A line chart over unordered categories | `md-bar-chart` | Lines imply continuity. |
| `toDataURL()` before `mdReady` | Wait for the event | Nothing drawn yet. |
| Truncated y-axis to dramatise a trend | Start at zero, or state the range | Misleading. |
| Chart in a hidden tab with no `resize()` | Call it on reveal | Renders at zero size. |
| Driving state via `getInstance()` | Use props and methods | Outside the contract. |

## Accessibility, RTL, density, i18n

**Accessibility**
- The host is `role="figure"` with a generated `aria-label` summary (`summary`
  replaces it wholesale), and the plot is a focusable `role="application"`
  region named by `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 is rendered inside the component;
  `tableLabels` translates its column headings.
- **Provide the numbers as a visible table** wherever precision matters.
- Don't distinguish series by colour alone — the legend, `series-labels`, and
  differing curve/mark styles all help.
- The zoom slider is an `md-slider`; translate `label-zoom-start` /
  `label-zoom-end`.
- `loading` sets `aria-busy="true"` and names the overlay with `loading-label`.
- The engine already honours `prefers-reduced-motion`; `no-animation` turns the
  entrance off outright.

**RTL** — the finished frame is mirrored under `dir="rtl"`: the value axis and
its gutter move to the right, the x axis runs 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, corner
radius and 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"` — `density="0"` will not do it.

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

## Related components

`md-area-chart` · `md-bar-chart` · `md-pie-chart` · `md-sparkline` ·
`md-slider` · `md-progress-indicator`

## Theming

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

<!-- Auto Generated Below -->


## Overview

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

Pattern parity with MUI X Charts:
  • multi-series with per-series colour / label / curve
  • category, time, value, log scales
  • curve types: linear / smooth / monotone / step variants
  • stacking: normal / percentage
  • markers, area fills, gap bridging (`connectNulls`)
  • legend with toggle + custom position
  • hover crosshair + tooltip
  • emits `mdMarkerClick`, `mdLineClick`, `mdAreaClick`, `mdAxisClick`,
    `mdLegendClick`, `mdHover`

MD3 expressive layer on top:
  • palette resolves to `--md-sys-color-*` tokens
  • dark theme + brand re-themes via CSS variables only
  • a11y: role="figure" + screen-reader-only data table
  • fully responsive via ResizeObserver

## 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`                                                                                                      |
| `area`              | `area`               | Render the line area as a filled gradient below the line.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `boolean`                                                                                                                               | `false`                                                                                                          |
| `axisTicks`         | `axis-ticks`         | Draw small perpendicular tick marks on the axes.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 | `boolean`                                                                                                                               | `false`                                                                                                          |
| `connectNulls`      | `connect-nulls`      | Whether `null` values are bridged with a straight line.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | `boolean`                                                                                                                               | `false`                                                                                                          |
| `curve`             | `curve`              | Default curve interpolation. Overridable per-series.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             | `"linear" \| "monotone" \| "smooth" \| "step" \| "step-before" \| "step-middle"`                                                        | `'smooth'`                                                                                                       |
| `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`                                                                                                              |
| `grid`              | `grid`               | Gridlines: horizontal (y ticks), vertical (x ticks), both, or none.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | `"both" \| "horizontal" \| "none" \| "vertical"`                                                                                        | `'horizontal'`                                                                                                   |
| `heightProp`        | `height`             | Force a specific height (CSS length).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `string \| undefined`                                                                                                                   | `undefined`                                                                                                      |
| `inverted`          | `inverted`           | Transpose the axes — the x-axis data runs vertically and the values run horizontally (e.g. a temperature-by-altitude spline). Best with `curve="smooth"`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `boolean`                                                                                                                               | `false`                                                                                                          |
| `label`             | `label`              | Optional chart title rendered above the plot.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | `string`                                                                                                                                | `''`                                                                                                             |
| `labelEmpty`        | `label-empty`        | Text shown when there is no data. The `empty` slot still overrides it — this is the prop form, for handing a string straight from a dictionary.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | `string`                                                                                                                                | `'No data to display'`                                                                                           |
| `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 made as keyboard focus moves. `%x%` is the axis value and `%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'`                                                                                             |
| `legend`            | `legend`             | Legend position. `'none'` hides the legend.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `"bottom" \| "bottom-end" \| "bottom-start" \| "left" \| "none" \| "right" \| "top" \| "top-end" \| "top-start"`                        | `'top-end'`                                                                                                      |
| `lineWidth`         | `line-width`         | Line stroke width in px, for every series. Default 2.5; a per-series `series[].lineWidth` overrides it.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                          | `number \| undefined`                                                                                                                   | `undefined`                                                                                                      |
| `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 data draws itself 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 and the screen-reader table. Empty follows the browser. An explicit `valueFormatter` / `xAxis.valueFormatter` always wins, so a consumer that formats its own values is unaffected.                                                                                                                                                                                                                                                                                                                                                                                          | `string`                                                                                                                                | `''`                                                                                                             |
| `markLines`         | --                   | Vertical reference lines across the plot (e.g. a "current time" divider).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `MdChartMarkLine[] \| undefined`                                                                                                        | `undefined`                                                                                                      |
| `markSize`          | `mark-size`          | Marker RADIUS in px, for every series. Overrides the per-symbol default; a per-series `series[].markSize` wins.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                  | `number \| undefined`                                                                                                                   | `undefined`                                                                                                      |
| `noAnimation`       | `no-animation`       | Disable all animation (shorthand for `animation="none"`).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `boolean`                                                                                                                               | `false`                                                                                                          |
| `series`            | --                   | Series array. Each entry is one line. Empty → empty state.  `data` is either a bare list of y values, positioned by index against `xAxis.data`, or a list of points carrying their own x — `{ x: '2021-11-13', y: 0.12 }` / `['2021-11-13', 0.12]`. Points are what irregular data needs: uneven sampling, or several series measured on completely different dates. Set `xAxis.scale = 'time'` (or `'value'`) so the gaps render proportionally; series carrying their own x need no `xAxis.data` at all.                                                                                                                                                       | `MdChartXYSeries[]`                                                                                                                     | `[]`                                                                                                             |
| `seriesLabels`      | `series-labels`      | Label each series at its last point with its name (follows the line end).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                        | `boolean`                                                                                                                               | `false`                                                                                                          |
| `showLabels`        | `show-labels`        | Print each point's value as a data label beside its marker.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      | `boolean`                                                                                                                               | `false`                                                                                                          |
| `showLine`          | `show-line`          | Draw the line for each series. Default `true`. Set `false` for a fill-only chart (with `area`) or a marks-only chart — handy when a high `fillOpacity` makes the same-coloured line vanish into the fill. A per-series `series[].stroke` still wins over this.                                                                                                                                                                                                                                                                                                                                                                                                   | `boolean`                                                                                                                               | `true`                                                                                                           |
| `showMarks`         | `show-marks`         | Show data-point markers on every series by default.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              | `boolean`                                                                                                                               | `false`                                                                                                          |
| `stack`             | `stack`              | Stacking strategy.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                               | `"none" \| "normal" \| "percentage" \| "silhouette" \| "wiggle"`                                                                        | `'none'`                                                                                                         |
| `subtitle`          | `subtitle`           | Optional sub-title rendered under the title in the muted text colour.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `string \| undefined`                                                                                                                   | `undefined`                                                                                                      |
| `summary`           | `summary`            | Replaces the generated `aria-label` outright. The default summary is assembled in English ("Revenue, Line chart, with 2 series, from Jan to Dec."); rather than translate it piecewise, hand over the whole sentence built in your own language.                                                                                                                                                                                                                                                                                                                                                                                                                 | `string`                                                                                                                                | `''`                                                                                                             |
| `tableLabels`       | --                   | Translatable chrome for the screen-reader data table. `%shown%` and `%total%` are substituted in `truncated`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    | `undefined \| { x?: string \| undefined; index?: string \| undefined; series?: string \| undefined; truncated?: string \| undefined; }` | `undefined`                                                                                                      |
| `titleAlign`        | `title-align`        | Title alignment over the plot: `start` (left), `center`, or `end` (right).                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | `"center" \| "end" \| "start"`                                                                                                          | `'start'`                                                                                                        |
| `tooltip`           | `tooltip`            | Tooltip interaction model.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       | `"axis" \| "item" \| "none"`                                                                                                            | `'axis'`                                                                                                         |
| `tooltipRenderer`   | --                   | Replace the tooltip's content with your own. Receives everything the chart knows about the hovered x — the axis value + its formatted label, and per visible series the index, label, colour, raw and formatted value, and whether it is the emphasised one — split into `series` (has a reading here) and `missing` (does not).  Return a DOM `Node` (what React / Vue / Angular renderers produce), a string (inserted as TEXT, never parsed as markup), `{ unsafeHtml }` to opt into raw HTML, `undefined` to fall back to the built-in tooltip, or `null` for no tooltip at this x. The engine keeps positioning it, and it is still the `tooltip` CSS part. | `((context: MdChartTooltipContext) => MdChartTooltipContent) \| undefined`                                                              | `undefined`                                                                                                      |
| `valueFormatter`    | --                   | Format raw Y values for tooltips / a11y table.  The tooltip also asks it about series with NO value at the hovered x — `null` when the series has a null datum there, `undefined` when it has no datum at all. Return `''` (or leave those cases unhandled) to keep the default behaviour of omitting the series; return a string to show a row for it — e.g. `(v) => (v === null ? 'no reading' : v === undefined ? '—' : v + ' m')`.                                                                                                                                                                                                                           | `((value: number \| null \| undefined) => string) \| undefined`                                                                         | `undefined`                                                                                                      |
| `xAxis`             | --                   | X-axis configuration.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `MdChartAxis \| undefined`                                                                                                              | `undefined`                                                                                                      |
| `yAxes`             | --                   | Multiple independent value (y) axes. When set (non-empty), each series is measured against `yAxes[series.yAxisIndex ?? 0]`, and the axes stack outward from the plot (first → left, rest → right, or per each axis' `position`). Supersedes the single `yAxis`. Each axis keeps its own `min`/`max`/`scale`/ `valueFormatter`/`label`.                                                                                                                                                                                                                                                                                                                           | `MdChartAxis[] \| undefined`                                                                                                            | `undefined`                                                                                                      |
| `yAxis`             | --                   | Y-axis configuration.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            | `MdChartAxis \| undefined`                                                                                                              | `undefined`                                                                                                      |
| `zoom`              | `zoom`               | Interactive zoom over the x range.   • `inside` — drag horizontally across the plot to zoom into that span;     double-click anywhere in the plot to reset.   • `slider` — an `md-slider` in range mode under the plot: drag either     thumb to resize the window, click the track to jump the nearest thumb,     drag the window itself to pan. Thumbs are focusable and take     Arrow/Home/End/PageUp/PageDown.   • `both` — both of the above.  Zoom is a *view* over the data: `series`/`xAxis` are untouched, and `mdHover`/`mdMarkerClick` keep reporting absolute indices into your data.                                                               | `"both" \| "inside" \| "none" \| "slider"`                                                                                              | `'none'`                                                                                                         |


## Events

| Event           | Description                                                                                                                                                                                                     | Type                                                                                       |
| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `mdAreaClick`   | Fires when a series' filled area is clicked (`area` charts only) — the region between that series' line and its baseline, excluding its line and points. `dataIndex` is the point the click is nearest along x. | `CustomEvent<MdChartClickDetail<MdChartXYSeries>>`                                         |
| `mdAxisClick`   | Fires when the plot background is clicked (inside the plot, but not on a mark, line or area): the nearest x position plus every visible series' value there.                                                    | `CustomEvent<MdChartAxisClickDetail>`                                                      |
| `mdHover`       | Fires (throttled to rAF) as the pointer crosses the plot.                                                                                                                                                       | `CustomEvent<MdChartHoverDetail>`                                                          |
| `mdLegendClick` | Fires when a legend entry is clicked.                                                                                                                                                                           | `CustomEvent<{ seriesIndex: number; seriesId?: string \| undefined; selected: boolean; }>` |
| `mdLineClick`   | Fires when a series' drawn line is clicked *between* its data points (a click on a point emits `mdMarkerClick` instead). `dataIndex` is the point the click is nearest along x.                                 | `CustomEvent<MdChartClickDetail<MdChartXYSeries>>`                                         |
| `mdMarkerClick` | Fires on marker click. `dataIndex` addresses the clicked series' own `data` array (its point index, for series that carry their own x).                                                                         | `CustomEvent<MdChartClickDetail<MdChartXYSeries>>`                                         |
| `mdReady`       | Fires after the chart finishes its initial render.                                                                                                                                                              | `CustomEvent<void>`                                                                        |
| `mdZoom`        | Fires when the zoom window changes (drag, slider or `setZoom`/`resetZoom`).                                                                                                                                     | `CustomEvent<{ startIndex: number; endIndex: number; reset: boolean; }>`                   |


## Methods

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

Return the underlying chart engine for advanced cases.

#### Returns

Type: `Promise<LineChartEngine | 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>`

Force a resize — useful after the chart was hidden then shown.

#### Returns

Type: `Promise<void>`



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

Zoom to an absolute index range.

#### Parameters

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

#### Returns

Type: `Promise<void>`



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

Render the current frame to a PNG data URL.

#### 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-line-chart --> md-slider
  md-line-chart --> md-progress-indicator
  style md-line-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.