Layout
Frame your app with Scaffold, choose a canonical layout for its content, or compose Pane and PaneGrid for a custom arrangement.
Application scaffold
Scaffold renders App once, including its safe-area handling and providers.
Its optional appBar, rail, actionRail and navbar snippets hold the perimeter regions; children fill the pane region. Do not wrap
it in another App. It accepts the same configuration props as App, and each bar or rail retains its own positioning and visibility rules.
<script lang="ts">
import { Scaffold, AppBar, Pane, Title, Body } from '@noxlovette/material';
</script>
<Scaffold>
{#snippet appBar()}
<AppBar title="Workspace" ghost />
{/snippet}
<Pane>
<Title>Overview</Title>
<Body>Your content goes here.</Body>
</Pane>
</Scaffold> Add a Rail in the leading rail slot and a Navbar in the bottom slot for
adaptive navigation. A trailing ActionRail can hold a vertical toolbar. Slots do not
automatically reserve space for arbitrary custom fixed elements. Use AppBar ghost to reserve the measured top-bar height in the document flow; the published height adjusts a full
pane's minimum height but does not move its top edge. Application regions example.
Two primitives
A Pane is one region: its width, whether it sticks, whether it resizes, and at which
window sizes it shows. A PaneGrid arranges panes with direction, gap, padding and margin, each responsive. A pane without width grows to fill the remaining space. The default direction is column below expanded
and row from expanded (840dp).
Keep one to three visible panes, with at least one flexible pane. PaneGrid warns about violations in development; this is guidance, not a production layout constraint. Feed
cards are items inside a pane, not separate panes. This site's guides use a sticky navigation pane
and a content pane with an "On this page" pane beside it.
Single pane
A single-column page needs no grid. padding and gap space the content
and adapt to the window size; contentClass caps its width:
<Pane padding="lg" gap="lg" contentClass="mx-auto max-w-3xl"> <Title>Page title</Title> <Body>Content at a readable width.</Body> </Pane>
Supporting pane
Use SupportingPane when secondary content depends on the primary task: a document and
its comments, or a lesson and its context. Compact and medium stack support below the focus pane;
expanded places a fixed 360px supporting pane beside a flexible focus pane. placement="start" puts support on the leading side at expanded and above; "end" is the default. Below expanded both placements keep support below.
<SupportingPane placement="end" sticky>
{#snippet main()}
<Title>Proposal</Title>
<Body>The document being reviewed.</Body>
{/snippet}
{#snippet supporting()}
<Title>Comments</Title>
<Body>Context for this proposal.</Body>
{/snippet}
</SupportingPane> supportingWidth customizes the side width. sticky uses native position: sticky inside the current scroll container, and the deprecated stickyTop sets a px top offset (by default it follows the app bar). For a custom arrangement
use PaneGrid with a flexible Pane and a fixed-width sticky Pane. Document and comments example.
List-detail
Use ListDetail for a collection and the selected item's detail. Compact shows only
the list or the detail, selected by detailOpen. Medium also shows one pane by
default; mediumPanes=2 opts into side-by-side browsing at 600dp. Expanded and above
always show two panes. listWidth defaults to 360px.
<script lang="ts">
import { Body, Button, List, ListDetail, ListItem, Title } from '@noxlovette/material';
let detailOpen = $state(false);
let selected = $state('');
const items = ['Design review', 'Research notes'];
</script>
<ListDetail bind:detailOpen>
{#snippet list({ twoPane })}
<List aria-label="Messages">
{#each items as item (item)}
<ListItem headline={item}
selected={twoPane && detailOpen && selected === item}
onclick={() => { selected = item; detailOpen = true; }} />
{/each}
</List>
{/snippet}
{#snippet detail({ twoPane })}
{#if !twoPane}
<Button variant="text" onclick={() => (detailOpen = false)}>Back to inbox</Button>
{/if}
<Title>{selected}</Title>
<Body>Message content.</Body>
{/snippet}
{#snippet empty()}
<Body>Choose a message.</Body>
{/snippet}
</ListDetail> Both snippets receive twoPane: show list selection only in two-pane mode and a
back action only in single-pane detail mode. With no selection, two-pane mode renders empty, while single-pane mode renders the list. Selection remains yours when the
window changes. The layout does not fetch content, manage routes, preserve per-item scroll, or
restore focus after navigation; keep those policies in your application. Interactive inbox example.
Feed
Use Feed inside a Pane for independent browsable cards. Only one compact
column is defaulted. Set columns for your content; omitted tiers inherit the preceding
count. The counts below are a product choice, not mandated M3 column counts.
<Pane>
<Feed columns={{ compact: 1, medium: 2, expanded: 3, large: 4 }}>
<Card class="medium:col-span-2">
<Title>Lead article</Title>
<Body>A prominent item spans two columns only when they exist.</Body>
</Card>
<Card><Title>Another article</Title></Card>
<Card><Title>From the studio</Title></Card>
</Feed>
</Pane> Mixed card sizes use responsive column spans. Do not apply a two-column span at compact when the feed has only one column: that would create an extra implicit column. Feed is a regular CSS grid, not masonry; keep a meaningful source order. Mixed-size example and editable column counts.
Resizable split
Put a PaneHandle in the grid next to a pane and give both the same persistKey. The handle drags the pane's width between min and max, snaps to M3's recommended 360dp and 412dp widths, and saves the width to localStorage. The persistKey is required on the handle, and on any pane
that sets resizable. The handle is the gutter: while one is visible the grid's gap is none. In development, the grid warns about a resizable pane with no matching
handle, or a handle with no matching pane.
<PaneGrid direction="row">
<Pane width={320} resizable persistKey="app:nav-width">
<Title>Navigation</Title>
</Pane>
<PaneHandle persistKey="app:nav-width" min={240} max={480} />
<Pane>
<Body>Main content.</Body>
</Pane>
</PaneGrid> direction="row" deliberately keeps this split side by side at every width. For a responsive
split, give the handle the same visibleFrom or hiddenFrom as its fixed pane so an orphan handle is not left visible. Resizable split example.
With an app bar
A grid with full fills the window's height, minus the app bar's height: AppBar publishes --appbar-height and the grid reads it. A sticky pane
sits flush under the bar by default; the deprecated stickyTop still overrides that
offset in px. Avoid putting a viewport-height grid into a shorter overflow-hidden demo box: it can
clip the content.
Accessibility
visibleFrom and hiddenFrom change CSS display, so
hidden panes also leave the accessibility tree. The PaneHandle is a role="separator" whose value reflects the pane's current, minimum and maximum width.
Keep headings and labels meaningful, provide a real back action for single-pane details, and manage
focus when the action that opened a detail leaves the visible pane. Supporting-pane placement can
change visual order; check that keyboard and reading order remain understandable for your content.