Skip to content

Bottom Sheet

Supplementary content anchored to the bottom of the screen. It slides up over the page with a drag handle, an optional headline and an actions row — the mobile-first counterpart to md-side-sheet.

Live preview Open in Storybook
Share
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

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

<md-button id="bs-share-btn" variant="filled" icon="share">Share</md-button>

<md-bottom-sheet id="bs-share" headline="Share to" closeable>
  <md-list>
    <md-list-item headline="Copy link" leading-icon="link"></md-list-item>
    <md-list-item headline="Email" leading-icon="email"></md-list-item>
    <md-list-item headline="Message" leading-icon="chat"></md-list-item>
  </md-list>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-share-btn').addEventListener('click', () => {
    document.getElementById('bs-share').show();
  });
</script>

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


<md-bottom-sheet></md-bottom-sheet>
  • Mobile supplementary content or a short task: share targets, filters, a picker, a set of actions on an item.
  • Content the user can dismiss without consequence.
  • A list of contextual actions where a menu would be too small to tap.
SituationUse instead
A blocking decision or critical informationmd-dialog
Desktop supplementary contentmd-side-sheet
Brief feedbackmd-snackbar
A compact action list on desktopmd-menu
Primary content of the screenA page
Explaining a controlmd-tooltip
A full sub-task on mobilemd-dialog with fullscreen
VariantChromeUse for
standardFlush to the bottom edge, top corners only roundedThe default tray: share targets, filters, action lists
detachedInset by --md-bottom-sheet-detached-margin on every edge, all four corners rounded, elevatedA sheet that should read as a floating card — a media player, a compact picker

Both are modal — scrim, focus trap, body scroll lock, Escape and drag-to-dismiss. Only the chrome differs.

Every demo on this page renders the sheet closed next to its trigger; press the button to open it.

Standard and detached Open in Storybook
Standard Detached

Anchored to the bottom edge, rounded top corners only — the default.

Close

Floating, inset from every edge, all four corners rounded.

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

<md-button id="bs-standard-btn" variant="filled">Standard</md-button>
<md-button id="bs-detached-btn" variant="tonal">Detached</md-button>

<md-bottom-sheet id="bs-standard" headline="Standard" closeable>
  <p>Anchored to the bottom edge, rounded top corners only — the default.</p>
</md-bottom-sheet>

<md-bottom-sheet id="bs-detached" variant="detached" headline="Detached" closeable>
  <p>Floating, inset from every edge, all four corners rounded.</p>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-standard-btn').addEventListener('click', () => {
    document.getElementById('bs-standard').show();
  });
  document.getElementById('bs-detached-btn').addEventListener('click', () => {
    document.getElementById('bs-detached').show();
  });
</script>

detached is the one to reach for when the sheet should read as a floating card rather than a tray welded to the bottom edge:

Detached — inset on every edge Open in Storybook
Detached sheet
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

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

<md-button id="bs-det3-btn" variant="filled">Detached sheet</md-button>

<md-bottom-sheet id="bs-det3" variant="detached" headline="Share" closeable style="--md-bottom-sheet-content-padding-inline: 8px;">
  <md-list>
    <md-list-item headline="Send in email" leading-icon="email"></md-list-item>
    <md-list-item headline="Copy link" leading-icon="link"></md-list-item>
    <md-list-item headline="Save to Drive" leading-icon="add_to_drive"></md-list-item>
  </md-list>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-det3-btn').addEventListener('click', () => {
    document.getElementById('bs-det3').show();
  });
</script>

The states overview collects every combination in one Storybook page, and standard isolates the default variant.

SlotHolds
(default)The body. Scrolls internally when content exceeds the height
headlineRich title content — overrides the text of the headline prop
closeA custom close affordance, replacing the built-in icon button
actionsThe action row pinned below the body

show-drag-handle is on by default. closeable adds an explicit close button and is off by default — turn it on, because the drag gesture is pointer-only. top-divider and bottom-divider draw rules around the scrolling body; the bottom rule only renders when there is an actions row to separate.

Headline slot with dividers and actions, and a handle-less form sheet Open in Storybook
Full anatomy No drag handle Filters 3 active Reset Apply Cancel Add task
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="bs-anatomy-btn" variant="filled">Full anatomy</md-button>
<md-button id="bs-nohandle-btn" variant="tonal">No drag handle</md-button>

<md-bottom-sheet id="bs-anatomy" headline="Filters" top-divider bottom-divider closeable>
  <span slot="headline">Filters <span style="font-size: 0.6em; vertical-align: middle; color: var(--md-sys-color-primary);">3 active</span></span>
  <md-list>
    <md-list-item headline="Unread only"></md-list-item>
    <md-list-item headline="Has attachment"></md-list-item>
    <md-list-item headline="Starred"></md-list-item>
  </md-list>
  <md-button slot="actions" variant="text">Reset</md-button>
  <md-button slot="actions" variant="filled">Apply</md-button>
</md-bottom-sheet>

<md-bottom-sheet id="bs-nohandle" headline="New task" show-drag-handle="false" closeable>
  <md-text-field variant="outlined" label="Title" required></md-text-field>
  <md-button slot="actions" variant="text">Cancel</md-button>
  <md-button slot="actions" variant="filled">Add task</md-button>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-anatomy-btn').addEventListener('click', () => {
    document.getElementById('bs-anatomy').show();
  });
  document.getElementById('bs-nohandle-btn').addEventListener('click', () => {
    document.getElementById('bs-nohandle').show();
  });
</script>

The actions slot holds the footer buttons. The built-in close glyph can be replaced by slotting your own control into close — and slotting one is the request for a close affordance, so closeable is not required alongside it. The second sheet below has no closeable attribute and still shows its “Done” button.

An actions row, and a slotted close control Open in Storybook
Sheet with actions Custom close control

This removes it for everyone in the thread.

Cancel Delete
Done

A text button replaces the close glyph — no closeable attribute needed.

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

<md-button id="bs-actions-btn" variant="filled">Sheet with actions</md-button>
<md-button id="bs-customclose-btn" variant="outlined">Custom close control</md-button>

<md-bottom-sheet id="bs-actions" headline="Delete conversation?" closeable>
  <p>This removes it for everyone in the thread.</p>
  <md-button slot="actions" variant="text">Cancel</md-button>
  <md-button slot="actions" variant="filled">Delete</md-button>
</md-bottom-sheet>

<md-bottom-sheet id="bs-customclose" headline="Filters">
  <md-button slot="close" variant="text">Done</md-button>
  <p>A text button replaces the close glyph.</p>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-actions-btn').addEventListener('click', () => {
    document.getElementById('bs-actions').show();
  });
  document.getElementById('bs-customclose-btn').addEventListener('click', () => {
    document.getElementById('bs-customclose').show();
  });
</script>

Reach for a slotted close when “Close” should read as an explicit affirmation — Done, Got it, Apply — rather than a dismissal glyph:

A text button in the close slot Open in Storybook
Sheet with a Done button Done
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="bs-done-btn" variant="filled">Sheet with a Done button</md-button>

<md-bottom-sheet id="bs-done" headline="Sort by">
  <md-button slot="close" variant="text">Done</md-button>
  <md-list>
    <md-list-item headline="Relevance"></md-list-item>
    <md-list-item headline="Newest first"></md-list-item>
    <md-list-item headline="Price"></md-list-item>
  </md-list>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-done-btn').addEventListener('click', () => {
    document.getElementById('bs-done').show();
  });
