Skip to content

Layout

Canonical layouts

Ready-made adaptive layouts: list-detail, supporting pane and feed, with the window size classes that drive them.

Examples

List-detail

One pane below 840px of the layout's own width (the list, then the detail after a selection, with Back), list and detail side by side from 840px. The breakpoints follow the container, not the window.

The live demo runs with JavaScript. Its code is below.

React HTML
import {
  Icon,
  IconButton,
  List,
  ListDetailPaneScaffold,
  ListItem,
  useListDetailPaneScaffoldNavigator,
} from '@kyralabs/m3exp-web/react';

const MESSAGES = [
  { key: 'brunch', from: 'Ali Connors', subject: 'Brunch this weekend?' },
  { key: 'bbq', from: 'Sandra Adams', subject: 'Summer BBQ' },
  { key: 'trip', from: 'Trevor Hansen', subject: 'Trip photos' },
];

/** The list alone below 840px of the layout's own width; list and detail side by side above. */
export default function ListDetail() {
  const navigator = useListDetailPaneScaffoldNavigator<string>();
  const selected = [...navigator.destinationHistory]
    .reverse()
    .find((d) => d.contentKey !== undefined)?.contentKey;
  const message = MESSAGES.find((m) => m.key === selected);
  return (
    <div
      style={{
        inlineSize: '100%',
        blockSize: 'var(--m3e-sys-window-height-medium)',
        overflow: 'hidden',
      }}
    >
      <ListDetailPaneScaffold
        navigator={navigator}
        paneLabels={{ list: 'Inbox', detail: 'Message' }}
        listPane={
          <List
            aria-label="Inbox"
            onAction={(key) => {
              navigator.navigateTo('detail', String(key));
            }}
          >
            {MESSAGES.map((m) => (
              <ListItem key={m.key} id={m.key} supportingContent={m.subject}>
                {m.from}
              </ListItem>
            ))}
          </List>
        }
        detailPane={
          <div style={{ padding: 'var(--m3e-sys-space-200)' }}>
            {navigator.isExpanded('list') ? null : (
              <IconButton
                aria-label="Back"
                onPress={() => {
                  navigator.navigateBack();
                }}
              >
                <Icon name="arrow_back" />
              </IconButton>
            )}
            <p>{message ? `${message.from}: ${message.subject}` : 'Select a message.'}</p>
          </div>
        }
      />
    </div>
  );
}
<div style="inline-size: 100%; block-size: var(--m3e-sys-window-height-medium); overflow: hidden">
  <m3e-list-detail-layout id="inbox-layout" list-label="Inbox" detail-label="Message">
    <m3e-list slot="list" label="Inbox">
      <div class="m3e-list-item" data-value="brunch">
        <span class="m3e-list-item__content">
          <span class="m3e-list-item__headline">Ali Connors</span>
          <span class="m3e-list-item__supporting">Brunch this weekend?</span>
        </span>
      </div>
      <div class="m3e-list-item" data-value="bbq">
        <span class="m3e-list-item__content">
          <span class="m3e-list-item__headline">Sandra Adams</span>
          <span class="m3e-list-item__supporting">Summer BBQ</span>
        </span>
      </div>
      <div class="m3e-list-item" data-value="trip">
        <span class="m3e-list-item__content">
          <span class="m3e-list-item__headline">Trevor Hansen</span>
          <span class="m3e-list-item__supporting">Trip photos</span>
        </span>
      </div>
    </m3e-list>
    <div slot="detail" style="padding: var(--m3e-sys-space-200)">
      <button
        type="button"
        class="m3e-icon-button m3e-icon-button--standard"
        aria-label="Back"
        data-pane-navigate="back"
      >
        <span class="m3e-icon" aria-hidden="true">arrow_back</span>
      </button>
      <p id="message">Select a message.</p>
    </div>
  </m3e-list-detail-layout>
</div>
<script>
  const layout = document.getElementById('inbox-layout');
  const MESSAGES = {
    brunch: 'Ali Connors: Brunch this weekend?',
    bbq: 'Sandra Adams: Summer BBQ',
    trip: 'Trevor Hansen: Trip photos',
  };
  layout.querySelector('m3e-list').addEventListener('action', (event) => {
    document.getElementById('message').textContent = MESSAGES[event.detail.value];
    layout.navigateTo('detail', event.detail.value);
  });
