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.

Field

Jobs on the board

14

+2vs yesterday

Blocked

2

+1needs dispatch

Next up

ON08:00 – 10:00
Riverside Pumping Station
ENR09:30 – 11:00
Kingsgate Substation
BLK09:00 – 12:00
Harbour Cold Store
+1
SCH11:00 – 13:00
Northgate Depot
MobileMenu — tap the hamburger

Two 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.

Jobs
Sort
FLD-2209DONE
Old Mill Housing Block
48 Old Mill Street06:30 – 08:00
FLD-2206DONE
Bellrock Retail Park
9 Bellrock Avenue07:00 – 08:30
FLD-2213DONE
Priory Gardens Boiler House
3 Priory Gardens07:30 – 09:00
FLD-2201ON
Riverside Pumping Station
12 Wharf Road, Sector 408:00 – 10:00
FLD-2214ON
Lockwood Transfer Station
20 Lockwood Lane08:30 – 11:30
FLD-2203BLK
Harbour Cold Store
77 Quay Street09:00 – 12:00
+1
Below md — stacked rows, load more

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.

Today
Tap a row — the sheet is fixed, so it covers the page

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.

Jobs
FLD-2201ON
Riverside Pumping Station
12 Wharf Road, Sector 408:00 – 10:00
FLD-2202ENR
Kingsgate Substation
4 Kingsgate Way09:30 – 11:00
FLD-2203BLK
Harbour Cold Store
77 Quay Street09:00 – 12:00
+1
FLD-2204SCH
Northgate Depot
2 Northgate Industrial Park11:00 – 13:00
FLD-2205SCH
Meadow Lane Water Tower
31 Meadow Lane13:00 – 15:00
FLD-2206DONE
Bellrock Retail Park
9 Bellrock Avenue07:00 – 08:30
FLD-2207SCH
St Anne’s Clinic
15 Chapel Row14:00 – 16:00
FLD-2208BLK
Eastfield Grain Silo
Eastfield Farm Road10:30 – 12:30
FLD-2209DONE
Old Mill Housing Block
48 Old Mill Street06:30 – 08:00
FLD-2210ENR
Sandhill Reservoir
Sandhill Access Track10:00 – 12:00
+2
FLD-2211SCH
Carlton Street Exchange
6 Carlton Street15:30 – 17:00
FLD-2212SCH
Beacon Hill Relay
Beacon Hill Summit Road16:00 – 18:00
FLD-2213DONE
Priory Gardens Boiler House
3 Priory Gardens07:30 – 09:00
FLD-2214ON
Lockwood Transfer Station
20 Lockwood Lane08:30 – 11:30
Type, tap chips — the empty state is real

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.

Swipe, or tap the dots
Submit empty — real field errors

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.

Visit report

Step 1 of 3 · Visit

Visit

Where the crew went, and when.

Three steps, one FormSection each

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.

Harbour Cold StoreFLD-2203 · 3 on the crew
  • 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.

to send

Send a message — or clear the thread for the empty state

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.

Profile
Mai Trần

Mai Trần

Dispatcher · Northwind Utilities

Jobs closed this week

18

+3vs last week

Median time on site

41m

−6mvs last week

Riverside Pumping Station
08:00 – 10:00
ON
Kingsgate Substation
09:30 – 11:00
ENR
Harbour Cold Store
09:00 – 12:00
BLK
Tap the status line to edit it in place
Settings

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.

Units
Switch rows carry their own label and description

What actually changes below md

Surfacemd and upBelow 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
DetailSide panel or a route<Sheet side="bottom">
FiltersChips wrap onto a second lineChips scroll horizontally
Form layoutFormField colSpan 3–6 per rowEvery 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 AppShell own 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, not size-8.
  • Put the primary action in a fixed footer, and pass enterKeyHint and inputMode so the soft keyboard matches the field.
  • Use containerClassName, not className, to hide a DataTable — 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 Enter in 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, including closeLabel, ariaLabel and locale={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 an aria-label, and the active tab carries aria-current="page" — colour alone does not survive a screen reader, or a colour-blind user.
  • Rows that open something are real <button> elements via Item asChild. An onClick on a div is unreachable by keyboard and invisible to assistive technology.
  • MobileMenu wires its SheetContent close button to labels.closeMenu, so the one ShellLabels object 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: pass closeLabel on every one, as this page does.
  • AvatarGroup makes each avatar a focusable button carrying the member’s name, so the crew on a job is readable without a pointer. Pass label — 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.