</script>

The slotted-close story asserts the whole path, including that it emits mdClose without mdCancel.

headline-align and content-align take start (default), center or end and are logical — they mirror in RTL. scrim-dismissible defaults to true; turn it off when an accidental tap outside would lose work.

Centred content, and a scrim that refuses to dismiss Open in Storybook
Centred Non-dismissible scrim

Both the headline and the body are centred.

Continue

The scrim will not dismiss this sheet — pick one of the actions. Escape still works, and it fires mdCancel.

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

<md-button id="bs-centered-btn" variant="filled">Centred</md-button>
<md-button id="bs-persistent-btn" variant="tonal">Non-dismissible scrim</md-button>

<md-bottom-sheet id="bs-centered" headline="Choose a plan" headline-align="center" content-align="center" closeable>
  <p>Both the headline and the body are centred.</p>
  <md-button slot="actions" variant="filled">Continue</md-button>
</md-bottom-sheet>

<md-bottom-sheet id="bs-persistent" headline="Discard changes?" scrim-dismissible="false">
  <p>The scrim is inert here. Escape still dismisses, and still fires mdCancel.</p>
  <md-button slot="actions" variant="text">Keep editing</md-button>
  <md-button slot="actions" variant="filled">Discard</md-button>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-centered-btn').addEventListener('click', () => {
    document.getElementById('bs-centered').show();
  });
  document.getElementById('bs-persistent-btn').addEventListener('click', () => {
    document.getElementById('bs-persistent').show();
  });
</script>

The drag handle is a pointer affordance: press it, pull down past the 100px threshold and release to dismiss — release above it and the sheet springs back.

Drag the handle down to dismiss Open in Storybook
Drag me down to dismiss

Grab the handle at the top and pull down past the threshold — release below it and the sheet dismisses, release above it and it springs back.

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

<md-button id="bs-drag-btn" variant="filled">Drag me down to dismiss</md-button>

<md-bottom-sheet id="bs-drag" headline="Drag the handle" show-drag-handle closeable>
  <p style="margin: 0;">Grab the handle at the top and pull down past the threshold — release below it and the sheet dismisses, release above it and it springs back.</p>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-drag-btn').addEventListener('click', () => {
    document.getElementById('bs-drag').show();
  });
</script>
MethodReturnsNotes
show()Promise<void>Opens the sheet and moves focus into it
close()Promise<void>Closes it and fires mdClose — but not mdCancel

A programmatic close() is how you tell “the user accepted” from “the user dismissed”: the sheet below has no close glyph and an inert scrim, so its only exit is the action button.

scrim-dismissible=false — try clicking outside it Open in Storybook
Required action

The scrim is a no-op here and there is no close glyph — the only way out is the action below, so the choice cannot be skipped by tapping away. (Escape still works, and it fires mdCancel.)

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

<md-button id="bs-required-btn" variant="filled">Required action</md-button>

<md-bottom-sheet id="bs-required" headline="Accept the terms" scrim-dismissible="false">
  <p>The only way out is the action below.</p>
  <md-button slot="actions" variant="filled">I understand</md-button>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-required-btn').addEventListener('click', () => {
    document.getElementById('bs-required').show();
  });
</script>

Every dismissal route has its own story: the close button, dragging the handle down (and the drag threshold), Escape plus the focus trap, and the non-dismissible case where none of them apply.

Unlike md-card, a bottom sheet is meant to scroll its body internally: the content area plain-scrolls once it outgrows --md-bottom-sheet-max-height (80vh by default), while the header and the actions row stay pinned. Never scroll it horizontally.

A body that scrolls internally Open in Storybook
Long list Cancel
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="bs-scroll-btn" variant="filled">Long list</md-button>

<md-bottom-sheet id="bs-scroll" headline="Select a country" top-divider closeable>
  <md-list>
    <md-list-item headline="Argentina"></md-list-item>
    <md-list-item headline="Brazil"></md-list-item>
    <md-list-item headline="Canada"></md-list-item>
    <md-list-item headline="Denmark"></md-list-item>
    <md-list-item headline="Egypt"></md-list-item>
    <md-list-item headline="France"></md-list-item>
  </md-list>
  <md-button slot="actions" variant="text">Cancel</md-button>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-scroll-btn').addEventListener('click', () => {
    document.getElementById('bs-scroll').show();
  });
</script>

Keep the sheet short enough to leave context visible. A sheet that covers the whole viewport should be a full-screen md-dialog instead.

Width and height come from the --md-bottom-sheet-width / -height bounds rather than a prop, so one sheet can be a full-width tray on phones and a narrower panel on a wide screen without changing markup. The reflow is built in: below 640px the sheet is full-bleed, and at 640px and up it defaults to a centred 640px panel capped at calc(100% - 112px). Setting either custom property overrides the breakpoint’s fallback at both sizes.

A width cap, and a fixed height Open in Storybook
Narrow sheet Tall sheet

Capped at 420px, centred on a wide viewport.

Fixed to 60vh regardless of content.

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

<md-button id="bs-narrow-btn" variant="filled">Narrow sheet</md-button>
<md-button id="bs-tall-btn" variant="outlined">Tall sheet</md-button>

<md-bottom-sheet id="bs-narrow" headline="Narrow" closeable style="--md-bottom-sheet-max-width: 420px;">
  <p style="margin: 0;">Capped at 420px, centred on a wide viewport.</p>
</md-bottom-sheet>

<md-bottom-sheet id="bs-tall" headline="Tall" closeable style="--md-bottom-sheet-height: 60vh;">
  <p style="margin: 0;">Fixed to 60vh regardless of content.</p>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-narrow-btn').addEventListener('click', () => {
    document.getElementById('bs-narrow').show();
  });
  document.getElementById('bs-tall-btn').addEventListener('click', () => {
    document.getElementById('bs-tall').show();
  });
</script>

The sheet is fluid by default and caps at 80vh, so it always leaves some context visible above it.

One sheet at every width Open in Storybook
Open, then resize the window

Full-bleed on a phone, capped and centred on a wide screen — one sheet, no breakpoint markup. Resize the window with this open and watch it re-centre.

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

<md-button id="bs-resp-btn" variant="filled">Open, then resize the window</md-button>

<md-bottom-sheet id="bs-resp" headline="Fluid by default" closeable style="--md-bottom-sheet-max-width: 640px;">
  <p style="margin: 0;">Full-bleed on a phone, capped and centred on a wide screen — one sheet, no breakpoint markup. Resize the window with this open and watch it re-centre.</p>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-resp-btn').addEventListener('click', () => {
    document.getElementById('bs-resp').show();
  });
</script>

The responsiveness story steps through the breakpoints in Storybook.

Three worked examples — a form sheet, a media sheet and a filter panel — plus the canonical M3 share sheet.

The form sheet is the case that most often gets focus handling wrong: show() moves focus to the first field, the focus guards keep Tab inside, and Escape hands it back to the trigger.

A form sheet — focus lands inside, Escape returns it Open in Storybook
Form inside a sheet
Cancel Save
Show code for each technology
<!-- index.html — register the AWC UI elements once -->
<script type="module">
  import '@awc-ui/core/define';