</script>

Run the HTML version (custom elements, no framework).

Supporting pane

The main pane alone below 840px; the supporting pane beside it from 840px, or under it when the layout is at least 900px tall.

The live demo runs with JavaScript. Its code is below.

React HTML
import { SupportingPaneScaffold } from '@kyralabs/m3exp-web/react';

/** The main pane alone below 840px; the supporting pane beside it above. */
export default function SupportingPane() {
  return (
    <div
      style={{
        inlineSize: '100%',
        blockSize: 'var(--m3e-sys-window-height-medium)',
        overflow: 'hidden',
      }}
    >
      <SupportingPaneScaffold
        paneLabels={{ main: 'Article', supporting: 'Related' }}
        mainPane={
          <article style={{ padding: 'var(--m3e-sys-space-200)' }}>
            <h4>Five days in the mountains</h4>
            <p>The main pane holds the content people came for.</p>
          </article>
        }
        supportingPane={
          <aside style={{ padding: 'var(--m3e-sys-space-200)' }}>
            <h4>Related</h4>
            <p>Supporting content that adds to it.</p>
          </aside>
        }
      />
    </div>
  );
}
<div style="inline-size: 100%; block-size: var(--m3e-sys-window-height-medium); overflow: hidden">
  <m3e-supporting-pane-layout main-label="Article" supporting-label="Related">
    <article slot="main" style="padding: var(--m3e-sys-space-200)">
      <h4>Five days in the mountains</h4>
      <p>The main pane holds the content people came for.</p>
    </article>
    <aside slot="supporting" style="padding: var(--m3e-sys-space-200)">
      <h4>Related</h4>
      <p>Supporting content that adds to it.</p>
    </aside>
  </m3e-supporting-pane-layout>
</div>

Run the HTML version (custom elements, no framework).

Feed

A grid that is one column below 600px of its own width, then as many columns as fit.

Mountains

A post in the feed.

Forest

A post in the feed.

Beach

A post in the feed.

Sailing

A post in the feed.

Hiking

A post in the feed.

Kayaking

A post in the feed.

React HTML
import { Card, FeedLayout } from '@kyralabs/m3exp-web/react';

const POSTS = ['Mountains', 'Forest', 'Beach', 'Sailing', 'Hiking', 'Kayaking'];

/** One column below 600px of its own width, then as many as fit. */
export default function Feed() {
  return (
    <div style={{ inlineSize: '100%' }}>
      <FeedLayout>
        {POSTS.map((post) => (
          <Card
            key={post}
            variant="filled"
            as="article"
            style={{ padding: 'var(--m3e-sys-space-200)' }}
          >
            <h4>{post}</h4>
            <p>A post in the feed.</p>
          </Card>
        ))}
      </FeedLayout>
    </div>
  );
}
<div class="m3e-layout" style="inline-size: 100%">
  <div class="m3e-feed">
    <article class="m3e-card m3e-card--filled" style="padding: var(--m3e-sys-space-200)">
      <h4>Mountains</h4>
      <p>A post in the feed.</p>
    </article>
    <article class="m3e-card m3e-card--filled" style="padding: var(--m3e-sys-space-200)">
      <h4>Forest</h4>
      <p>A post in the feed.</p>
    </article>
    <article class="m3e-card m3e-card--filled" style="padding: var(--m3e-sys-space-200)">
      <h4>Beach</h4>
      <p>A post in the feed.</p>
    </article>
    <article class="m3e-card m3e-card--filled" style="padding: var(--m3e-sys-space-200)">
      <h4>Sailing</h4>
      <p>A post in the feed.</p>
    </article>
    <article class="m3e-card m3e-card--filled" style="padding: var(--m3e-sys-space-200)">
      <h4>Hiking</h4>
      <p>A post in the feed.</p>
    </article>
    <article class="m3e-card m3e-card--filled" style="padding: var(--m3e-sys-space-200)">
      <h4>Kayaking</h4>
      <p>A post in the feed.</p>
    </article>
  </div>
</div>

Run the HTML version (CSS classes only).

API

React

<ListDetailPaneScaffold>

