Scaffolds and canonical layouts
Assemble adaptive regions around a real task, preserve state through structural changes and make every pane reachable.
Window, grid and scaffold have different jobs
Begin with tasks and relationships, then assign space. A grid of equal rectangles cannot by itself decide which content is primary. Browser CSS pixels, physical pixels and native density-independent units are distinct; the lab measures CSS pixels only.
| Concept | Web responsibility | Verification |
|---|---|---|
| Window or viewport | The space made available by the browser, including resizing and zoom | Use actual available space; do not infer a device from a user agent |
| Grid | Alignment relationships and tracks inside an available region | Check content minimums, margins and gaps |
| Scaffold | The product arrangement of bars, rails and content panes | Identify each region’s task and reading order |
| Pane | A coherent task, record or supporting context | Keep identity, state and recovery attached to that pane |
| Ruler | A shared alignment edge for related titles/content | A design alignment aid, not automatically a draggable widget |
| Drag handle | An optional affordance for changing a boundary | Expose operation, limits and a keyboard/single-pointer alternative |
Bars, safety regions and rails
Bars organize navigation or actions along a page edge. Rails reserve inline-edge space for persistent navigation or tools. They are scaffold regions, not interchangeable ARIA roles: a destination rail needs navigation semantics; a tool rail may contain a labelled toolbar. Keep the main task identifiable and avoid multiple indistinguishable navigation landmarks.
Reserve space for any fixed chrome and device/browser occlusion before placing task controls. Safe-area environment values can help with cutouts, but do not solve zoom, software keyboards or browser controls automatically. Flow placement is often the simplest way to prevent content from being covered.
| Placement | Contract | Failure |
|---|---|---|
| Top bar | Page/task identity and relevant actions; readable title | A sticky bar covers the focused heading |
| Bottom bar | Reachable primary destinations with reserved content space | Last result or action is hidden behind the bar |
| Inline-start rail | Stable navigation and logical boundary/padding | CSS reversal disagrees with tab order |
| Supporting tools | Named commands associated with the task | A tool rail is mislabelled as primary navigation |
| Safety region | Deliberate reserved inset around inaccessible edges | Padding alone is claimed to prove every hardware condition |
.fixed-bottom-navigation {
padding-block-end: env(safe-area-inset-bottom, 0px);
}
.task-with-fixed-navigation {
/* Use the actual bar size, including wrapped labels. */
padding-block-end: var(--measured-navigation-size);
}
.task-heading { scroll-margin-block-start: var(--measured-header-size); }
/* Measure chrome and verify keyboard/zoom; do not assume a fixed 56px bar. */Choose a canonical arrangement
A feed card should not become a list-detail selection merely because it is drawn beside another card. An inspector should not replace the task it supports. For MobTranslate, source and review qualifications remain connected to the exact entry even when supporting material moves.
| Arrangement | Use when | Compact adaptation | Expanded adaptation |
|---|---|---|---|
| List-detail | A collection and the selected record belong to one task | Show the collection or its selected detail with an explicit return path | Keep selection visible beside the record |
| Feed | People scan independent items rather than a master/detail hierarchy | Use one readable item column | Add columns only when individual items remain usable |
| Supporting pane | Context supports an already identifiable primary activity | Keep necessary context after the activity or reveal it deliberately | Place subordinate context beside the task |
| Three panes | A third region has a distinct useful job | Prioritize which task/context remains visible and provide return paths | Give each region a minimum and bounded relationship |
| Custom arrangement | The task genuinely differs from these relationships | Document reading order, navigation and restoration | Do not turn extra width into unrelated panels |
Reference classes and local web thresholds
The captured May 2026 overview renames window size classes to breakpoints and responsive layout to adaptive design. These published width categories are context for design handoff. They are not aliases for this library’s sm/md/lg CSS tokens and are not inferred from device names. The Layout chapter records the actual committed web token breakpoints. The lab uses its own measured container thresholds because a pane can be narrow inside a wide browser window.
Height and input conditions remain independent. Test an unusually short wide window, enlarged text, touch and keyboard rather than assuming a width class proves ergonomics.
| Material class | Reference window width in dp | Local adaptation question |
|---|---|---|
| Compact | Below 600 | Can one task remain readable with reachable navigation? |
| Medium | 600 to below 840 | Does a second low-density pane fit without squeezing controls? |
| Expanded | 840 to below 1200 | Does an adjacent record/context help the task? |
| Large | 1200 to below 1600 | Should margins or supporting context use the added room? |
| Extra-large | 1600 and above | Is a third region useful, or merely more distraction? |
Working composition and retained-state lab
Open the public English example records, edit distinct notes and save local snapshots. Switch composition, resize and revisit the records: note identity belongs to the record, not its grid position. A saved snapshot stays fixed while its draft changes.
List-detail uses the actual measured container at 640 CSS px, not a forced canvas wider than the page. Supporting context moves beside the task at 900px. Feed tracks change at these same demonstration thresholds. These are local, content-tested web decisions, not copied Material window-class thresholds.
The leading-pane range supplies a keyboard and single-pointer alternative to dragging. Each applicable composition retains its chosen share while stacked. Changing a share does not persist user preferences or resize a native device window.
Measuring the available container. The maximum is a cap, not a forced width.
Local example records
Example A
Example B
Example C
Choose a local record
Select a record to inspect its description and edit its own local note.
All notes and snapshots belong to this page instance. They survive composition, size and record changes, but not reload. English fixture text keeps its language and glyph order when placement is RTL. This is not a foldable-device, browser-history or remote-storage implementation.
Pane identity, navigation and transitions
- Keep record IDs and task state independent of which panes are mounted or visible. Layout changes must not submit, clear or duplicate an edit.
- Compact detail needs a visible return action and a defined focus destination. Restore the originating record trigger, or a meaningful surviving control when it no longer exists.
- Expanded collection/detail can show two panes without creating two independent primary routes. Decide whether selection is addressable; use actual URL/history state when the product requires deep links.
- Feed detail can appear after the feed or in a separate destination. Preserve the selected identity and the return point; opening detail is not evidence that an item was read.
- Supporting context follows its task in DOM order. If essential qualifications are hidden behind disclosure, keep a visible truthful summary beside the result.
- In the lab, opening moves focus to the detail heading; returning restores the record button. Resizing while a selected list trigger has focus transfers focus before that list becomes hidden. Browser history is intentionally outside this local lab; the Navigation pattern supplies its separate URL/history implementation.
Persistent and temporary resizing
Persistent resizing changes the normal split or preferred size of a region. Temporary expansion gives a region more space for a particular task and needs a clear restore path. Define minimums from actual controls, text and targets; preserve the user’s preference when a compact layout cannot currently apply it.
A handle is optional. If used, distinguish a separator from a draggable divider, expose its direction/value/limits and provide documented keys or a named range/size control. Pointer cancellation must not accidentally publish or lose state. Do not repurpose a decorative divider into an unnamed drag-only input.
The lab implements bounded preferred splits with native range inputs, in memory. It does not implement an overlay expansion or claim cross-session persistence. For a production preference, validate the stored version and scope before applying it to a later workspace.
| Resize state | Required behavior |
|---|---|
| Preferred split | Store separately from draft/record state |
| Minimum or maximum | Bound the value; keep all essential controls reachable |
| Compact layout | Stack or switch panes; retain the dormant preference |
| Temporary expansion | Name the expanded region and provide restore/close |
| Interrupt/cancel | Keep a recoverable prior size and unsubmitted work |
| Focus during resize | Move focus only if its region is about to become unavailable |
Reveal, divide, resize, reposition and swap
Available height matters as well as width. A short wide window may need scrolling and shorter nonessential chrome; it does not justify reducing text or target size. Changes in pointer, keyboard, posture or motion preference may affect behavior independently of width. Check mobile, desktop and spatial handoffs against the actual platform contract.
| Adaptive decision | Example | Invariant |
|---|---|---|
| Reveal | Show supporting context when enough space remains | Required evidence is reachable at every size |
| Divide | Use list and detail tracks for one selected record | Selection identity is unchanged |
| Resize | Bound pane shares and wrap long text | Controls retain names, targets and complete values |
| Reposition | Move context below its task using normal DOM/grid flow | Reading and keyboard order remain coherent |
| Swap | Use a temporary drawer instead of a persistent navigation rail | Destinations and current-route truth remain identical |
| Levitate | Temporarily present content over another region | Name context, contain focus when modal, restore on close |
| Spatial panels | Adapt related tasks to a spatial environment | Do not equate CSS pane pixels with physical/headset units |
Grids, rulers, grouping and spacing
Align title and content edges deliberately. Start with outer margins and the minimum readable task, then place bars/rails and divide the remaining space. Use separate alignment rules when a full-width bar and a bounded reading column have different jobs; do not force every element into equal tracks.
Rhythm and repeated alignment make relationships recognizable. Proximity groups a label with its field and source context with its entry. Larger gaps can distinguish tasks; similar spacing alone should not imply that unrelated actions have the same importance. Keep negative space around focal actions without pushing required context beyond discovery.
- Use grid for aligned tracks and flex wrapping for a sequence of actions. Set min-inline-size: 0 on flexible pane content.
- Derive spacing from committed tokens; use the existing Spacing chapter to inspect the actual scale.
- Keep prose measure bounded while data regions use deliberate named local scrolling.
- Treat ruler choices as alignment decisions: title edges, content edges, bar/safety edges and any intentional exceptions.
- Test a long field label, a multi-line action and mixed-length records before accepting the alignment.
Information density and unit boundaries
Density changes how much information is visible, not whether people can reach and read it. Adjust hierarchy, spacing and progressive disclosure before shrinking every component. A dense table can coexist with a comfortable task action and readable help text.
Display pixel density is a hardware/rendering concern; an information-density preference is a product concern. CSS px are neither physical pixels nor an automatic conversion to Android dp. Do not apply a universal scaling multiplier to fonts, hit targets and icons and then claim accessibility.
- Keep the chosen target size independently of the visual glyph size.
- Preserve line height, wrapping and complete error/source qualifications at compact density.
- If density is a setting, keep it separate from text enlargement and the browser’s zoom.
- Verify minimum pane sizes with real content and every relevant control state, not a blank wireframe.
Bidirectionality and meaningful mirroring
Use logical edges for margins, rail borders and pane placement. A list-detail collection at inline start naturally moves to the opposite physical edge in RTL, while record and focus order remain explicit. Feed sequence should follow the intended reading direction without reversing DOM order.
Mirror a directional action only when its meaning follows reading direction. Media playback, clocks, written glyphs and many symbols do not become correct by flipping their geometry. Mixed text, dates and identifiers need deliberate direction isolation and language metadata. The Internationalization and Iconography chapters supply those contracts.
The lab changes region placement only; its text is original English fixture text. This does not verify an actual translated RTL interface, touch swipe convention or glyph shaping.
Layering, folds and failure recovery
- Keep overlays in a documented stacking system. Modal focus containment, Escape and return behavior are different from an ordinary adjacent pane.
- Treat a fold, hinge, keyboard or unsafe region as a real platform boundary. Avoid putting a continuous control or critical text across an inaccessible gap.
- Do not infer a hinge from screen width alone. Without a validated platform signal, retain a usable regular-window fallback.
- Retain edited work and selected identity through window changes. A new arrangement must not claim a remote save.
- Check empty collections, unavailable records, long content and failed data loading without removing every recovery control.
- Use the shared Dialog/Drawer patterns for modal content, and the XR chapter for spatial units/capabilities. This container example is not hardware qualification.
Compiled implementation
This source is the rendered container/state model. CSS container queries own track geometry; ResizeObserver owns actual-width reporting and compact list/detail availability. Values are local page state and do not authorize language publication.
'use client';
import {useEffect,useRef,useState,type CSSProperties} from 'react';
import {Button} from '@mobtranslate/ui';
import './layout-compositions.css';
type Composition='list-detail'|'feed'|'supporting';
const records=[
{id:'a',title:'Example A',description:'A short English interface record for comparing reading and editing regions.',context:'Illustrative record A. No language entry or publication permission is asserted.'},
{id:'b',title:'Example B',description:'A second local record with its own independently retained note.',context:'Illustrative record B. Editing a note does not change a source or review status.'},
{id:'c',title:'Example C',description:'A longer local example for testing wrapping, selection and a stable return path.',context:'Illustrative record C. These records are public documentation fixtures.'},
] as const;
type RecordId=typeof records[number]['id'];
export function LayoutCompositionsExample(){
const [mode,setMode]=useState<Composition>('list-detail');
const [requestedWidth,setRequestedWidth]=useState(960);
const [actualWidth,setActualWidth]=useState<number|null>(null);
const [rtl,setRtl]=useState(false);
const [shares,setShares]=useState({'list-detail':34,supporting:66});
const [selected,setSelected]=useState<RecordId|null>(null);
const [drafts,setDrafts]=useState<Partial<Record<RecordId,string>>>({});
const [saved,setSaved]=useState<Partial<Record<RecordId,string>>>({});
const [status,setStatus]=useState('');
const [openEpoch,setOpenEpoch]=useState(0);
const [returnId,setReturnId]=useState<RecordId|null>(null);
const root=useRef<HTMLDivElement>(null),detail=useRef<HTMLHeadingElement>(null);
const triggers=useRef<Partial<Record<RecordId,HTMLElement>>>({});
const compact=actualWidth===null||actualWidth<640;
const share=mode==='feed'?50:shares[mode];
const active=records.find(record=>record.id===selected)??(mode==='supporting'?records[0]:null);
const showList=mode!=='supporting'&&(mode==='feed'||!compact||selected===null);
const showDetail=mode==='supporting'||(mode==='list-detail'&&!compact)||selected!==null;
useEffect(()=>{
const element=root.current;if(!element)return;
const observer=new ResizeObserver(entries=>{
const width=entries[0]?.contentRect.width;if(width===undefined)return;
// If resizing will hide a focused list item, keep the current task reachable.
if(mode==='list-detail'&&width<640&&selected&&element.querySelector('[data-composition-list]')?.contains(document.activeElement))detail.current?.focus();
setActualWidth(width);
});observer.observe(element);return()=>observer.disconnect();
},[mode,selected]);
useEffect(()=>{if(openEpoch)detail.current?.focus();},[openEpoch]);
useEffect(()=>{if(returnId){triggers.current[returnId]?.focus();setReturnId(null);}},[returnId]);
const choose=(id:RecordId)=>{setSelected(id);setStatus('');setOpenEpoch(value=>value+1);};
const back=()=>{if(selected)setReturnId(selected);setSelected(null);setStatus('');};
const save=()=>{if(!active)return;const note=drafts[active.id]??'';setSaved(current=>({...current,[active.id]:note}));setStatus(`${active.title}: local note snapshot saved. No source or publication changed.`);};
return <div className="space-y-4" data-composition-ready={actualWidth!==null}>
<div className="composition-settings">
<label htmlFor="composition-mode">Composition<select id="composition-mode" className="mt-input" value={mode} onChange={event=>{setMode(event.target.value as Composition);setStatus('');}}><option value="list-detail">List-detail</option><option value="feed">Feed</option><option value="supporting">Supporting pane</option></select></label>
<label htmlFor="composition-width">Maximum container width: {requestedWidth}px<input id="composition-width" type="range" min="280" max="1200" step="20" value={requestedWidth} onChange={event=>setRequestedWidth(Number(event.target.value))} /></label>
<label className="composition-direction"><input id="composition-rtl" type="checkbox" checked={rtl} onChange={event=>setRtl(event.target.checked)} />Right-to-left placement</label>
{mode!=='feed'&&<label htmlFor="composition-share">Leading pane share: {share}%<input id="composition-share" type="range" min="30" max="70" step="1" value={share} onChange={event=>{const value=Number(event.target.value);setShares(current=>({...current,[mode]:value}));}} aria-describedby="composition-share-help" /></label>}
</div>
{mode!=='feed'&&<p id="composition-share-help">Adjust the split with the native range control. The share applies only when both panes fit; it is retained while stacked. No drag handle is required.</p>}
<p data-composition-measure>{actualWidth===null?'Measuring the available container.':`Actual container: ${Math.round(actualWidth)} CSS px. ${mode==='list-detail'?(compact?'Single pane':'Two panes'):mode==='supporting'?(actualWidth<900?'Stacked context':'Context beside task'):'Content-driven feed'}.`} The maximum is a cap, not a forced width.</p>
<div ref={root} className="composition-container" style={{maxInlineSize:requestedWidth,'--composition-leading':`${share}fr`,'--composition-trailing':`${100-share}fr`} as CSSProperties} dir={rtl?'rtl':'ltr'} data-composition-container data-mode={mode}>
<div className="composition-shell">
<div className="composition-list" data-composition-list hidden={!showList}>
<h3 dir="auto" className="font-semibold text-xl">Local example records</h3>
<ul>{records.map(record=><li key={record.id}><h4 dir="auto" className="font-semibold">{record.title}</h4>{mode==='feed'&&<p dir="auto" lang="en">{record.description}</p>}<Button type="button" variant={selected===record.id?'primary':'outline'} ref={node=>{if(node)triggers.current[record.id]=node;else delete triggers.current[record.id];}} data-composition-open={record.id} aria-current={selected===record.id?'true':undefined} onClick={()=>choose(record.id)}>Open {record.title}</Button></li>)}</ul>
</div>
<div className="composition-task" data-composition-task hidden={!showDetail}>
{mode==='supporting'&&<label htmlFor="composition-record">Active record<select id="composition-record" className="mt-input" value={active?.id??'a'} onChange={event=>{setSelected(event.target.value as RecordId);setStatus('');}}>{records.map(record=><option value={record.id} key={record.id}>{record.title}</option>)}</select></label>}
<h3 ref={detail} dir="auto" tabIndex={-1} className="font-semibold text-xl" data-composition-heading>{active?active.title:'Choose a local record'}</h3>
{active?<><p dir="auto" lang="en">{active.description}</p>
{mode!=='supporting'&&<p data-composition-context dir="auto" lang="en">{active.context}</p>}
<label htmlFor="composition-note">Local note for {active.title}<textarea id="composition-note" dir="auto" lang="" className="mt-textarea mt-textarea-resize-vertical" value={drafts[active.id]??''} onChange={event=>setDrafts(current=>({...current,[active.id]:event.target.value}))} /></label>
<div className="composition-actions"><Button type="button" data-composition-save onClick={save}>Save local note snapshot</Button>{mode!=='supporting'&&<Button type="button" variant="outline" data-composition-back onClick={back}>{mode==='feed'?'Close detail':compact?'Back to records':'Clear selection'}</Button>}</div>
<p data-composition-saved dir="auto" className="whitespace-pre-wrap">{saved[active.id]===undefined?'No local snapshot for this record.':<>Saved note: <bdi lang="">{saved[active.id]||'(empty note)'}</bdi></>}</p>
</>:<p>Select a record to inspect its description and edit its own local note.</p>}
</div>
{mode==='supporting'&&active&&<aside className="composition-context" aria-label={`Context for ${active.title}`} data-composition-support><h3 dir="auto" className="font-semibold">Context for {active.title}</h3><p dir="auto" lang="en">{active.context}</p><p dir="auto" lang="en">The context follows its task in DOM order and stays available when stacked.</p></aside>}
</div>
</div>
<p role="status" data-composition-status>{status}</p>
<p className="text-sm">All notes and snapshots belong to this page instance. They survive composition, size and record changes, but not reload. English fixture text keeps its language and glyph order when placement is RTL. This is not a foldable-device, browser-history or remote-storage implementation.</p>
</div>;
}
Container CSS
.composition-settings { display:flex; flex-wrap:wrap; align-items:end; gap:16px; }
.composition-settings label { display:flex; flex-direction:column; gap:8px; max-inline-size:100%; }
.composition-settings input[type=range], .composition-settings select { min-block-size:44px; }
.composition-settings .composition-direction { flex-direction:row; align-items:center; min-block-size:44px; }
.composition-container { container-type:inline-size; inline-size:100%; min-inline-size:0; }
.composition-shell { display:grid; gap:16px; min-inline-size:0; }
.composition-shell > [hidden] { display:none; }
.composition-list, .composition-task, .composition-context { min-inline-size:0; padding:16px; border:1px solid var(--color-muted-foreground); border-radius:var(--radius-md); }
.composition-shell p, .composition-shell h3, .composition-shell h4, .composition-shell label { overflow-wrap:anywhere; }
.composition-list ul { display:grid; gap:16px; list-style:none; margin:16px 0 0; padding:0; }
.composition-list li, .composition-task, .composition-context { display:flex; flex-direction:column; gap:12px; }
.composition-task label { display:flex; flex-direction:column; gap:8px; }
.composition-task textarea { inline-size:100%; min-block-size:120px; }
.composition-actions { display:flex; flex-wrap:wrap; gap:12px; }
.composition-shell .mt-btn { min-block-size:44px; block-size:auto; max-inline-size:100%; white-space:normal; overflow-wrap:anywhere; }
.composition-task h3 { scroll-margin-block-start:96px; }
.composition-container[data-mode=feed] .composition-list li { padding-block-end:16px; border-block-end:1px solid var(--color-muted-foreground); }
@container (min-width:640px) {
.composition-container[data-mode=list-detail] .composition-shell { grid-template-columns:minmax(0,var(--composition-leading)) minmax(0,var(--composition-trailing)); }
.composition-container[data-mode=feed] .composition-list ul { grid-template-columns:repeat(2,minmax(0,1fr)); }
}
@container (min-width:900px) {
.composition-container[data-mode=supporting] .composition-shell { grid-template-columns:minmax(0,var(--composition-leading)) minmax(0,var(--composition-trailing)); }
.composition-container[data-mode=feed] .composition-list ul { grid-template-columns:repeat(3,minmax(0,1fr)); }
}
@media (forced-colors:active) {
.composition-list, .composition-task, .composition-context, .composition-list li { border-color:CanvasText; }
}
Verification
- At widths below/above 640 and 900px, compare reported container width to measured bounds and visible pane relationships. A large viewport with a narrow container must still use the compact behavior.
- Open A and B, keep separate drafts/snapshots, change modes and resize repeatedly; verify exact values and no duplicate task effect.
- Use Tab/Enter to open, edit, save and return. Test focus when a selected list item disappears during resize.
- Change the leading-pane share with arrow keys; check limits, dormant preference preservation, RTL geometry and surviving targets.
- Inspect feed tracks, supporting context and list/detail at 320px, both themes, enlarged text and long unbroken content.
- Review actual mobile/desktop input, software keyboard, browser zoom and any fold/spatial hardware before making platform claims.
- Verify addressable selection and browser history separately when those are requirements of the consuming application.