</script>

<md-button id="bs-form-btn" variant="filled">Form inside a sheet</md-button>

<md-bottom-sheet id="bs-form" headline="Add a label" closeable>
  <div style="display: grid; gap: 16px;">
    <md-text-field label="Name" variant="outlined"></md-text-field>
    <md-text-field label="Colour" variant="outlined"></md-text-field>
  </div>
  <md-button slot="actions" variant="text">Cancel</md-button>
  <md-button slot="actions" variant="filled">Save</md-button>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-form-btn').addEventListener('click', () => {
    document.getElementById('bs-form').show();
  });
</script>
Music player — a detached sheet with no headline Open in Storybook
Now playing
Song Title
Artist Name
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

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

<md-button id="bs-music-btn" variant="filled" icon="play_arrow">Now playing</md-button>

<md-bottom-sheet id="bs-music" variant="detached">
  <div style="text-align: center; padding-block: 8px;">
    <div style="inline-size: 200px; block-size: 200px; margin: 0 auto 16px; border-radius: 16px; background: linear-gradient(135deg, var(--md-sys-color-primary-container), var(--md-sys-color-tertiary-container)); display: flex; align-items: center; justify-content: center;">
      <span class="material-symbols-outlined" aria-hidden="true" style="font-size: 64px; color: var(--md-sys-color-on-primary-container);">music_note</span>
    </div>
    <div style="font-size: 20px; font-weight: 500; margin-block-end: 4px;">Song Title</div>
    <div style="font-size: 14px; color: var(--md-sys-color-on-surface-variant);">Artist Name</div>
    <div style="display: flex; justify-content: center; gap: 24px; margin-block-start: 24px;">
      <md-icon-button variant="standard" icon="skip_previous" aria-label="Previous"></md-icon-button>
      <md-icon-button variant="filled" icon="play_arrow" aria-label="Play"></md-icon-button>
      <md-icon-button variant="standard" icon="skip_next" aria-label="Next"></md-icon-button>
    </div>
  </div>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-music-btn').addEventListener('click', () => {
    document.getElementById('bs-music').show();
  });
</script>

A filter panel is the other common shape: chips and switches over a divider, with the actions row committing or clearing.

Filter panel Open in Storybook
Filters
Category
Electronics Clothing Books Home
Clear Apply
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

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

<md-button id="bs-filters-btn" variant="filled" icon="tune">Filters</md-button>

<md-bottom-sheet id="bs-filters" headline="Filters" closeable>
  <div style="display: flex; gap: 8px; flex-wrap: wrap;">
    <md-chip>Electronics</md-chip>
    <md-chip>Clothing</md-chip>
    <md-chip>Books</md-chip>
  </div>
  <md-divider></md-divider>
  <div style="margin-block-start: 16px; display: grid; gap: 12px;">
    <label style="display: flex; align-items: center; gap: 12px; cursor: pointer;">
      <md-switch></md-switch>
      In stock only
    </label>
    <label style="display: flex; align-items: center; gap: 12px; cursor: pointer;">
      <md-switch></md-switch>
      Free delivery
    </label>
  </div>
  <md-button slot="actions" variant="text">Clear</md-button>
  <md-button slot="actions" variant="filled">Apply</md-button>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-filters-btn').addEventListener('click', () => {
    document.getElementById('bs-filters').show();
  });
</script>

And a share sheet — a plain list of destinations, the canonical M3 example:

Share actions Open in Storybook
Share
Show code for each technology
<!-- index.html <head> — the icon font the components draw from -->
<link rel="stylesheet"
  href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap">

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

<md-button id="bs-shareacts-btn" variant="filled" icon="share">Share</md-button>

<md-bottom-sheet id="bs-shareacts" headline="Share" closeable style="--md-bottom-sheet-content-padding-inline: 8px;">
  <md-list>
    <md-list-item headline="Send in email" leading-icon="email"></md-list-item>
    <md-list-item headline="Copy link" leading-icon="link"></md-list-item>
    <md-list-item headline="Copy to clipboard" leading-icon="content_copy"></md-list-item>
    <md-list-item headline="Save to Drive" leading-icon="add_to_drive"></md-list-item>
    <md-list-item headline="Print" leading-icon="print"></md-list-item>
  </md-list>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-shareacts-btn').addEventListener('click', () => {
    document.getElementById('bs-shareacts').show();
  });
</script>
EventCancelableDetailFires
mdOpennovoidThe sheet has opened
mdClosenovoidAny close, including programmatic close()
mdCancelnovoidA dismissal: scrim click, Escape, drag-down, or the built-in close glyph
You want to…Listen to
Restore focus, tear down state, run on every exitmdClose
Treat the exit as “the user backed out” — discard a draft, log a dismissalmdCancel
React to the sheet becoming visible — measure it, load its contentmdOpen

Open the sheet below and leave it three different ways — the glyph, Escape, and the Done button — and watch which events each route emits.

Every exit route, logged
Share Done Press Share, then close the sheet three different ways.
Show code for each technology
<md-button id="bs-ev-btn" variant="filled" icon="share">Share</md-button>

<md-bottom-sheet id="bs-ev" headline="Share to" closeable>
<md-list>
  <md-list-item headline="Copy link" leading-icon="link"></md-list-item>
  <md-list-item headline="Email" leading-icon="email"></md-list-item>
</md-list>
<md-button slot="actions" id="bs-ev-done" variant="filled">Done</md-button>
</md-bottom-sheet>

<script type="module">
const sheet = document.getElementById('bs-ev');
const btn = document.getElementById('bs-ev-btn');
const done = document.getElementById('bs-ev-done');

btn.addEventListener('click', () => sheet.show());
// A slotted / actions-row control is never auto-wired: call close() yourself.
done.addEventListener('click', () => sheet.close());

sheet.addEventListener('mdOpen', () => console.log('opened'));
sheet.addEventListener('mdCancel', () => console.log('dismissed'));   // scrim / Esc / drag / glyph
sheet.addEventListener('mdClose', () => btn.focus({ preventScroll: true })); // any close
</script>

Properties

PropertyAttributeTypeDefaultReflects
openopenbooleanfalseYes
variantvariant'standard' | 'detached''standard'Yes
headlineheadlinestring''
showDragHandleshow-drag-handlebooleantrueYes
closeablecloseablebooleanfalseYes
scrimDismissiblescrim-dismissiblebooleantrue
topDividertop-dividerbooleanfalseYes
bottomDividerbottom-dividerbooleanfalseYes
sheetAriaLabelaria-labelstring''
scrollShadowscroll-shadowbooleantrueYes
headlineAlignheadline-align'start' | 'center' | 'end''start'Yes
contentAligncontent-align'start' | 'center' | 'end''start'Yes
densitydensity0 | -1 | -2 | -3 | -40Yes

Methods

MethodParameters
show()none
close()none

Slots

SlotDescription
(default)
headlineCustom headline content
closeCustom close element
actionsBottom action buttons

CSS Custom Properties

Override on the host element for per-instance theming:

