A scrolling column in an app frame, with a sticky header and footer and a place in the stack when panes share a screen.
import { Pane } from '@oztix/roadie-components/pane'
<section> with its own surface and its own scroll. The page
never scrolls.Pane.Header and Pane.Footer stay put while the body scrolls between them.
Pane.Body is the body. It fills the height the header leaves and holds the
part that waits, so a skeleton stands in for it while the header stays.Navigator, panes become columns where there is
room and a stack where there isn't.Navigator, never nested.Card, a pane is not a general-purpose surface.
Don't use one to box content inside a page.Navigation explains depth, reached and how panes
map to routes. This page is the reference for each prop and part.
A pane in a box. Scroll it to see the body move under the sticky header.
The List declares no padding. The pane pads its content by
--content-inset, and the header, search and footer read the same value, so
the title and body text line up. A subtler List pulls its rows back out by
their own padding, so row backgrounds run wider than the text.
The names match Card, except that a pane's subtler has no surface at all.
It sits straight on the frame behind it.
measure caps the pane's content and its title while the header and footer
still span the column, so the pane's surface fills its space and the content
keeps a width that reads well.
| Measure | Width | Use for |
|---|---|---|
full | The whole column | The default. Dashboards, tables and anything that uses the room |
narrow | 24rem | Forms, sign in, settings |
readable | 65ch | Text and details |
wide | 56rem | Dense layouts that still need a cap |
readable is 65 characters of the pane's own text size. If the body text is
larger than the pane's, set its size on the Pane so the measure counts the
right characters. Content is centred; measureAlign='start' holds it to the
start instead.
Pane.Header has a top row, with Back at the start and Pane.Actions at the
end. The title, a Pane.Search and anything else sit beneath it at full width.
Pane.Footer is the same material at the bottom of the pane.
Pane.Actions at their default size.Pane.Search is the search field a searchable Navigator.Secondary uses. It
doesn't filter anything, so .filter() your own rows. Cancel shows while the
field has focus.onBack and nothing sits to its left.
Inside a Navigator, the columns decide. See
Back and Close.Pane.Title renders a large heading and a compact title. As the pane scrolls,
the compact title fades in, centred in the header's top row, and the header
takes a shadow. Tapping the compact title scrolls the pane back to the top.
Pane.Title must be a direct child of Pane.Header, because the compact title
places itself in the header's grid.
Pane.BodyTitle puts the heading in the scrolling body instead. Leave
Pane.Header without a title, and it shows the same compact title once the
body title scrolls away. This is the recommended option, because the header
keeps one height throughout.
text-display-ui-3. Pass another text-display-* class to
resize it.Pane.Title, that title wins and
Pane.Header warns in development.Inside a Navigator, panes sit side by side where there is room and stack
where there isn't. Navigator measures its content area, not the window.
| Content width | Columns |
|---|---|
| Below 46.25rem | One. Only the top pane shows. |
| From 46.25rem | Two, with the root beside a detail |
| From 55.25rem | Two, with a detail beside a deeper pane |
| From 76rem | Three, starting from the root |
| From 81rem | Three, starting from a detail |
A row never shows more columns than its stack has levels. The right-most column fills, at least 28rem wide. Each column to its left takes a parent track:
The panes behind the top fill the remaining columns nearest first, so the left-most pane drops first.
These columns need container style queries, in Chrome 111, Safari 18 and Firefox 151. Older browsers get a simpler layout. They show only the top pane below 46.25rem, and the root beside it from there.
In the stack, motion follows depth.
prefers-reduced-motion: reduce.A destination whose Navigator.Secondary sets overview draws all its routes
in one pane. For a step between them, Roadie slides an inert copy of the page
being left, hidden from assistive technology and removed when the animation
settles. Nothing else in the frame is copied.
Select a row, then resize the window. The same code is a side-by-side reveal where two columns fit and a push where they don't.
column says which column a pane is. A shell has at most one list, any number
of details and at most one inspector. It defaults to detail.
| Column | Where it sits |
|---|---|
list | The root |
detail | Under the root |
inspector | A fixed column outside the stack, 14rem unless size says otherwise. It shows only when every level of the stack fits beside it. |
size sets the inspector's width: sm is 14rem and the default, md is
20rem and lg is 24rem. Each has its own thresholds, so the stack's panes keep
their minimum widths beside it. A wider inspector needs a wider window, and
gives its column up sooner:
| Size | Width | Shows beside 1 level | 2 levels | 3 levels | 4 levels |
|---|---|---|---|---|---|
sm | 14rem | 46.25rem | 69rem | 99.75rem | 105.75rem |
md | 20rem | 50.25rem | 75rem | 105.75rem | 111.75rem |
lg | 24rem | 54.25rem | 79rem | 109.75rem | 115.75rem |
Pick the smallest size its content fits. Don't widen the column with a class: the thresholds would still assume the size, and the panes beside it would be squeezed below their minimums.
When the inspector's column is hidden, its content moves into a bottom
Drawer. Put a Pane.InspectorTrigger where people
should find it, such as the detail pane's Pane.Actions. It shows only while
the column is hidden and opens the drawer. The drawer takes its name from the
inspector's aria-label, and keeps the page readable behind it. The drawer is a fixed lg sheet, so filtering its content can't resize it. Pass drawerSize to pick another Drawer size, such as fit for short content like the example below. In the drawer, the inspector's Pane.Header gets a Close in its top-left corner. Content without a Pane.Header gets a header holding just the Close.
reveal says the inspector's content should be seen. While the column shows,
it already is. Once the column has yielded, reveal opens the drawer, so a
link that lands on a filter can show the filtered list without asking where it
is. onRevealChange reports the trigger opening the drawer and people
dismissing it. The content renders in one place at a time, so keep state that
must survive the move, such as a filter, in the URL.
For anything else that should change while the column is hidden, the
pane-inspector-yielded: variant applies only then. It applies anywhere in the same Navigator, including the inspector's own content once it's in the drawer.
The header's leading cell shows at most one button, based on where the pane sits in the columns.
| The pane is | It shows |
|---|---|
| The root, at depth 0 | Nothing |
| The left-most visible pane, with its parent dropped | Back, to that parent |
| The top pane, with its parent visible beside it | Close |
| A middle column | Nothing |
| A placeholder detail not yet reached | Nothing |
| An inspector | Nothing |
On a phone the top pane is also the left-most, so it gets Back. Both buttons go
one level up. backHref is the canonical target and real link for both:
<Pane><Pane.Header backHref='/tickets/glamping' backLabel='Glamping'><Pane.Title>Sam Okafor</Pane.Title></Pane.Header></Pane>
On an ordinary click, Roadie traverses browser history when the immediately
previous entry is from the current document and matches backHref's origin,
path and query. That restores the parent's existing scroll and local state
without adding a second parent entry. A direct load, reload, unrelated previous
route or browser without the Navigation API follows the link normally instead.
Modified clicks also keep normal link behaviour, so opening a new tab and
copying the link still use backHref.
onBack does the same as a handler. onClose overrides it for Close alone:
<Pane.Header onBack={goUp} onClose={closeDetail}>
backLabel names it for assistive
tech, as "Back to Glamping", without showing on screen.reached defaults to true. Pass false on a pane mounted before the route
reaches it, such as
an empty detail column.
Keep that pane mounted rather than mounting it on selection, or the list
jumps to a parent track when it appears.depth overrides the depth Roadie reads from render order. Pass it only for
a pane that renders out of order, such as one streamed into a resumed
prerender.tabBar sets what the phone tab bar does while the pane is on top. auto,
the default, collapses the bar to the active tab as the pane scrolls.
visible keeps it, and hidden removes it for a pushed screen, like iOS's
hidesBottomBarWhenPushed.Navigation covers when each one comes up.
Each pane has its own scroll, and the page never scrolls, so the browser cannot
put a pane back where it was. Pane does it instead. A pane takes its place
down as it is scrolled, against the browser's own id for the history entry it is
on, and a back or a forward traversal puts it there again. Going forward is a
history entry no pane has been scrolled on, so it starts at the top, as does a
fresh load.
The place is taken as the scroll happens, not when the route changes: React replaces a pane's content before any effect runs, and by then the viewport has already clamped a scroll the new content has no room for. Restoring holds the pane in place for a few frames while the page grows under it, and gives up the moment the pane is touched.
Scroll restoration reads no URL and writes no history state; the router owns
both. It reads navigation.currentEntry.key, so where an engine has no
Navigation API panes keep starting at the top. A pane is recognised by its seat,
its level, column and depth, rather than by its identity, so a pane the
router re-makes still finds its place. Up to 30 history entries are kept, the
least recently written dropped first, in memory: a document reload starts over.
Put the slow part of a pane inside Pane.Body. The header paints at once and a
skeleton stands in for the body until the content lands. Press Reload to wait
again.
Pane.Body is a Suspense boundary inside a <div> that fills the height the
header leaves. A band or background on it reaches the pane's bottom edge,
and a child with grow in a flex flex-col body does too.Pane.Body too. Then a skeleton header stands
in for the real one.loading on the pane replaces the default skeleton. loading on
Pane.Body wins over it.pending holds the frame's pending indicator for a wait Roadie can't see,
such as a fetch without Suspense or a mutation. It sets aria-busy on the
pane.<><Pane column='list'>…</Pane><Pane>…</Pane></>
Do
Render panes side by side, shallowest first. A layout returns its pane and its children in a fragment.
<Pane column='list'>…<Pane>…</Pane></Pane>
Don’t
Put a pane inside another pane, or wrap panes in a div. The columns
break.
<div className='grid grid-cols-3 gap-4'><Card>…</Card><Card>…</Card><Card>…</Card></div>
Do
Use a Card for a surface in a page's content, such as a grid of stats.
<div className='grid grid-cols-3 gap-4'><Pane emphasis='normal'>…</Pane><Pane emphasis='normal'>…</Pane><Pane emphasis='normal'>…</Pane></div>
Don’t
Use a Pane as a card. It brings its own scroll, header chrome and a
place in the frame's stack.
Do
Put actions directly in Pane.Header, so they take their cell opposite
Back.
Don’t
Wrap them. Grid placement only reaches direct children, so the actions render below the top row.
Navigator from wherever it renders, such as a
route layout or your own component. With no Pane at all, Navigator warns in
development.List doesn't virtualise. That is fine into the
hundreds of rows. For thousands, virtualise against the viewport at
[data-slot="pane-viewport"].<section>. Pass aria-label to make it a named region.Pane.Title is an <h2>, so it never clashes with the page's <h1>.
Pane.BodyTitle is an <h1>, because it is the page's own heading. Use at
most one per pane, and no other <h1> beside it.IconButton labelled "Back", or "Back to Events" with
backLabel='Events'.IconButton labelled "Close". It uses onClose, then onBack,
then backHref.Pane.Search is a search field named by its aria-label or placeholder. Its
Cancel button is labelled "Cancel search", and Escape also clears the field.aria-busy
wrapper. The skeletons themselves are hidden from assistive technology.The column it fills; an `inspector` gives up its column first.
Defaults to 'detail'.
The route has reached this pane; pass `false` only for one mounted early, such as an empty detail column.
Defaults to true.
Only for a pane rendered out of document order, such as one streamed into a resumed prerender.
The surface. `subtler` paints none.
Defaults to 'raised'.
An inspector's width: 14rem, 20rem or 24rem. A wider one yields its column sooner.
Defaults to 'sm'.
Caps the body and title while the header and footer span the column: `narrow` 24rem for forms, `readable` 65ch for text, `wide` 56rem.
Defaults to 'full'.
Where capped content sits in a wider pane.
Defaults to 'center'.
What the phone tab bar does while this pane is top; `auto` collapses it on scroll.
Defaults to 'auto'.
Holds the pending indicator for a wait Roadie can't see, such as a fetch without Suspense or a mutation.
The body skeleton while the pane's content is suspended. `Pane.Body` shows it too.
The drawer an inspector's content moves into once its column yields. Fixed tall, so filtering the content can't resize it.
Defaults to 'lg'.
An inspector's content should be seen: already true while its column shows, and opens its drawer once the column has yielded.
An inspector's drawer opened from `Pane.InspectorTrigger`, or was dismissed.
Trailing slot in the header's top row. Direct child of `Pane.Header`.
No additional props. It forwards all standard HTML attributes to the underlying element.
The pane's body: it fills the height the header leaves, and keeps the header on screen while a skeleton stands in.
The skeleton shown while the body is suspended. Defaults to the pane's `loading`.
The view's h1, in the scrolling body. The header shows a compact copy once it scrolls away.
Replace the rendered element.
Sticky chrome at the foot of a pane.
No additional props. It forwards all standard HTML attributes to the underlying element.
Sticky chrome at the top of a pane: Back, a compact title, and actions.
Back's parent route; a plain click traverses to a matching previous entry when it can. Wins over `onBack`.
Names the Back button for assistive tech, as "Back to {label}".
Back's target, as a click handler. Close uses it unless `onClose` is set.
Close's handler. Wins over `onBack` and `backHref` for Close.
Opens the inspector's content in a drawer; shows only while the inspector's column has yielded.
CSS class applied to the element, or a function that returns a class based on the component's state.
Defaults to normal.
Icon-button sizing. Use `'xs' | 'sm' | 'md' | 'lg'` (plain) — the `'icon-*'` aliases are accepted for backwards compatibility but discouraged.
Defaults to 'md'.
Force external-link treatment regardless of `href` shape. Useful for first-party URLs that should still open in a new tab, or for an `https://` URL that should route internally through the provider.
Override the auto `target='_blank'` default on external hrefs.
Download the `href` instead of navigating, optionally as this filename.
Inherited from ButtonProps
Whether the button should be focusable when disabled.
Defaults to false.
Inherited from NativeButtonProps
Whether the component renders a native `<button>` element when replacing it via the `render` prop. Set to `false` if the rendered element is not a button (for example, `<div>`).
Defaults to true.
A search field for a pane header, with a Cancel that shows while it has focus. Filter your own data.
Names the field; defaults to the placeholder.
Defaults to Search.
The pane's heading, plus a compact title that scrolls the pane to the top. A direct child of `Pane.Header`.
Replaces the h2. The compact title is unaffected.