Build a master–detail page that survives a refresh and a deep link
By the end of this recipe you'll have a list-on-the-left / detail-on-the-right page on desktop and a list-then-bottom-sheet on phones — with the selected record encoded in the URL, the row staying highlighted, swipe-between-records on mobile, and an edit-in-place toggle for the detail.
Overview
A two-pane layout: list on the left, detail on the right. On phones, the detail rides up as a BottomSheet over the list. The selected id lives in the URL hash so the back button works and deep links land on the right record.
Master–detail is the "let me read this row in context" pattern: the user scans the list, picks one, reads the full record, maybe edits it, then comes back to the same place in the list. The mistake is making the detail a separate route — then the list scroll position and selection are gone every time. Keep the list mounted and slide the detail in.
The opinionated bit: selectedId is derived from the URL, not held in state. useEffect on hash → selectedId; clicking a row updates the hash, not state. That single source of truth is why deep links, refresh and the back button all "just work".
Skip this recipe if the detail is one or two fields and could live in the row itself (use the inline-edit recipe instead). Don't reach for master–detail until the detail has enough to merit its own pane.
What you'll build
Three states. Desktop split, mobile bottom-sheet, and edit-in-place.
Leave requests
Leave requests
Chipo Mhaka · Sick
Chipo Mhaka
You can see this live in
Portal demos that wire this list / detail layout in context. Open one and drill into a row.
Required pieces
Everything this recipe pulls from @corelithzw/react.
@corelithzw/react exports used here: DataTable, BottomSheet, RowCard, Stack, Input, Button, EmptyState. The detail pane uses DetailView's slot conventions — header / body / footer.
Step-by-step build
Bind the selected id to the URL — not to component state
Use the hash (or your router's param) as the truth. useEffect listens to hashchange and derives selectedId; clicking a row updates the hash. The back button now navigates between records for free — and so do deep links.
pushState on row click, listener on popstate for back-button.
Deep link to /leave-requests#/LR-2026-014 opens that exact detail.
Switch to the Code tab to see the snippet.
Two layouts, one tree — match on a single breakpoint
The desktop layout is a CSS grid: list 360px + detail 1fr. The mobile layout is the list at full width, with the detail riding up as a BottomSheet when something is selected. Same children, different shell.
No conditional rendering of the LIST — it stays mounted. Scroll position is preserved.
BottomSheet has the same children as the right-pane div.
Switch to the Code tab to see the snippet.
Render the list — and keep the selected row visually anchored
The selected row gets aria-current="true" and a brand-soft background. On phones, when the sheet closes, scroll the selected row into view — easy to forget, jarring when missing.
Render the detail — with swipe-next/prev hooked to the URL
On mobile, give the sheet swipe gestures: left → next id in the list, right → previous. Implement it as onSwipe calling onSelect(nextId); the URL updates and the sheet re-renders with new content. Bonus: arrow keys on desktop do the same.
Chipo Mhaka · Sick leave
Edit-in-place toggle — per field, not per page
Most users want to read; a few want to edit. Don't ship a Detail and a Detail-edit page. Each DetailRow has an "Edit" affordance that swaps the read-only value for an Input + Save. Closing the sheet without saving discards the edit (with a confirm if dirty).
For richer edits (autosave, conflict), reuse the inline-edit-row recipe's state machine.
Switch to the Code tab to see the snippet.
Final composition
The LeaveRequestsPage, assembled. URL-bound selection, two-layout shell, neighbour navigation, edit-in-place.
Chipo Mhaka · Sick
Variations
Three forks. Each a small diff off the final composition.
Three-pane (list · detail · sidebar)
Inbox-style layouts where the detail itself has an aux panel (related threads, attachments).
gridTemplateColumns:
'320px minmax(0, 1fr) 280px'
// detail becomes the middle column
// aux gets its own URL param: ?aux=attachments
const [aux, setAux] = useUrlParam('aux');
Detail-as-route
Detail lives at a real URL (/leave/LR-2026-014), not a hash. Use your router; the list still stays mounted via a layout route.
// React Router
<Route path="/leave" element={<ListLayout/>}>
<Route path=":id" element={<LeaveDetail/>}/>
</Route>
const { id } = useParams();
const selected = rows.find(r => r.id === id);
Full-screen detail on mobile
Instead of BottomSheet, replace the list entirely on phone. Cheaper memory; loses "swipe back to list" gesture.
if (isPhone) {
return selectedId
? <FullDetail .../>
: list;
}
// drop BottomSheet entirely
// back button: setSelectedId(null)
Accessibility
What this recipe takes care of for you.
- List is a real listbox.
role="listbox"on the container,role="option"on each row,aria-current="true"on the selected row. Screen readers announce "Chipo Mhaka, selected, 2 of 18". - Arrow keys move selection on desktop. Up/Down step through the list and update the URL. Escape clears selection.
- Bottom sheet traps focus. Opening the sheet moves focus to its heading; closing returns focus to the row that opened it.
- Deep links land correctly. The component reads the URL on mount, fetches if needed, and scrolls the selected row into view — no "you're on the page but the wrong row is selected" state.
- Swipe gestures are progressive. They enhance the experience for pointer users, but the underlying Previous/Next buttons are always rendered and keyboard-reachable.
- Edit-in-place announces itself. Toggling to edit moves focus into the new
Input; Saving announces success via the globalToastlive region.