Screen layout

Updated: Sep 24, 2026
Build layouts from available space and intrinsic component sizes. Use flexible tracks, percentages, and semantic tokens rather than hardcoded viewport or device dimensions. Avoid branching on specific device dimensions: those values become stale as the display, host window, or content changes.
Keep the toolkit window background provided by App: var(--uit-color-background-window) with var(--uit-color-text-primary) for text. Do not replace it with a light or application-defined page background. On an additive-light display, unlit pixels form the background, which supports legibility without unnecessary light.

Page anatomy

Page fills the available space and clips its overflow. It owns two absolutely positioned sibling regions:
RegionPositionNotes
Header
Top center, above content
Rendered whenever showHeader is true (the default), even with no header text. Its position never changes.
Content
Fills from its top inset to the bottom edge
Inset below the system-bar region when enableSystemBarInset is true (the default).
The header overlays content instead of pushing it down, keeping the scroll region as tall as the display. Scrolling content therefore passes beneath the header. When the first item needs to start below it, reserve space on the scroller with insetForHeader and a headerHeight measured through the page handle. Do not insert a spacer, because the scroller owns header clearance and focus reveal.
import { useLayoutEffect, useRef, useState } from 'react';
import type { PageHandle } from '@wearables-ui-toolkit/mrbd';

const pageRef = useRef<PageHandle>(null);
const [headerHeight, setHeaderHeight] = useState(0);

useLayoutEffect(() => {
  setHeaderHeight(pageRef.current?.getHeaderHeight() ?? 0);
}, []);
getHeaderHeight() returns 0 when the header is hidden, so the same code supports showHeader={false}.
For a tabbed top-level route, use SubNavigationPager instead of Page. It is a full-bleed route root with its own top-centered tab bar. Do not nest it inside Page, because each component owns the route chrome and content region.

Insets and edge-to-edge regions

Use the component’s existing inset behavior before adding layout padding:
RegionInline inset
General page content you lay out yourself
var(--uit-spacing-large)
ScrollView, VerticalList, ButtonRail
None — they reach both display edges
Rows and buttons inside those
Supplied by the component (VerticalList pads its row container, ButtonRail pads its button row)
Content inside Container, StaticContainer, Surface, Panel
var(--uit-spacing-large) from the component edge, unless it is full bleed media
A backdrop-style Panel
May extend edge to edge
Modal
Stays inset
Keep scrollers, lists, and rails out of padded wrappers. Their children already provide the correct inset; another container would double the row inset and shorten the scroll viewport. When a list needs a different row-container style, use VerticalList’s contentClassName or contentStyle. These props style the row container while preserving an edge-to-edge scroller.
Keep adjacent ListItem rows flush. The list component owns row spacing, so no additional gap is needed.

Scroll region ownership

When content scrolls, assign one scroll owner per axis within a route or active pager page. Static content needs no scroll container. The active scroller owns movement, focus reveal, fading edges, and overflow feedback for its region.
  • ScrollView is the scrolling primitive. orientation defaults to vertical; width and height both default to filling the parent.
  • VerticalList is the required host for a vertical sequence of ListItem rows. It combines ScrollView with a row container that applies the list inline inset.
  • ButtonRail owns horizontal scrolling. A bottom rail can therefore coexist with a vertical list because each controls a different axis.
  • Avoid nesting a second vertical scroller inside the first. As soon as the inner scroller absorbs movement, the outer scroller can no longer align reliably to the focused element.
Let the scroller render its fading edges. fadingEdgeEnabled defaults to true, and each edge’s strength reflects the distance still hidden on that side, disappearing at the actual boundary. A design can override edge lengths with topFadingEdgeLength, bottomFadingEdgeLength, leftFadingEdgeLength, and rightFadingEdgeLength. Do not imitate these fades with gradient overlays or permanent clearance padding; both interfere with the scroller’s overflow signal.
ButtonRail similarly draws fading edges at each end while buttons remain out of view.
The single-axis scrollbar follows the active orientation. Control it with scrollbarEnabled or its alias showScrollbar. It is enabled by default but renders only when content overflows. Consider disabling it only for a short strip where it adds visual noise without useful position information.

The bottom action region

