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 two that change shape with the width — MobileMenu, and ResponsiveDialog, a Dialog on a desk and a Drawer on a phone — 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 drawer, 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.
Two more things live in this frame. A Banner sits under the app bar — in the frame, not in a screen, because “no signal” is true of every tab. It is a role="status" region, so Retry turning it into “Back online” is announced without moving focus. And the Today tab opens on a CircularProgress: how far through the day the crew is, named by its visible label and read out as “3 of 14”.
Sidebar — hidden md:flex
Visits closed today
Next: Riverside Pumping Station · 08:00 – 10:00
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 drawer
There is no room beside the list, and pushing a whole route for one record costs a navigation the user has to undo. ResponsiveDialog picks the presentation by width: below 768px — Tailwind’s md, moved with breakpoint — it is a Drawer, a bottom sheet with a grab handle that a thumb swipes down; from md up the same markup is a centred Dialog with a corner ×. The breakpoint lives in the package, like the shell’s. The footer is where the destructive action lives, behind ConfirmDialog — cancel a visit and it disappears from both tabs.
The Phone tab passes a breakpoint wider than any screen, so the Drawer opens on the desktop you are reading this on — a documentation-only concession, like the menu’s md:flex above; the Desktop tab keeps the default. The drawer takes focus when it opens: since 2.1.0 the package’s Drawer moves focus to the first control inside and keeps Tab there — vaul’s own default left it on the row behind the overlay, and Tab walked the page underneath a modal. On close, a Trigger gets focus back — but these rows open the detail from state. The Dialog branch then returns focus to the row by itself, since 3.3.0; the Drawer branch has no such return, so onCloseAutoFocus on ResponsiveDialogContent sends it back to the row — or to the first row left, when the visit was just cancelled. Its twin, onOpenAutoFocus, picks what takes focus first. Both branches honour both.
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 by phone, because a crew member has a phone and not always a work inbox. PhoneInput is given the workspace’s country — Vietnam, a workspace setting, not the language of this English UI — so a number typed the way people say it, 0912 345 678, is sent as +84912345678. The code goes into InputOTP. Both steps are FormField: submit either one empty and the error is 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 Stepper, whose onValidate runs before every move forward, so Continue on an empty Findings step stops and puts the caret in the notes — and a footer that stays under the thumb instead of at the end of a scroll.
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 | <ResponsiveDialog> as a centred Dialog | <ResponsiveDialog> as a Drawer |
| Filters | Chips wrap onto a second line | Chips scroll horizontally |
| Form layout | FormField colSpan 3–6 per row | Every colSpan collapses to 12 |
| Field text | 14px | 16px — every field, set by the package |
| Page heading | <PageHeader> with breadcrumb | <PageHeader> title only |
Two rows are a component swap, and in both the package makes it — AppShell with CSS, ResponsiveDialog with a media query. Field text is the package too: every field is text-base md:text-sm since 3.13.4, so there is nothing to set. 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
AppShellandResponsiveDialogown the breakpoint. The frame mounts both halves and the dialog picks its own branch; testing a width in your 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— the Banner’s × included. - Put the primary action in a fixed footer, and pass
enterKeyHintandinputModeso the soft keyboard matches the field. - Give
PhoneInputthe workspace’s country from its settings. Without one, a number typed the way people say it — with its leading 0 — is read as a country code. - Reach for the ordinary component on touch too: Rating takes a tap, Cropper pans with one finger and zooms with two, and Kanban drags with a finger — give its cards a
KanbanItemHandleon a phone, so a swipe across a card still scrolls. - 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 a drawer whose only way out is a swipe. A switch or screen-reader user needs a button, and the Drawer branch has no corner ×.
- Don’t lock zoom to stop iOS zooming into a field. The package already holds fields at 16px on iOS, and capping
maximumScaleat 1 or turninguserScalableoff in the viewport only takes pinch-zoom away from Android users (why). Don’t shrink a field’s text with a class of your own, either. - Don’t pass English strings to get English. The package speaks English by default and Vietnamese from one
ComitorLocaleProviderat the root (Set the language). The label props left on this page are Field’s own words — “Sign out”, “Assigned crew”.
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. - The detail drawer takes focus when it opens — the package’s
DrawerdefaultsautoFocusto true since 2.1.0 — and keeps Tab inside. Opened from a row rather than a Trigger, the drawer would drop focus on<body>when it closes: the package returns focus to a Trigger-less opener for its Dialog, not for vaul’s Drawer. SoonCloseAutoFocusreturns it to that row, or to the first row left if the visit was cancelled. It also carries a real “Close details” button, hidden in the Dialog branch through thedata-variantevery part carries. MobileMenuwires itsSheetContentclose button tolabels.closeMenu, so it reads “Close menu”, not a bare “Close”, because a screen reader announces that button out of context. The dialogs and sheets you mount yourself say “Close” in the provider’s language; passcloseLabelwhere out of context that is not enough.- The offline
Banneris arole="status"region, so its change to “Back online” is announced without taking focus, and its × is named “Close notification”. The day ring is a progressbar named by its visible label and read as “3 of 14”. - On the sign-in screen the country button names its value — “Country: Vietnam (+84)” — and the one-time code is a single real input behind six slots, so it takes a paste and the phone’s own code suggestion (
autoComplete="one-time-code"). 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, “Members”, does not say which members.- 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.