Responsive Behaviour
How product UI adapts across breakpoints — App Nav, drawers, and other content that forks by width.
How product UI adapts across phone, tablet, desktop, and ultrawide. This is the home for breakpoint behaviour — the shell fork, and any other surface that changes with width. Read it before changing App Nav, a Drawer, or inventing a new mobile/tablet split.
One primary layout fork: lg (1024px). Phone and tablet portrait share
the same shell below that. Secondary widths (1366, 1920) only tweak desktop
layout — they never decide bottom sheet vs right rail.
Playground
Step through the locked schematic captures (App nav / Drawer, open / closed, breakpoint):
// App nav · drawer closed// Phone · joint// /changelog/2026-08-12-shell-nav-phone.pngApp nav
Product top navigation (App Nav). Source of truth in code for nav shape.
Breakpoint matrix, screenshots, and rules
| Viewport | Width | No drawer | Drawer open |
|---|---|---|---|
| Phone | ~390 (< lg) | Joint icons-only, centered | Hidden |
| Tablet portrait | ~768 (< lg) | Same as phone | Hidden |
| Desktop typical | 1024–1919 (lg+) | Split track (two pills) | Drawer-compact tuck (pack left on the same track) |
| Desktop + scroll | 1024–1919, scrolled | Joined full-width frost, icons at both ends | Tuck from joined: keep frost, shrink gap only |
| Ultrawide | ≥ 1920 | Split track | No tuck — split stays split |
Reference screenshots
| State | Image |
|---|---|
| Phone · joint | ![]() |
| Tablet · joint | ![]() |
| Desktop · split | ![]() |
| Desktop · joined | ![]() |
| Desktop · packed (from split) | ![]() |
| Desktop · packed (from joined) | ![]() |
| Ultrawide · split | ![]() |
Rules (normative — change this page in the same PR if you change the code)
Below lg — joint compact
- One centered icons-only pill (no first-name label, no hover / scroll expansion).
- Dock Back (instrument routes) pins far left of the padded track and must not shift the pill.
- Slightly denser icon slots below
sm; a touch larger fromsmthroughlg. - Directional scroll hide: down hides, up shows; within ~16px of the top
always shown. Hide with a transform on the outer wrapper, never on the
glass nav host (that flattens
backdrop-filterin Chromium). - Open or exiting overlay drawer → dock fully hidden for the whole exit
(
useDrawerExit/OVERLAY_EXIT_MS), not icon-compact.
lg+ — split track (resting)
- Always keep the desktop split track — never swap in the mobile joint pill for drawer-compact.
- Rest: destinations pill + account pill with a flexible gap.
- On scroll (unify-on-scroll): gap closes, frosts crossfade into one full-width bar; icons stay at both ends. Hover / menu still expand labels.
- When the drawer closes, drawer-tuck must fully unwind so resting scroll-unify owns the frost again.
lg+ and < 1920 — drawer-compact tuck
Never rewrite the resting track. Only the gap and frost phases change:
- From split: compress first (gap closes, account slides left) → then merge frosts into one left-hugging pill.
- From already joined: keep the unified frost; only shrink the gap.
- Close: reverse. If still scroll-joined, clear tuck and reopen the gap. Otherwise unmerge, then expand.
Pack timing follows the dock expand duration (~320ms); the short unmerge beat when closing from a packed split uses the collapse duration.
≥ 1920 — no drawer tuck
The overlay rail clears the account shell; the split dock stays split with the drawer open.
Secondary desktop layout (do not invent new tiers):
| Width | Extra rule |
|---|---|
< 1366 | Dock Back sits in-flow beside the shells (a left gutter would clip) |
≥ 1366 | Dock Back may sit in the left gutter when shown |
< 1920 | Desktop drawer triggers drawer-compact tuck |
≥ 1920 | Ultrawide — account shell clears the rail; no tuck |
Drawer
Overlay Drawer from @sophic/portal-ui. Callers
don’t branch on viewport — Drawer uses useLgUp() internally. Source of
truth for slide axis (bottom sheet vs right rail).
Breakpoint matrix, screenshots, and rules
| Viewport | Width | Overlay | Dock while open |
|---|---|---|---|
| Phone | ~390 (< lg) | Bottom inset sheet + wash | Hidden |
| Tablet portrait | ~768 (< lg) | Same as phone | Hidden |
| Desktop typical | 1024–1919 (lg+) | Right 450px rail (undimmed unless overlay) | Packed left |
| Ultrawide | ≥ 1920 | Right 450px rail | Split stays |
Reference screenshots
| State | Image |
|---|---|
| Phone · bottom sheet | ![]() |
| Tablet · bottom sheet | ![]() |
| Desktop · right rail (+ packed dock) | ![]() |
| Ultrawide · right rail (+ split dock) | ![]() |
Rules (normative — change this page in the same PR if you change the code)
Below lg — bottom overlay
- Viewport-fit inset card; stops ~10% from the top.
- Always shows a subtle dimming wash; always modal.
- Covers most of the screen, so the App nav hides for the whole open + exit
(
useDrawerExit/OVERLAY_EXIT_MS).
lg+ — right rail
- Fixed 450px rail; non-modal by default (page underneath stays
interactive unless
overlayis set). - Desktop right rails stay undimmed unless
overlayis set. - Below 1920 the App nav packs left; at 1920+ the split dock stays put.
Frosted header and action docks (same at every breakpoint)
DrawerHeaderDock— title + close / back at the top; downward frost.DrawerActionDock— primary actions at the bottom; upward frost.- Absolute docks need a
relativeparent. Scroll bodies need matching top / bottom padding so content doesn’t sit under the frost.
Push modes (push-reserve / push-scale / push-translate) are a
separate desktop affordance — see the Drawer
playground. Below sm they fall back to a fullscreen sheet via useSmUp();
that does not change the lg overlay axis above.
Trade Buy/Sell
Buy / Sell control in trade compose. Forks on lg so the bottom sheet doesn’t
use the desktop folder tabs.
Breakpoint matrix and rules
| Viewport | Control |
|---|---|
| All viewports (column + bottom-sheet) | BuySellTabs (underline Tabs — same as settings Profile nav) |
Buy/Sell no longer switches between folder tabs and a segmented control by breakpoint. Market/Limit inside the ticket use the default segmented Tabs.
Holdings compact column headers
Holdings Table column headers shorten on narrow viewports. Content fork only — not a shell orientation change.
Breakpoint matrix and rules
| Viewport | Profit & Loss | % of holdings |
|---|---|---|
Below md | P&L | % |
md and up | Profit & Loss | % of holdings |
Uses SlickDataTable headerCompact for the short label. Full header stays
the wide label and accessibility name. See also
Table.
What's inside
Weight rows (top holdings, sectors, countries, asset classes). Content fork only — the card stays full width.
Breakpoint matrix and rules
| Viewport | Row |
|---|---|
Below sm (~390 phone) | Name on the first line. Weight bar and percent on the next, bar filling the row. |
sm and up (640+) | Name, bar, and percent on one line. |
Phone portrait is the tight case. Tablet portrait stays on one line — the column is wide enough.
Card headers
Section Card headers adapt to the card's width, not the viewport's. The
header is a size container (@container), so the same card behaves the
same on a phone, in a 370px sidebar, or in a narrow desktop column. Content
fork only.
Header matrix and rules
| Header width | View toggle | Other actions | Detail |
|---|---|---|---|
| Below 32rem | Chip Select (View Switch) | Icon only; a second one moves to an overflow menu | Chevron only |
| 32rem and up | Segmented Control | Up to two visible | Label and chevron |
- Narrow headers keep at most one visible action plus the detail chevron.
- Switch views with View Switch; don't pair a Select and a Segmented Control
by hand with
lg:hidden. - Pass
collapseBelowwhen a long set of options needs more room (for example a toolbar with four options collapses belowxl, 36rem). - Outside a Section Card, make the row that holds the switch an
@container. - Keep option labels short ("Annualized", not "Annualized returns"); the card title already names the subject.
Key files
Where to look in the repo for important pieces.
Show pieces
| Piece | Role |
|---|---|
AppNav | Top nav — split vs joint compact, drawer tuck (docs) |
useLgUp() (portal) | App-facing lg breakpoint |
useLgUp() (@sophic/portal-ui) | Drawer’s push vs overlay / slide axis |
useSmUp() (@sophic/portal-ui) | Push-drawer fullscreen below sm only |
useDrawerCompactViewport() | Ultrawide gate for drawer tuck (< 1920) |
Drawer | Picks push vs overlay |
| Overlay drawer mode | Bottom inset card (< lg) vs right 450px rail (lg+) |
useDrawerExit() / OVERLAY_EXIT_MS | Exit coordination for shell UI |
DrawerHeaderDock / DrawerActionDock | Frosted top / bottom bars in detail drawers |
BuySellTabs | Trade Buy/Sell (underline Tabs) |
SlickDataTable headerCompact | Short column labels below md (e.g. P&L) |
SectionCard header @container | Card header fork by card width (32rem) |
ViewSwitch | Segmented Control or chip Select by container width |
Changelog
- 6 October 2026 — Section Card headers fork on the card's width: View Switch collapses to a chip Select and the detail label drops below 32rem. Full notes
- 5 October 2026 — What's inside weight rows put the bar and percent
under the name below
sm. Full notes - 25 August 2026 — Holdings % of holdings shortens to % below
md(alongside P&L). Full notes - 12 August 2026 — Foundations page for breakpoint behaviour (shell matrix + content forks). Repo standards pointer removed; this page is the single home. Full notes