Place actions below the content they affect, docked to the bottom of the display and outside the scroller. Reserve an intrinsic grid row rather than positioning the actions absolutely. This makes the scroll region end above the actions, so content remains visible instead of passing behind them.
import { Button, ButtonRail, VerticalList } from '@wearables-ui-toolkit/mrbd';

<div className="route-layout">
  <VerticalList className="route-scroller" ariaLabel="Messages">
    {rows}
  </VerticalList>
  <ButtonRail className="route-actions">
    <Button title="Reply" icon={replyIcon} onClick={onReply} />
    <Button title="Mute" icon={muteIcon} onClick={onMute} />
  </ButtonRail>
</div>;
.route-layout {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  block-size: 100%;
  min-block-size: 0;
  min-inline-size: 0;
}

.route-scroller {
  min-block-size: 0;
  min-inline-size: 0;
}

.route-actions {
  min-inline-size: 0;
  padding-block-end: var(--uit-spacing-xsmall);
}
Both minmax(0, 1fr) and min-block-size: 0 are required for the scroll row to shrink below its content height. Without either declaration, the grid can size the row to the entire list and push the action rail below the display. The xsmall inset below the rail is the only extra spacing added by the action region.
When content beneath the dock needs additional contrast, place a Scrim with ScrimPosition.BOTTOM between the scroller and rail. Prefer this supported layer over a solid strip so the relationship between content and actions stays visible.

Intrinsic sizing

Let interactive components size themselves from content and state. Several components grow when they receive focus, and preserving their intrinsic size keeps that transition visible without disturbing scroll calculations.
  • Keep Button, Chip, Tag, ListItem, and the page Header at intrinsic size. Avoid fixed width, fixed height, and track stretching for these components.
  • Constrain text through component props such as headerMaxLines on Page and subtitleMaxLines on ListItem, rather than placing text in a fixed box.
  • ButtonRail measures both its viewport and children, then recomputes its scroll offset when either changes. This keeps a button that expands on focus visible; a fixed-width wrapper prevents that calculation from reflecting the available space.
  • Do not nest Container, StaticContainer, Surface, Button, Chip, Tag, or the page Header inside another member of that set. Each component owns a material, corner radius, and content inset, and nesting duplicates all three.
  • Use Panel as a panel rather than a list row. Prefer the toolkit component that directly represents an interaction instead of imitating one with rounded rectangles or combinations of other components.

Responsive behavior

  • Express vertical structure with grid tracks and block-size: 100% instead of calculated heights.
  • Give flexible tracks minmax(0, 1fr) and shrinkable children min-block-size: 0 or min-inline-size: 0. These declarations allow content to shrink below its natural size without pushing adjacent regions away.
  • Use logical properties such as padding-inline, padding-block-end, and block-size so layout follows the writing mode.
  • Treat a fixed length inside min(), max(), clamp(), a grid track, or a shadow as a hardcoded value. A different unit does not turn that value into a semantic token.
  • Avoid media queries tied to a device size. When content needs two arrangements, let the content and available region determine the change rather than a known viewport.
The following route gives the list the flexible region and the action rail its intrinsic region. The list and rail remain edge to edge and own their respective scroll axes.
import {
  Button,
  ButtonRail,
  ListItem,
  Page,
  VerticalList,
  type IconSource,
} from '@wearables-ui-toolkit/mrbd';

const replyIcon: IconSource = '/icons/reply.svg';
const readIcon: IconSource = '/icons/read.svg';

export interface Message {
  id: string;
  sender: string;
  body: string;
  time: string;
}

export interface ThreadRouteProps {
  messages: ReadonlyArray<Message>;
  onOpenMessage: (id: string) => void;
  onReply: () => void;
  onMarkRead: () => void;
}

export function ThreadRoute({
  messages,
  onOpenMessage,
  onReply,
  onMarkRead,
}: ThreadRouteProps) {
  return (
    <Page headerText="Field Ops" headerMetadata={`${messages.length} messages`}>
      <div className="thread-layout">
        <VerticalList className="thread-scroller" ariaLabel="Messages">
          {messages.map(message => (
            <ListItem
              key={message.id}
              title={message.sender}
              subtitle={message.body}
              timestamp={message.time}
              onClick={() => onOpenMessage(message.id)}
            />
          ))}
        </VerticalList>
        <ButtonRail className="thread-actions">
          <Button title="Reply" icon={replyIcon} onClick={onReply} />
          <Button title="Mark read" icon={readIcon} onClick={onMarkRead} />
        </ButtonRail>
      </div>
    </Page>
  );
}
.thread-layout {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  block-size: 100%;
}

