Layout
Canonical layouts
Ready-made adaptive layouts: list-detail, supporting pane and feed, with the window size classes that drive them.
- COMPONENT_SPEC §16a
- CSS · custom elements · React
- Open in the playground
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.
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.
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.
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-onlyscaffoldValue,currentDestination. - Methods:
navigateTo(pane, contentKey?)(list|detail|extraor a role),navigateBack(behavior?),canNavigateBack(behavior?). - Declarative: a click on an element with
data-pane-navigate="detail"(and an optionaldata-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)).