List-detail canonical layout (COMPONENT_SPEC §16a; Compose ListDetailPaneScaffold): the list alone below 840px of the layout's own width (the detail after navigateTo('detail'), back with navigateBack()), list and detail from 840px, list, detail and extra from 1600px (m3.material.io; paneRule="compose": from 1200px, Compose's calculatePaneScaffoldDirective). Panes move on Compose's pane motion and focus follows the destination (m3e-elements' PaneScaffoldLayout, the code <m3e-list-detail-layout> runs). Elements with data-pane-navigate="detail" / "back" inside navigate on click. Other props go to the .m3e-layout root (the size container); give it a height.

Prop Type Default Description
navigator ListDetailPaneScaffoldNavigator<any> | undefined From useListDetailPaneScaffoldNavigator(); default: one of its own.
listPane required ReactNode The list pane (Compose listPane, ThreePaneScaffoldRole.Secondary).
detailPane required ReactNode The detail pane (Compose detailPane, Primary).
extraPane ReactNode An optional extra pane (Compose extraPane, Tertiary).
paneLabels Partial<Record<Pane, string>> | undefined Pane region names (default Compose's "Primary pane" / "Secondary pane" / "Tertiary pane").
paneRule "compose" | "recommended" recommended (default: m3.material.io, two panes at large, three from 1600px) or compose (Compose's calculatePaneScaffoldDirective: three panes from large, 1200px).
hasDragHandle boolean | undefined A pane expansion drag handle between two expanded panes (Compose paneExpansionDragHandle).
anchors readonly PaneExpansionAnchor[] | undefined Anchors the drag handle snaps to (default: the Compose samples' anchors).
preferredWidths Partial<Record<Pane, number>> | undefined PaneScaffoldScope.preferredWidth per pane (px).

Also accepts HTML attributes not listed here.

<SupportingPaneScaffold>

Supporting-pane canonical layout (COMPONENT_SPEC §16a; Compose SupportingPaneScaffold): the main pane alone below 840px (the supporting pane reflows under it when the layout is at least 900px tall), main and supporting from 840px, all three from 1600px (paneRule="compose": from 1200px). Same behavior as ListDetailPaneScaffold.

Prop Type Default Description
navigator SupportingPaneScaffoldNavigator<any> | undefined From useSupportingPaneScaffoldNavigator(); default: one of its own.
mainPane required ReactNode The main pane (Compose mainPane, Primary).
supportingPane required ReactNode The supporting pane (Compose supportingPane, Secondary).
extraPane ReactNode An optional extra pane (Compose extraPane, Tertiary).
paneLabels Partial<Record<Pane, string>> | undefined Pane region names (default Compose's "Primary pane" / "Secondary pane" / "Tertiary pane").
paneRule "compose" | "recommended" recommended (default: m3.material.io, two panes at large, three from 1600px) or compose (Compose's calculatePaneScaffoldDirective: three panes from large, 1200px).
hasDragHandle boolean | undefined A pane expansion drag handle between two expanded panes (Compose paneExpansionDragHandle).
anchors readonly PaneExpansionAnchor[] | undefined Anchors the drag handle snaps to (default: the Compose samples' anchors).
preferredWidths Partial<Record<Pane, number>> | undefined PaneScaffoldScope.preferredWidth per pane (px).

Also accepts HTML attributes not listed here.

<FeedLayout>

Feed canonical layout (COMPONENT_SPEC §16a; m3.material.io Canonical examples › Feed — Compose has no feed scaffold): a grid of items, one column below 600px of its own width, from 600px as many columns as fit, with m3.material.io's 16 / 24dp margins. CSS only: renders div.m3e-layout > div.m3e-feed. Props go to the .m3e-feed grid.

Prop Type Default Description
children ReactNode The feed's items (give an item className="m3e-feed__item--wide" to span two columns).

Also accepts HTML attributes not listed here.

useListDetailPaneScaffoldNavigator()

rememberListDetailPaneScaffoldNavigator: the list first (Compose's default history); pass the result to <ListDetailPaneScaffold navigator>.

useSupportingPaneScaffoldNavigator()

rememberSupportingPaneScaffoldNavigator: the main pane first.

ThreePaneScaffoldNavigator()

DefaultThreePaneScaffoldNavigator (ThreePaneScaffoldNavigator.kt L359-490) without Compose's transition state: navigateTo / navigateBack change the history and the value at once; the caller animates (and Compose's directive change snaps, L349-354: so does setting scaffoldDirective here). Content keys compare with Object.is (Kotlin equals for data classes: use primitives or shared objects).

useWindowSizeClass()

The current window size class (FOUNDATIONS §9; Compose currentWindowAdaptiveInfoV2() .windowSizeClass), re-rendering only when the width or height class changes. Follows the viewport, or target's content box, through m3e-elements' observeWindowSizeClass. Pass the result to isWidthAtLeastBreakpoint, paneScaffoldDirective or navigationSuiteType (@kyralabs/m3e-motion, also re-exported by @kyralabs/m3e-elements and the umbrella).

Custom elements

<m3e-list-detail-layout>

import '@kyralabs/m3e-elements/list-detail-layout.js';

<m3e-list-detail-layout> (COMPONENT_SPEC §16a; Compose ListDetailPaneScaffold with rememberListDetailPaneScaffoldNavigator): children with slot="list", slot="detail" (also every child without a slot) and slot="extra" become the list, detail and extra panes. One pane below 840px of the layout's own width (the list until something navigates to the detail), list and detail from 840px, all three from 1600px (m3.material.io; pane-rule="compose": from 1200px, Compose's calculatePaneScaffoldDirective).

  • Attributes: list-label, detail-label, extra-label (the panes' region names; default Compose's "Secondary pane" / "Primary pane" / "Tertiary pane"), pane-rule (recommended, the default, | compose), drag-handle (a pane expansion handle between two panes).
  • Properties: navigator (m3e-motion ThreePaneScaffoldNavigator; created on first use), anchors (drag handle anchors), preferredWidths (px per role), read-only scaffoldValue, currentDestination.
  • Methods: navigateTo(pane, contentKey?) (list | detail | extra or a role), navigateBack(behavior?), canNavigateBack(behavior?).
  • Declarative: a click on an element with data-pane-navigate="detail" (and an optional data-content-key) navigates there; data-pane-navigate="back" navigates back and hides while there is nothing to go back to.
  • Event: scaffold-value-change (detail: { value, destination }) after every navigation or size change.
Attribute Property Type Notes
pane-rule paneRule string
drag-handle dragHandle boolean
list-label listLabel string
detail-label detailLabel string
extra-label extraLabel string
Event Detail Description
scaffold-value-change ScaffoldValueChangeDetail The navigator moved: the scaffold value and the current destination.

<m3e-supporting-pane-layout>

import '@kyralabs/m3e-elements/supporting-pane-layout.js';

<m3e-supporting-pane-layout> (COMPONENT_SPEC §16a; Compose SupportingPaneScaffold with rememberSupportingPaneScaffoldNavigator): children with slot="main" (also every child without a slot), slot="supporting" and slot="extra" become the main, supporting and extra panes. One pane below 840px of the layout's own width (the main pane; the supporting pane reflows under it when the layout is at least 900px tall), main and supporting from 840px, all three from 1600px (m3.material.io; pane-rule="compose": from 1200px, Compose's calculatePaneScaffoldDirective).

Attributes main-label, supporting-label, extra-label, pane-rule, drag-handle; the properties, methods (navigateTo('supporting'), …), declarative data-pane-navigate controls and the scaffold-value-change event are those of <m3e-list-detail-layout>.

Attribute Property Type Notes
pane-rule paneRule string
drag-handle dragHandle boolean
main-label mainLabel string
supporting-label supportingLabel string
extra-label extraLabel string
Event Detail Description
scaffold-value-change ScaffoldValueChangeDetail The navigator moved: the scaffold value and the current destination.

CSS

layout.css

@import '@kyralabs/m3e-css/layout.css';

Canonical layouts (COMPONENT_SPEC §16a, FOUNDATIONS §9): list-detail and supporting pane (Compose ListDetailPaneScaffold / SupportingPaneScaffold over ThreePaneScaffold), and the feed (m3.material.io; Compose has no feed scaffold).

<div class="m3e-layout">                                    (the size container)
  <div class="m3e-pane-scaffold m3e-pane-scaffold--list-detail" data-destination="secondary">
    <div class="m3e-pane-scaffold__pane m3e-pane-scaffold__pane--secondary"
         role="region" aria-label="Inbox" tabindex="-1">…list…</div>
    <div class="m3e-pane-scaffold__pane m3e-pane-scaffold__pane--primary" …>…detail…</div>
    <div class="m3e-pane-scaffold__pane m3e-pane-scaffold__pane--tertiary" …>…extra…</div>
  </div>
</div>

<div class="m3e-layout"><div class="m3e-feed">…items (.m3e-feed__item--wide spans two
  columns)…</div></div>

Pane roles are Compose's ThreePaneScaffoldRole: list-detail = list (secondary), detail (primary), extra (tertiary), in that order from the start; supporting pane = main (primary), supporting (secondary), extra (tertiary). Put the panes in that DOM order (it is the visual and focus order; RTL mirrors it).

Sizing is the layout's own: .m3e-layout is an inline-size container and the queries below read its width, so a layout inside a navigation suite's content area (or any pane) adapts to the room it has, not to the window. Compose reads the window size class (calculatePaneScaffoldDirective); the breakpoints are the same, the pane counts by default m3.material.io's (Breakpoints; owner decision 2026-10-18, COMPONENT_SPEC §16a):

  • below 840px (compact, medium): one pane, the destination (data-destination: primary,
`secondary` or `tertiary`; default the list / the main pane);
  • from 840px (expanded) and at large (1200px): two panes, the destination plus the
highest-priority other pane (primary, then secondary, then tertiary), 24dp apart;
  • from 1600px (extra-large): all three panes;
  • --compose follows Compose's calculatePaneScaffoldDirective instead: all three panes from
1200px (large).

Each pane starts at its preferred width (360dp, 412dp from large; a pane's own --m3e-comp-pane-scaffold-pane-preferred-width overrides it), the primary pane takes the rest, and every pane shrinks by the same ratio when they do not fit (ThreePaneScaffold's measure policy). Give the layout a height (it fills its parent); each pane scrolls on its own.

CSS alone shows the current destination only. m3e-elements' PaneScaffoldLayout (used by <m3e-list-detail-layout>, <m3e-supporting-pane-layout> and m3e-react) adds the destination history, reflow, navigation motion, focus and the drag handle. It sets [data-managed] and then writes, per pane, data-pane-value (expanded | reflowed | hidden), [data-grow] (the pane that takes the rest), [data-collapsed] (a split dragged to an edge) and --_m3e-pane-basis (a resting drag split), plus [data-reflow] on the scaffold (a pane reflowed under another: stacked, the lower one at most its preferred height or half). While panes move ([data-animating]) every shown pane is absolutely placed from

--_m3e-pane-x / --_m3e-pane-y / --_m3e-pane-w / --_m3e-pane-h   (px, physical, per frame)

and an exiting pane keeps [data-exiting] until it is out; nothing reads layout meanwhile. The drag handle (__drag-handle, a separator around .m3e-vertical-drag-handle) is centred on

--_m3e-pane-handle-x   (px from the left edge)

while [data-handle] is set (exactly two panes expanded).

10 custom properties it reads
Custom property Default
--m3e-comp-divider-thickness 1px
--m3e-comp-drag-handle-shape var(--m3e-sys-shape-corner-full)
--m3e-comp-feed-column-min-width 240px
--m3e-comp-feed-gap 8px
--m3e-comp-feed-margin 24px
--m3e-comp-feed-margin-compact 16px
--m3e-comp-minimum-interactive-component-size 48px
--m3e-comp-pane-scaffold-pane-preferred-height 420px
--m3e-comp-pane-scaffold-pane-preferred-width 360px
--m3e-comp-pane-scaffold-partition-spacer-size 24px

vertical-drag-handle.css

@import '@kyralabs/m3e-css/vertical-drag-handle.css';

Vertical drag handle (Compose VerticalDragHandle, material3 DragHandle.kt): the bar a pane scaffold's pane expansion handle draws (layout.css). The host gives it its touch target and states:

<div class="m3e-pane-scaffold__drag-handle" role="separator" …>   (≥ 48dp target)
  <span class="m3e-vertical-drag-handle"></span>
</div>

[data-pressed] on the host: pressed size / shape / color; [data-dragged]: dragged. Compose switches them without animation (graphicsLayer shape and layout size read the interaction state directly, DragHandle.kt L84-124), so this does too.

Tokens (DragHandleTokens.kt @ 19991f2c, generated drag-handle.css): 4 × 48dp, corner full, outline; pressed and dragged 12 × 52dp, corner medium, on-surface.

12 custom properties it reads
Custom property Default
--m3e-comp-drag-handle-color var(--m3e-sys-color-outline)
--m3e-comp-drag-handle-color-dragged var(--m3e-sys-color-on-surface)
--m3e-comp-drag-handle-color-pressed var(--m3e-sys-color-on-surface)
--m3e-comp-drag-handle-height 48px
--m3e-comp-drag-handle-height-dragged 52px
--m3e-comp-drag-handle-height-pressed 52px
--m3e-comp-drag-handle-shape var(--m3e-sys-shape-corner-full)
--m3e-comp-drag-handle-shape-dragged var(--m3e-sys-shape-corner-medium)
--m3e-comp-drag-handle-shape-pressed var(--m3e-sys-shape-corner-medium)
--m3e-comp-drag-handle-width 4px
--m3e-comp-drag-handle-width-dragged 12px
--m3e-comp-drag-handle-width-pressed 12px

Differences from Compose

sized by the layout, not the window (decided; Compose reads the window); Levitate, predictive back and hinges are not built; bounds animate as rectangles (content re-lays out at the animated width) where Compose's EnterWithExpand / ExitWithShrink clip a full-width pane; Compose scales the bounds animation to its transition's total duration when a delayed enter runs alongside, here every pane runs its own spring in real time; the drag release picks the anchor from the release position (Compose runs the fling's decay first); keyboard handling of the drag handle is web-only (Compose: one click action); the first navigation of an element jumps if it happens before the lazily loaded pane-motion.js entry arrives; the CSS-only layout has no history and no reflow; the feed gap is the Cards Specs' 8dp and the column width a project default (D19 (5), (6); SOURCES #65, #66).

Sources

Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from m3.material.io, as cited below from COMPONENT_SPEC §16a and the m3e-css sources.

material3-adaptive adaptive-layout ThreePaneScaffold.kt, ThreePaneScaffoldValue.kt, AdaptStrategy.kt, ListDetailPaneScaffold.kt, SupportingPaneScaffold.kt, PaneScaffoldDirective.kt, PaneMotion.kt, ThreePaneMotion.kt, Pane.kt, PaneExpansionState.kt, PaneExpansionDraggableModifier.kt, androidMain res/values/strings.xml; adaptive-navigation ThreePaneScaffoldNavigator.kt, BackNavigationBehavior.kt; samples ThreePaneScaffoldSample.kt; material3 DragHandle.kt + DragHandleTokens.kt (all vendored at 19991f2c); androidx tests read for reference: PaneMotionTest.kt, ThreePaneScaffoldValueTest.kt, ListDetailPaneScaffoldNavigatorTest.kt. m3.material.io (carbon 2026-10-13_06-10-19): Canonical examples (23b84814-…json: feed, list-detail and supporting-pane guidance), Scaffold (7490d3ef-…json: pane counts, drag handles, focus order), Breakpoints (16281dc1-…json: margins, spacer, recommended pane counts, fixed pane widths).

no *Tokens exist; components/pane-scaffold-defaults.css (PARITY.md lines):

layout.css sources

(material3-adaptive adaptive-layout @ 19991f2c; values in pane-scaffold-defaults.css, lines in PARITY.md): PaneScaffoldDirective.kt (spacer, default pane sizes), ThreePaneScaffold.kt (measure policy L700-771), ListDetailPaneScaffold.kt / SupportingPaneScaffold.kt (pane orders); feed: m3.material.io Canonical examples › Feed (one column at compact, more columns as the window grows), Breakpoints (margins 16dp compact, 24dp from medium) and Cards › Specs (8dp between cards; SOURCES register #65, D19 (5)); the 240dp column minimum is a project default (register #66, D19 (6)).