PropertyDescription
--md-bottom-sheet-container-colorContainer background
--md-bottom-sheet-container-shapeCorner radius (top corners
--md-bottom-sheet-content-colorContent text color
--md-bottom-sheet-headline-colorHeadline text color
--md-bottom-sheet-scrim-colorScrim overlay color
--md-bottom-sheet-drag-handle-colorDrag handle indicator color
--md-bottom-sheet-divider-colorTop / bottom divider color
--md-bottom-sheet-icon-colorClose icon color
--md-bottom-sheet-widthInline size (default 100%
--md-bottom-sheet-min-widthMinimum inline size
--md-bottom-sheet-max-widthMaximum inline size
--md-bottom-sheet-heightBlock size (default `auto`)
--md-bottom-sheet-min-heightMinimum block size
--md-bottom-sheet-max-heightMaximum block size
--md-bottom-sheet-detached-marginMargin around the container
--md-bottom-sheet-content-padding-inline

CSS Shadow Parts

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

PartDescription
scrimScrim overlay
containerSheet surface
drag-handleDrag handle hit area
drag-handle-indicatorDrag handle visible bar
headerHeader row
headlineHeadline text
closeClose button wrapper
divider-topDivider between header and content
contentContent area wrapper (inline padding)
divider-bottomDivider between content and actions
actionsBottom action bar
  • The container is a modal dialog: role="dialog", aria-modal="true", and while closed the whole panel is inert and aria-hidden so nothing inside it is tab-reachable.
  • show() moves focus to the first focusable control inside the sheet; a pair of focus guards wraps Tab and Shift+Tab back around, and closing restores focus to whatever was focused before — with preventScroll, so the page doesn’t jump.
  • Escape dismisses from anywhere on the page, firing mdCancel then mdClose.
  • Name it with headline (wired through aria-labelledby) or with aria-label when there is no visible headline. A headline-less, unlabelled sheet falls back to the literal name “Bottom sheet” — never ship that.
  • The drag handle is aria-hidden and a pointer affordance only. Always give keyboard and AT users a way out — closeable, or a cancel button in the actions slot.
  • Keep enough of the underlying context visible that users understand where they are.
  • Clean under axe-core in every documented configuration.

Open the sheet below, then Tab past the last button: focus wraps back to the first control instead of escaping to the page. Press Escape and focus lands back on the trigger.

A headline-less sheet named by aria-label — try tabbing past the last control Open in Storybook
Open a labelled sheet

This sheet has no visible headline, so aria-label names it instead. Tab around — focus is trapped inside and returns to the opener on close.

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

<md-button id="bs-a11y-btn" variant="filled">Open a labelled sheet</md-button>

<md-bottom-sheet id="bs-a11y" aria-label="Payment options" closeable>
  <p>No visible headline, so aria-label names the dialog.</p>
  <md-button slot="actions" variant="text">Close</md-button>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-a11y-btn').addEventListener('click', () => {
    document.getElementById('bs-a11y').show();
  });
</script>

In Storybook: accessibility, focus restoration, the focus-guard wrap, a custom aria-label and opening already-open.

RTL — nothing on the sheet is hard-coded to a physical side. The header row, the close affordance, the actions row and headline-align / content-align are all logical, so the same markup mirrors under dir="rtl":

<div dir="rtl">
<md-bottom-sheet headline="مشاركة إلى" headline-align="start" closeable>
<p>المحاذاة منطقية، لذا يتبع العنوان اتجاه القراءة.</p>
</md-bottom-sheet>
</div>
Same markup, dir=ltr vs dir=rtl Open in Storybook
ltr
Share to

The close glyph sits at the inline end, and the headline starts at the inline start.

rtl
مشاركة إلى

المحاذاة منطقية، لذا يتبع العنوان اتجاه القراءة.

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

<div style="display:grid;grid-template-columns:auto 1fr;gap:14px 16px;align-items:center">
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">ltr</span>
  <div dir="ltr" style="display:flex;flex-wrap:wrap;gap:8px;">
    <md-button id="bs-ltr-btn" variant="filled">Share to</md-button>
    <md-bottom-sheet id="bs-ltr" headline="Share to" top-divider closeable>
      <p style="margin: 0;">The close glyph sits at the inline end, and the headline starts at the inline start.</p>
    </md-bottom-sheet>
  </div>

  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">rtl</span>
  <div dir="rtl" style="display:flex;flex-wrap:wrap;gap:8px;">
    <md-button id="bs-rtl-btn" variant="filled">مشاركة إلى</md-button>
    <md-bottom-sheet id="bs-rtl" headline="مشاركة إلى" top-divider closeable>
      <p style="margin: 0;">المحاذاة منطقية، لذا يتبع العنوان اتجاه القراءة.</p>
    </md-bottom-sheet>
  </div>
</div>

<script type="module">
  document.getElementById('bs-ltr-btn').addEventListener('click', () => {
    document.getElementById('bs-ltr').show();
  });
  document.getElementById('bs-rtl-btn').addEventListener('click', () => {
    document.getElementById('bs-rtl').show();
  });
</script>

headline-align="start" follows the reading direction. Hard-coding the same intent with ::part(headline) { text-align: left } does not — it pins the headline to the physical left even in Arabic. Both rows below are under dir="rtl":

Logical alignment vs a hard-coded physical one, both under dir=rtl
right
headline-align="start"

Logical: the headline follows the reading direction and lands on the right.

wrong
text-align: left

Physical: pinned to the left, fighting the reading direction.

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

<style>
  #bs-align-wrong::part(headline) { text-align: left; }
</style>
<div dir="rtl" style="display:grid;grid-template-columns:auto 1fr;gap:14px 16px;align-items:center">
  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">right</span>
  <div style="display:flex;flex-wrap:wrap;gap:8px;">
    <md-button id="bs-align-right-btn" variant="filled">headline-align="start"</md-button>
    <md-bottom-sheet id="bs-align-right" headline="مشاركة إلى" headline-align="start" closeable>
      <p style="margin: 0;">Logical: the headline follows the reading direction and lands on the right.</p>
    </md-bottom-sheet>
  </div>

  <span style="inline-size:3.5rem;opacity:.65;font-size:.75rem;font-family:ui-monospace,monospace;">wrong</span>
  <div style="display:flex;flex-wrap:wrap;gap:8px;">
    <md-button id="bs-align-wrong-btn" variant="outlined">text-align: left</md-button>
    <md-bottom-sheet id="bs-align-wrong" headline="مشاركة إلى" closeable>
      <p style="margin: 0;">Physical: pinned to the left, fighting the reading direction.</p>
    </md-bottom-sheet>
  </div>
</div>

<script type="module">
  document.getElementById('bs-align-right-btn').addEventListener('click', () => {
    document.getElementById('bs-align-right').show();
  });
  document.getElementById('bs-align-wrong-btn').addEventListener('click', () => {
    document.getElementById('bs-align-wrong').show();
  });
</script>

density="-1…-4" compacts the padding, the header and the actions row together. Rung 0 is the uncompacted default, not a value you set — there is no [density="0"] rule to opt back into (see Density and direction together). On the sheets below the header goes 48 → 43 → 38 → 37 → 36px (a 40 → 36 → 32px close button over a shrinking header gap) and the content gutter 24 → 22 → 20 → 18 → 16px. The header flattens out after -2; the gutter keeps tightening to the floor, and the actions row steps separately — it reads the shared --md-sys-spacing-* tokens, which only move at -2 and -3. See Density.