.thread-scroller {
  min-block-size: 0;
}

.thread-actions {
  padding-block-end: var(--uit-spacing-xsmall);
}
When content fits within the available region, prefer a static layout rather than adding an unnecessary scroller. Center the task and reserve an intrinsic row for confirmation actions.
import {
  Button,
  ButtonGroup,
  Page,
  TextStyle,
  TextView,
} from '@wearables-ui-toolkit/mrbd';

export interface ConfirmRouteProps {
  onCancel: () => void;
  onConfirm: () => void;
}

export function ConfirmRoute({ onCancel, onConfirm }: ConfirmRouteProps) {
  return (
    <Page headerText="Delete route">
      <div className="confirm-layout">
        <div className="confirm-body">
          <TextView as="h2" textStyle={TextStyle.HEADING2}>
            Delete this route?
          </TextView>
          <TextView textStyle={TextStyle.BODY2}>
            Saved stops are removed from the device.
          </TextView>
        </div>
        <ButtonGroup className="confirm-actions">
          <Button title="Cancel" onClick={onCancel} />
          <Button title="Delete" onClick={onConfirm} />
        </ButtonGroup>
      </div>
    </Page>
  );
}
.confirm-layout {
  display: grid;
  grid-template-rows: minmax(0, 1fr) auto;
  block-size: 100%;
  padding-inline: var(--uit-spacing-large);
}

.confirm-body {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--uit-spacing-med-lg);
  min-block-size: 0;
}

.confirm-actions {
  padding-block-end: var(--uit-spacing-xsmall);
}
This route applies its inline inset to the body because it contains no scroller, list, or rail with component-owned inset behavior.
A SubNavigationPager child can render any suitable React content. This example uses one VerticalList in each pane because every destination is a scrolling list. Sibling panes can each have a scroll scope because only the current pane participates in focus and scrolling.
import {
  ListItem,
  SubNavigationPager,
  VerticalList,
  type SubNavigationItem,
} from '@wearables-ui-toolkit/mrbd';

const TABS: SubNavigationItem[] = [
  { label: 'Recent', icon: '/icons/recent.svg' },
  { label: 'People', icon: '/icons/people.svg' },
  { label: 'Alerts', icon: '/icons/alerts.svg' },
];

<SubNavigationPager items={TABS} homeIndex={0} ariaLabel="Home sections">
  <VerticalList insetForHeader ariaLabel="Recent">{recentRows}</VerticalList>
  <VerticalList insetForHeader ariaLabel="People">{peopleRows}</VerticalList>
  <VerticalList insetForHeader ariaLabel="Alerts">
    <ListItem title="Battery low" subtitle="Charge soon" />
  </VerticalList>
</SubNavigationPager>;
The tab bar sits above the panes and fades in a scrim while focused, keeping its labels legible over page content. When a pane scrolls beneath the overlaid strip, set insetForHeader on that VerticalList or ScrollView rather than adding a spacer or padded wrapper.
  • Derive dimensions from available space, content, and semantic tokens to keep the route responsive.
  • Let scrollers, lists, and rails reach both display edges so their children can apply the correct inset.
  • Reserve bottom actions with a layout track, keeping the scroll viewport clear.
  • Preserve interactive components’ intrinsic size so focus-driven size changes remain visible and measurable.

Avoid these patterns

  • Avoid hardcoded viewport, device, and component dimensions; they bind the layout to one environment.
  • Do not nest a second same-axis scroller inside the first, because focus and scroll ownership become ambiguous.
  • Keep scrollers, lists, and rails out of padded wrappers to avoid duplicate insets and shortened viewports.
  • Place actions below the content they affect rather than above it, preserving a stable reading and navigation order.
  • Avoid imitating a toolkit component, styling its internals, or depending on its DOM structure and generated class names. The toolkit owns those details so behavior can remain consistent.