Patterns
Mobile App
The same product, below md. These are the screens of Comitor Field — a dispatch app whose users are outdoors with one thumb free — built from the same composites as every other pattern here, and shown at the width they were designed for.
Almost nothing on this page is a mobile component. The package ships one — MobileMenu — and everything else is the ordinary composite at a narrow width. What changes is the arrangement: a table becomes a stack, a side panel becomes a sheet, a pager becomes “load more”.
The sidebar hands over to MobileMenu
AppShell mounts both halves of the frame and lets CSS pick. Sidebar is hidden md:flex; MobileMenu is a Sheet whose content is md:hidden, and the hamburger that opens it lives inside AppHeader. No width is tested in application code.
Open the menu: the order inside it is deliberately not the sidebar’s. Workspace switcher, then this app’s nav, then the app list, then the user — because on a phone the thing you opened the menu to find is the current app’s navigation, and an ecosystem of eight products would otherwise push it below the fold.
Sidebar — hidden md:flex
Jobs on the board
14
+2vs yesterday
Blocked
2
+1needs dispatch
Next up
MobileMenu — tap the hamburgerTwo documentation-only concessions, both visible in the demo: the sheet is given md:flex so it opens on the desktop you are reading this on, and the phone renders its own hamburger because AppHeader cannot be squeezed into a 300px box. The sheet is fixed, so it covers the page rather than the frame — that is the component behaving correctly, and on a real phone the two are the same thing.
One list, two densities
The job table is six columns and 860px wide. At 375px that is a truncated site name and nothing else, so below md the DataTable is swapped for a stack of Item rows — the same StatusPill, AvatarGroup and RelativeTime, rearranged. Sort in one tab and the other agrees: both read one controlled sort object.
Detail arrives as a sheet
There is no room beside the list, and pushing a whole route for one record costs a navigation the user has to undo. A bottom Sheet keeps the list underneath and closes on Escape or the backdrop — not on a downward drag. Swipe-to-dismiss is Drawer, the vaul-backed sibling; reach for it when the drag handle is part of the design. The footer is where the destructive action lives, behind ConfirmDialog — cancel a visit and it really disappears from the list.
SheetContent takes closeLabel, and its default is Vietnamese. It is the one string in the component that no other prop reaches, and it is the accessible name of the close button.
Search and filters at 375px
SearchFilterBar needs no mobile variant — its row already wraps, and the result count keeps a fixed min-h-7 so the list does not jump when a filter turns on. FilterChips does need one: six status chips do not fit one row at this width, so the page overrides flex-wrap into a scroll strip. Filter everything away and you get a real EmptyState, not a blank box.
First run: onboarding and sign-in
Onboarding is the one screen where a swipe is the primary control, so it is the one screen where Carousel earns its weight — drag, momentum and arrow keys come with it, and setApi exists so the app can draw its own dots. Sign-in is plain FormField + InputGroup: submit it empty and the errors are wired through aria-describedby by the field, not by hand.
Your day, sequenced
Every visit for today, ordered by arrival window and kept on the device when signal drops.
Report from the site
Readings, photos and parts used go in before you leave the gate — no evening paperwork.
Dispatch sees it live
Mark en route or blocked once and the board updates for the whole crew.
A long form, cut into steps
The FormSection is the desktop one, unchanged: its content is a 12-column grid, and every colSpan class starts at col-span-full and only widens at sm/md. A two-across form is one field per row here for free. What the phone adds is the split into steps, a Progress bar, and a footer that stays under the thumb instead of at the end of a scroll.
Step 1 of 3 · Visit
Visit
Where the crew went, and when.
A job thread
Every message carries a RelativeTime, which renders a real <time> with the absolute timestamp in its title. LetterAvatar gives one person the same colour in every Comitor app. The composer sends on mod+Enter only — on a soft keyboard the return key is how people start a new line — and says so with KeyboardHint, which prints ⌘ or Ctrl per platform.
- Tomás Ruiz
Tomás Ruiz
Gate code from the file is wrong — security won’t let the van through. - Calling the site contact now. Hold at the layby.
- Amara Okoye
Amara Okoye
New code is 4417. Marking us on site once we’re through.
Ctrl+↵ to send
Profile and settings
Two screens that are almost entirely composites. InlineEdit replaces an “edit profile” round trip with a tap; StatCard stacks to one column and keeps its delta polarity honest — a falling time on site is coloured as good, a rising one is not. On the settings screen, Switch takes label and description itself, which is what makes the whole row a tap target rather than text sitting next to a control.
Mai Trần
Dispatcher · Northwind Utilities
Jobs closed this week
18
+3vs last week
Median time on site
41m
−6mvs last week
On the road
Keep today’s jobs on the device when signal drops.
Mark a job on site when the van stops within 100m.
Notifications
Push a notification when a job is reassigned to you.
What actually changes below md
| Surface | md and up | Below md |
|---|---|---|
| Navigation | <Sidebar> rail, 256px | <MobileMenu> sheet + a bottom tab bar |
| Record list | <DataTable>, sortable columns | <Item> rows, one sort control |
| Paging | <TablePagination> | “Load more” at the end of the list |
| Detail | Side panel or a route | <Sheet side="bottom"> |
| Filters | Chips wrap onto a second line | Chips scroll horizontally |
| Form layout | FormField colSpan 3–6 per row | Every colSpan collapses to 12 |
| Page heading | <PageHeader> with breadcrumb | <PageHeader> title only |
Only the first row is a component swap. The rest are the same components in a different arrangement — which is why a mobile screen in this system is a layout decision, not a second component library. For the full-width version of these screens, see Application Shell and Tasks & Projects.
Best practices
Do
- Let
AppShellown the breakpoint. Both halves of the frame are already mounted; testing a width in your own code adds a second source of truth. - Keep one state for both layouts. The phone list and the table on this page share
sort— forking it is how “it filters differently on mobile” bugs start. - Give every bar control a 44px box. Icon buttons here are
size-11, notsize-8. - Put the primary action in a fixed footer, and pass
enterKeyHintandinputModeso the soft keyboard matches the field. - Use
containerClassName, notclassName, to hide aDataTable— the latter targets<table>and leaves an empty bordered box behind.
Don’t
- Don’t let a wide table scroll horizontally inside a page that already scrolls vertically. Swap the layout instead — two-axis scrolling on touch is a coin flip.
- Don’t build a second set of “mobile components”. Everything on this page except the frame, the status bar and the tab bar is the ordinary composite.
- Don’t rely on hover to reveal an action. The row menus here are visible; a hover-only affordance simply does not exist on touch.
- Don’t send on bare
Enterin a composer, and don’t put a destructive action in the primary footer slot. - Don’t ship the package defaults. Every composite here is given English
labels, includingcloseLabel,ariaLabelandlocale={enUS}.
Accessibility
- Touch target size is a WCAG 2.2 criterion (2.5.8, 24×24 minimum), not a style preference. This page uses 44px, the platform guidance from both Apple and Google, for every bar and footer control.
- The bottom tab bar is a
<nav>with anaria-label, and the active tab carriesaria-current="page"— colour alone does not survive a screen reader, or a colour-blind user. - Rows that open something are real
<button>elements viaItem asChild. AnonClickon adivis unreachable by keyboard and invisible to assistive technology. MobileMenuwires itsSheetContentclose button tolabels.closeMenu, so the oneShellLabelsobject localises it too — and it reads “Close menu”, not a bare “Close”, because a screen reader announces that button out of context. Sheets you mount yourself are not connected to it: passcloseLabelon every one, as this page does.AvatarGroupmakes each avatar a focusable button carrying the member’s name, so the crew on a job is readable without a pointer. Passlabel— the default group name is Vietnamese.- Test at 320px and at 200% text zoom, then again with the phone in landscape: a fixed footer plus a soft keyboard is where mobile layouts break first.