Nothing stops two sheets being open at once — there is no singleton registry, and each open sheet installs its own document-level focus trap, so a second one fights the first. Compare the rungs by opening each in turn.

Density 0 through -4 — open each in turn
0
Open
-1
Open
-2
Open
-3
Open
-4
Open

Same content at every rung — compare the header, the content gutter and the actions row.

Close

Same content at every rung — compare the header, the content gutter and the actions row.

Close

Same content at every rung — compare the header, the content gutter and the actions row.

Close

Same content at every rung — compare the header, the content gutter and the actions row.

Close

Same content at every rung — compare the header, the content gutter and the actions row.

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

<md-button id="bs-d2-btn" variant="outlined">Open</md-button>

<md-bottom-sheet id="bs-d2" headline="Density -2" density="-2" closeable>
  <p>Same content at every rung — compare the header, the content gutter and the actions row.</p>
  <md-button slot="actions" variant="text">Close</md-button>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-d2-btn').addEventListener('click', () => {
    document.getElementById('bs-d2').show();
  });
</script>

A global data-density ancestor and dir="rtl" compose — and a local density="-1…-4" on one sheet overrides the inherited rung without touching the direction. It only ever tightens, though: to loosen a sheet back out of an inherited rung you set --md-sys-density-scale yourself.

dir=rtl with data-density=-2: one sheet inheriting -2, one tightening to -4, one reset with --md-sys-density-scale: 0
يرث -2 أكثف -4 إعادة الضبط إلى 0

This sheet inherits data-density=-2 from its ancestor, and mirrors with it.

A local density of -1 through -4 does win over the inherited rung — this sheet tightens past its ancestor's -2, still RTL.

density="0" would change nothing here. Setting --md-sys-density-scale: 0 inline is the real reset — roomy again, still RTL.

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

<div dir="rtl" data-density="-2" style="display:flex;flex-wrap:wrap;gap:8px;">
  <md-button id="bs-dd-inherit-btn" variant="filled">يرث -2</md-button>
  <md-button id="bs-dd-tighter-btn" variant="outlined">أكثف -4</md-button>
  <md-button id="bs-dd-reset-btn" variant="outlined">إعادة الضبط إلى 0</md-button>

  <md-bottom-sheet id="bs-dd-inherit" headline="كثافة موروثة" closeable>
    <p style="margin: 0;">This sheet inherits data-density=-2 from its ancestor, and mirrors with it.</p>
  </md-bottom-sheet>

  <md-bottom-sheet id="bs-dd-tighter" headline="كثافة محلية -4" density="-4" closeable>
    <p style="margin: 0;">A local density of -1 through -4 does win over the inherited rung — this sheet tightens past its ancestor's -2, still RTL.</p>
  </md-bottom-sheet>

  <md-bottom-sheet id="bs-dd-reset" headline="إعادة الضبط" style="--md-sys-density-scale: 0" closeable>
    <p style="margin: 0;">density="0" would change nothing here. Setting --md-sys-density-scale: 0 inline is the real reset — roomy again, still RTL.</p>
  </md-bottom-sheet>
</div>

<script type="module">
  document.getElementById('bs-dd-inherit-btn').addEventListener('click', () => {
    document.getElementById('bs-dd-inherit').show();
  });
  document.getElementById('bs-dd-tighter-btn').addEventListener('click', () => {
    document.getElementById('bs-dd-tighter').show();
  });
  document.getElementById('bs-dd-reset-btn').addEventListener('click', () => {
    document.getElementById('bs-dd-reset').show();
  });
</script>

Density — the rung drives --md-sys-density-scale, which the container shape, the header height, the content padding and the typescale all read from, so a single attribute retunes the whole surface.

i18n — translate headline, aria-label, the action labels and the body; the localization story switches all four at once. Longer translations push the sheet taller — re-check that it still leaves context visible under the 80vh cap.

Custom propertyPurposeDefault
--md-bottom-sheet-container-colorSheet surfacesurface-container-low
--md-bottom-sheet-container-shapeCorner radiusmax(16px, 28px + density × 2px)
--md-bottom-sheet-headline-colorHeadline texton-surface
--md-bottom-sheet-content-colorBody texton-surface
--md-bottom-sheet-scrim-colorBackdroprgba(0, 0, 0, 0.32)
--md-bottom-sheet-drag-handle-colorHandle indicatoron-surface-variant
--md-bottom-sheet-divider-colorTop / bottom rulesoutline-variant
--md-bottom-sheet-icon-colorClose glyphon-surface-variant
--md-bottom-sheet-width / -min-width / -max-widthInline-size bounds — the width fallbacks change at the 640px breakpointBelow 640px: 100% / 0 / 100%. At 640px and up: 640px / 0 / calc(100% - 112px)
--md-bottom-sheet-height / -min-height / -max-heightBlock-size boundsauto / 0 / 80vh
--md-bottom-sheet-detached-marginInset for variant="detached"16px
--md-bottom-sheet-content-padding-inlineBody inline paddingmax(12px, 24px + density × 2px)
Themed instance Open in Storybook
Themed sheet

Recoloured surface, squared corners and a capped width.

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

<md-button id="bs-themed-btn" variant="filled">Themed sheet</md-button>

<md-bottom-sheet
  id="bs-themed"
  variant="detached"
  headline="Themed"
  closeable
  style="--md-bottom-sheet-container-color: var(--md-sys-color-primary-container); --md-bottom-sheet-headline-color: var(--md-sys-color-on-primary-container); --md-bottom-sheet-content-color: var(--md-sys-color-on-primary-container); --md-bottom-sheet-container-shape: 8px; --md-bottom-sheet-max-width: 420px;">
  <p style="margin: 0;">Recoloured surface, squared corners and a capped width.</p>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-themed-btn').addEventListener('click', () => {
    document.getElementById('bs-themed').show();
  });
</script>

CSS partsscrim, container, drag-handle, drag-handle-indicator, header, headline, close, divider-top, content, divider-bottom and actions. Note that the sheet surface is container, not surface; there is no surface part.

CSS parts — outlined container, wider handle, tinted scrim Open in Storybook
Styled parts

Outlined surface, italic headline, a wider drag handle and a tinted scrim.

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

<style>
  #bs-parts::part(container) { border: 2px solid var(--md-sys-color-primary); }
  #bs-parts::part(headline) { font-style: italic; letter-spacing: .04em; }
  #bs-parts::part(drag-handle-indicator) { inline-size: 48px; }
  #bs-parts::part(scrim) { background: color-mix(in srgb, var(--md-sys-color-primary) 30%, transparent); }
</style>
<md-button id="bs-parts-btn" variant="filled">Styled parts</md-button>

<md-bottom-sheet id="bs-parts" headline="Styled with ::part()" closeable>
  <p style="margin: 0;">Outlined surface, italic headline, a wider drag handle and a tinted scrim.</p>
</md-bottom-sheet>

<script type="module">
  document.getElementById('bs-parts-btn').addEventListener('click', () => {
    document.getElementById('bs-parts').show();
  });
</script>
md-bottom-sheet::part(container) {
border: 2px solid var(--md-sys-color-primary);
}
md-bottom-sheet::part(drag-handle-indicator) {
inline-size: 48px;
}

Check any override in both themes — the scrim and the sheet surface sit at different elevations in each, so a colour that reads well on one can flatten on the other. The dark-theme story is the counterpart to the themed instance above.

md-side-sheet · md-dialog · md-snackbar · md-menu · md-list · md-icon-button

For AI Agents — md-bottom-sheet

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-bottom-sheet 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-bottom-sheet readme.md

# md-bottom-sheet

<!-- llm:meta
tag: md-bottom-sheet
category: containment
status: md3-mapped
m3-guidelines: https://m3.material.io/components/bottom-sheets/guidelines
form-associated: false
depends-on: md-icon-button
used-by: none
-->

**Supplementary content anchored to the bottom of the screen.** Slides up over
the page with a drag handle, an optional headline and close button, and an
optional actions row. **Both variants are modal**: they render a scrim, trap
focus, lock body scroll and close on Escape.

> Setup, theming, density and i18n are configured once for the whole library —
> see the library-wide specification, shipped next to these manuals as
> `main-llm.md` at the root of the `@awc-ui/core` package.

---

## When to use

- **Mobile** supplementary content or a short task: share targets, filters,
  a picker, a set of actions on an item.
- Content the user can dismiss without consequence.
- A list of contextual actions where a menu would be too small to tap.

## When NOT to use

| Situation | Use instead |
|---|---|
| A blocking decision or critical information | `md-dialog` |
| Desktop supplementary content | `md-side-sheet` |
| Brief feedback | `md-snackbar` |
| A compact action list on desktop | `md-menu` |
| Primary content of the screen | A page |
| Explaining a control | `md-tooltip` |
| A full sub-task on mobile | `md-dialog fullscreen` |
| Non-modal content the user works alongside | `md-side-sheet` with `variant="standard"` |

## Decision cues

| Need | Setting |
|---|---|
| Anchored to the bottom edge, top corners rounded | `variant="standard"` (default) |
| Floating, inset from every edge, all corners rounded | `variant="detached"` |
| Drag-to-dismiss affordance | `show-drag-handle` (default `true`) |
| No handle, no drag gesture | `show-drag-handle="false"` |
| Explicit close button | `closeable` |
| Your own close control | `slot="close"` |
| Prevent click-away dismissal | `scrim-dismissible="false"` |
| Rule under the header | `top-divider` |
| Rule above the actions row | `bottom-divider` (needs `slot="actions"` content) |
| Centre the title or content | `headline-align="center"` / `content-align="center"` |
| Edge-to-edge lists or tables | `--md-bottom-sheet-content-padding-inline: 0` |
| Open/close from code | `show()` / `close()`, or set `open` |

## API contract

```html
<md-bottom-sheet
  open                                 <!-- default: false; reflects -->
  variant="standard|detached"          <!-- default: standard -->
  headline="Share to"                  <!-- default: "" -->
  show-drag-handle="true|false"        <!-- default: true -->
  closeable                            <!-- default: false -->
  scrim-dismissible="true|false"       <!-- default: true -->
  top-divider                          <!-- default: false -->
  bottom-divider                       <!-- default: false; needs slotted actions -->
  headline-align="start|center|end"    <!-- default: start -->
  content-align="start|center|end"     <!-- default: start -->
  aria-label="Share options"           <!-- default: "" -->
  density="-1|-2|-3|-4"                <!-- default: 0 (uncompacted; only -1…-4 have rules) -->
>
  <md-list>…</md-list>
  <md-button slot="actions" variant="text">Cancel</md-button>
</md-bottom-sheet>
```

**Deprecated / inert attribute — do not emit:** `scroll-shadow` is retained for
API compatibility and is a documented no-op. The content area always
plain-scrolls.

**Events** — `mdOpen`, `mdClose`, `mdCancel`, all `CustomEvent<void>` and all
default Stencil events (bubbling and composed).

**Methods** — `show(): Promise<void>` and `close(): Promise<void>`. Both just
set `open`, so `open` stays the single source of truth.

**Slots** — `(default)` main content · `headline` · `close` · `actions`.

**Parts** — `scrim`, `container`, `drag-handle`, `drag-handle-indicator`,
`header`, `headline`, `close`, `divider-top`, `content`, `divider-bottom`,
`actions`.

### Behavioral contract worth knowing

- **`variant` only changes the chrome, not the modality.** Unlike the M3
  "standard bottom sheet", `variant="standard"` here still renders a scrim,
  traps focus and locks body scroll — exactly like `detached`. If you need a
  genuinely non-modal surface, use `md-side-sheet`.
- **The `headline` slot only renders when the `headline` prop is non-empty.**
  The header is gated on the prop, so `<span slot="headline">…</span>` alone
  is silently dropped. Set `headline` to the plain-text version *and* slot the
  rich version if you need markup.
- **`bottom-divider` is double-gated.** The bottom rule renders only when the
  flag is set **and** there is slotted `[slot="actions"]` content — the actions
  row is what it separates. `<md-bottom-sheet bottom-divider>` with no actions
  renders no rule at all. `top-divider` has no such gate.
- The header row itself renders only when there is a `headline`, `closeable`,
  or a slotted `close` element. With none of those there is no header, no
  close button, and only the drag gesture and Escape can dismiss the sheet.
- **`mdOpen` fires on mount for a sheet rendered with `open`** (the mount
  handler calls the open path), and again on every later change. `mdClose`
  fires on every close.
- `mdCancel` fires only on dismissal — a scrim click, Escape, a completed
  drag-down, or the built-in close button. `mdClose` fires on *every* close,
  including those, so a dismissal emits `mdCancel` **and** `mdClose`.
- **A slotted `close` element does not close the sheet.** Only the built-in
  `closeable` icon-button is wired; your own control must call `close()`.
- Escape is handled by a **capture-phase listener on `document`** while the
  sheet is open, with `preventDefault()` and `stopPropagation()` — so an outer
  Escape handler will not also fire.
- **Drag-to-dismiss lives on the drag handle only** and needs
  `show-drag-handle`. Dragging down more than 100px emits `mdCancel` and
  closes; anything less snaps the sheet back.
- **The sheet is never removed from the DOM.** While closed the container is
  translated off-screen and marked `inert` + `aria-hidden="true"`, so it is not
  focusable or exposed to assistive tech, but its slotted content still exists.
- Focus enters the sheet on open and is kept inside by focus-guard sentinels
  plus a `focusin` listener on `document`; on close, focus returns to whatever
  was focused before, with `preventScroll`.
- The body scrolls internally when the content overflows — that is intended
  here, unlike `md-card`.
- **Width is responsive by default**: 100% below 640px, a centred 640px panel
  (capped at `calc(100% - 112px)`) at 640px and above. Override with
  `--md-bottom-sheet-width` / `--md-bottom-sheet-max-width`.
- Block size defaults to `auto`, capped at `80vh`
  (`--md-bottom-sheet-max-height`).
- The host is `display: contents`; the scrim and container are `position: fixed`
  in the library's popup/dialog stacking layer.

---

## Do / Don't

M3's bottom-sheet page carries guidance in prose rather than Do/Don't cards;
the rules below combine it with the closely-related
[side sheets](https://m3.material.io/components/side-sheets/guidelines) guidance
and this component's behavior.

| ✅ Do | ❌ Don't |
|---|---|
| Use bottom sheets for supplementary content on **mobile** | Don't use one for desktop side content — use `md-side-sheet` |
| Keep the drag handle so the gesture is discoverable | Don't hide the handle and then expect drag-to-dismiss to work |
| Let the body scroll vertically when content is long | Don't allow horizontal scrolling |
| Keep the sheet short enough to leave context visible | Don't cover the whole screen — that's a full-screen dialog |
| Provide an explicit close for keyboard and AT users | Don't rely on the drag gesture alone |
| Use it for dismissible, non-critical content | Don't put a blocking decision in a sheet — use a dialog |
| Give it a `headline` or `aria-label` | Don't ship a sheet named only by the built-in English fallback |
| Keep actions in the `actions` slot | Don't scatter actions through the body |

---

## Patterns

```html
<!-- Share sheet with a headline, a close button and an actions row -->
<md-button id="share-btn">Share</md-button>

<md-bottom-sheet id="share" headline="Share to" closeable>
  <md-list>
    <md-list-item>Copy link</md-list-item>
    <md-list-item>Email</md-list-item>
  </md-list>
  <md-button slot="actions" variant="text" id="share-cancel">Cancel</md-button>
</md-bottom-sheet>

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

  document.getElementById('share-btn')
    .addEventListener('mdClick', () => sheet.show());

  // Slotted action buttons never close the sheet on their own.
  document.getElementById('share-cancel')
    .addEventListener('mdClick', () => sheet.close());

  // Dismissal only (scrim / Escape / drag / close button).
  sheet.addEventListener('mdCancel', () => console.log('dismissed'));
</script>
```

```html
<!-- Detached, centred headline, no click-away dismissal -->
<md-bottom-sheet id="plans" variant="detached" headline="Choose a plan"
                 headline-align="center" content-align="center"
                 scrim-dismissible="false" closeable>
  <p>Switch plans at any time.</p>
</md-bottom-sheet>

<script type="module">
  document.getElementById('plans').show();
</script>
```

```html
<!-- Long scrolling content with rules above and below -->
<md-bottom-sheet id="filters" headline="Filters" top-divider bottom-divider>
  <label><md-checkbox value="in-stock"></md-checkbox> In stock</label>
  <label><md-checkbox value="on-sale"></md-checkbox> On sale</label>
  <label><md-checkbox value="free-delivery"></md-checkbox> Free delivery</label>
  <md-button slot="actions" variant="text" id="filters-reset">Reset</md-button>
  <md-button slot="actions" variant="filled" id="filters-apply">Apply</md-button>
</md-bottom-sheet>

<script type="module">
  const sheet = document.getElementById('filters');
  document.getElementById('filters-apply')
    .addEventListener('mdClick', () => sheet.close());
</script>
```

```html
<!-- Rich headline: the prop is still required for the header to render -->
<md-bottom-sheet id="rich" headline="Recent activity">
  <span slot="headline"><strong>Recent</strong> activity</span>
  <p>Nothing new today.</p>
</md-bottom-sheet>
```

```html
<!-- Edge-to-edge list: drop the content gutter -->
<md-bottom-sheet id="edge" headline="Pick a folder"
                 style="--md-bottom-sheet-content-padding-inline: 0;">
  <md-list>
    <md-list-item>Documents</md-list-item>
    <md-list-item>Downloads</md-list-item>
  </md-list>
</md-bottom-sheet>
```

## Anti-patterns

| ❌ Wrong | ✅ Right | Why |
|---|---|---|
| `<span slot="headline">` with no `headline` prop | Set `headline` too | The header only renders when the prop is non-empty, so the slot never mounts. |
| Expecting `variant="standard"` to be non-modal | Use `md-side-sheet` for non-modal | Both bottom-sheet variants render a scrim and trap focus. |
| Expecting a slotted `close` or action button to close the sheet | Call `close()` in its handler | Only the built-in `closeable` button is wired. |
| Handling `mdCancel` and `mdClose` as mutually exclusive | `mdCancel` implies `mdClose` | A dismissal emits both — you'll double-handle. |
| Assuming `mdOpen` won't fire for a sheet rendered with `open` | Expect it on mount | The mount handler runs the open path. |
| `show-drag-handle="false"` while relying on drag-to-dismiss | Keep the handle, or add `closeable` | The drag listeners live on the handle element. |
| Setting `scroll-shadow` | Delete it | It is a documented no-op. |
| Drag-to-dismiss as the only exit | Add `closeable` or an actions-row cancel | Keyboard and AT users can't drag. |
| A blocking confirmation in a sheet | `md-dialog` | Sheets are dismissible by design. |
| Horizontal scrolling inside the sheet | Vertical only | Narrow surface; M3 explicit. |
| A sheet covering the full viewport | `md-dialog fullscreen` | That's a different component. |
| No `headline` and no `aria-label` | Provide one | It falls back to the literal, untranslated name "Bottom sheet". |
| Setting `role`/`aria-modal` on `<md-bottom-sheet>` | Leave them alone | The container inside the shadow root already carries them. |
| `scrim-dismissible` left on for a destructive flow | `scrim-dismissible="false"` | Accidental dismissal. |

## Accessibility, RTL, density, i18n

**Accessibility**
- The container is `role="dialog"` with `aria-modal="true"`. Focus moves into
  the sheet on open, is kept inside while open, and returns to the previously
  focused element on close.
- Escape dismisses, firing `mdCancel` then `mdClose`.
- Name the sheet with `headline` (wired to `aria-labelledby`) or the
  `aria-label` attribute. With neither, it falls back to the hard-coded English
  string "Bottom sheet" — always set one of them in a localized app.
- The drag handle is `aria-hidden` and pointer-only. Always provide `closeable`
  or an actions-row cancel so there is a keyboard path out.
- The built-in close button's accessible name is the hard-coded English
  "Close bottom sheet"; use `slot="close"` with your own `md-icon-button` and
  `aria-label` to localize it, and wire it to `close()`.
- While closed the container is `inert`, so nothing inside it is reachable by
  keyboard or exposed to assistive tech.

**RTL** — the container, header, dividers and content use logical properties,
and `headline-align` / `content-align` are logical: `start` reads left in LTR
and right in RTL. `center` is direction-agnostic.

**Density** — set `density="-1"` … `density="-4"` for a local rung, or inherit a
global `data-density` ancestor. Rung `0` is the uncompacted default and has no
rule of its own, so `density="0"` does **not** opt a sheet out of an inherited
rung; use `style="--md-sys-density-scale: 0"` to reset the scale locally.
Density compacts the corner radius, content gutter, header and actions row.

**i18n** — translate `headline`, `aria-label`, action labels and body content,
and replace the built-in close button via `slot="close"` when you need its label
translated. Longer translations may push the sheet taller — check it still
leaves context visible under the `80vh` cap.

## Related components

`md-side-sheet` · `md-dialog` · `md-snackbar` · `md-menu` · `md-list` ·
`md-icon-button`

## Theming

| Custom property | Purpose | Default |
|---|---|---|
| `--md-bottom-sheet-container-color` | Surface background | `--md-sys-color-surface-container-low` |
| `--md-bottom-sheet-container-shape` | Corner radius | `max(16px, 28px + density × 2px)` |
| `--md-bottom-sheet-content-color` | Body text colour | `--md-sys-color-on-surface` |
| `--md-bottom-sheet-headline-color` | Headline text colour | `--md-sys-color-on-surface` |
| `--md-bottom-sheet-scrim-color` | Backdrop colour | `rgba(0, 0, 0, 0.32)` |
| `--md-bottom-sheet-drag-handle-color` | Drag-handle bar colour | `--md-sys-color-on-surface-variant` |
| `--md-bottom-sheet-divider-color` | Top / bottom rules | `--md-sys-color-outline-variant` |
| `--md-bottom-sheet-icon-color` | Close glyph colour | `--md-sys-color-on-surface-variant` |
| `--md-bottom-sheet-width` | Inline size | `100%` below 640px, `640px` at 640px+ |
| `--md-bottom-sheet-min-width` | Minimum inline size | `0` |
| `--md-bottom-sheet-max-width` | Maximum inline size | `100%` below 640px, `calc(100% - 112px)` at 640px+ |
| `--md-bottom-sheet-height` | Block size | `auto` |
| `--md-bottom-sheet-min-height` | Minimum block size | `0` |
| `--md-bottom-sheet-max-height` | Maximum block size | `80vh` |
| `--md-bottom-sheet-detached-margin` | Inset on the `detached` variant | `16px` |
| `--md-bottom-sheet-content-padding-inline` | Gutter inside the scroll area | `max(12px, 24px + density × 2px)` |

**CSS parts** — `scrim`, `container`, `drag-handle`, `drag-handle-indicator`,
`header`, `headline`, `close`, `divider-top`, `content`, `divider-bottom`,
`actions`.

```css
md-bottom-sheet.compact-panel {
  --md-bottom-sheet-max-height: 50vh;
  --md-bottom-sheet-max-width: 480px;
  --md-bottom-sheet-scrim-color: rgba(0, 0, 0, 0.6);
}

md-bottom-sheet.compact-panel::part(headline) {
  font-weight: 600;
}
```

<!-- Auto Generated Below -->


## Overview

MD3 Bottom sheet — secondary content anchored to the bottom of the screen.

Two variants per the M3 spec
(https://m3.material.io/components/bottom-sheets/overview). Both are
modal dialogs — they always render a scrim, trap focus, lock body
scroll, close on Escape, and support drag-to-dismiss.

- `standard` (default) — anchored to the bottom edge of the viewport.
  Full-width up to a responsive max, with rounded top corners only.
- `detached` — floats with a margin on every side and rounded corners
  on every side. Best for compact, dialog-like sheets on larger
  screens.

Width and height are entirely token-driven, so the only thing you
change to switch from anchored to floating chrome is the `variant`
attribute. The default media query reflows the sheet between
full-width on mobile and a centred 640px panel on desktop; consumers
can opt out via `--md-bottom-sheet-width` / `--md-bottom-sheet-max-width`.

## Properties

| Property           | Attribute           | Description                                                                                                                                                                                                                                                                                                                  | Type                           | Default      |
| ------------------ | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ------------ |
| `bottomDivider`    | `bottom-divider`    | Show divider between content and actions                                                                                                                                                                                                                                                                                     | `boolean`                      | `false`      |
| `closeable`        | `closeable`         | Show a close icon-button in the top-end corner of the header                                                                                                                                                                                                                                                                 | `boolean`                      | `false`      |
| `contentAlign`     | `content-align`     | Inline-axis alignment of the slotted content. Inherits to all default-slot children, so prose, lists, and forms all pick it up. `start` (default) reads left in LTR / right in RTL, `end` reads right in LTR / left in RTL, `center` is direction-agnostic. Slotted children that explicitly set their own `text-align` win. | `"center" \| "end" \| "start"` | `'start'`    |
| `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`          |
| `headline`         | `headline`          | Headline text (or use the `headline` slot)                                                                                                                                                                                                                                                                                   | `string`                       | `''`         |
| `headlineAlign`    | `headline-align`    | Inline-axis alignment of the headline text. `start` (default) reads left in LTR / right in RTL, `end` reads right in LTR / left in RTL, `center` is direction-agnostic. Use the matching `::part(headline)` selector for finer control (e.g. `text-align: justify`).                                                         | `"center" \| "end" \| "start"` | `'start'`    |
| `open`             | `open`              | Whether the sheet is visible                                                                                                                                                                                                                                                                                                 | `boolean`                      | `false`      |
| `scrimDismissible` | `scrim-dismissible` | Whether clicking the scrim closes the sheet                                                                                                                                                                                                                                                                                  | `boolean`                      | `true`       |
| `scrollShadow`     | `scroll-shadow`     | Retained for API compatibility, but now a no-op: the content area always plain-scrolls when it overflows (no scroll shadow / edge fades). Setting this has no visual effect.                                                                                                                                                 | `boolean`                      | `true`       |
| `sheetAriaLabel`   | `aria-label`        | Custom aria-label for the container                                                                                                                                                                                                                                                                                          | `string`                       | `''`         |
| `showDragHandle`   | `show-drag-handle`  | Show the drag handle indicator                                                                                                                                                                                                                                                                                               | `boolean`                      | `true`       |
| `topDivider`       | `top-divider`       | Show divider between header and content                                                                                                                                                                                                                                                                                      | `boolean`                      | `false`      |
| `variant`          | `variant`           | `standard` (default) anchors the sheet to the bottom edge. `detached` floats it with margin on every side. Both variants always render a scrim and trap focus.                                                                                                                                                               | `"detached" \| "standard"`     | `'standard'` |


## Events

| Event      | Description                                                         | Type                |
| ---------- | ------------------------------------------------------------------- | ------------------- |
| `mdCancel` | Emits when dismissed via scrim click, Escape, drag, or close button | `CustomEvent<void>` |
| `mdClose`  | Emits when the sheet closes                                         | `CustomEvent<void>` |
| `mdOpen`   | Emits when the sheet opens                                          | `CustomEvent<void>` |


## Methods

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



#### Returns

Type: `Promise<void>`



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



#### Returns

Type: `Promise<void>`




## Slots

| Slot         | Description                                               |
| ------------ | --------------------------------------------------------- |
|              | Main content                                              |
| `"actions"`  | Bottom action buttons (optional)                          |
| `"close"`    | Custom close element (replaces default close icon button) |
| `"headline"` | Custom headline content                                   |


## Shadow Parts

| Part                      | Description                                                                                  |
| ------------------------- | -------------------------------------------------------------------------------------------- |
| `"actions"`               | Bottom action bar                                                                            |
| `"close"`                 | Close button wrapper                                                                         |
| `"container"`             | Sheet surface                                                                                |
| `"content"`               | Content area wrapper (inline padding, edge-to-edge frame); the content scrolls directly here |
| `"divider-bottom"`        | Bottom divider (between content and actions)                                                 |
| `"divider-top"`           | Top divider (between header and content)                                                     |
| `"drag-handle"`           | Drag handle hit area                                                                         |
| `"drag-handle-indicator"` | Drag handle visible bar                                                                      |
| `"header"`                | Header row (headline + close)                                                                |
| `"headline"`              | Headline text                                                                                |
| `"scrim"`                 | Scrim overlay                                                                                |


## Dependencies

### Depends on

- [md-icon-button](../md-icon-button)

### Graph
```mermaid
graph TD;
  md-bottom-sheet --> md-icon-button
  md-icon-button --> md-ripple
  style md-bottom-sheet 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.