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

Field
No signal
2 updates wait on this phone.
3/14

Visits closed today

Next: Riverside Pumping Station · 08:00 – 10:00

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

Today
Tap a row — the Drawer branch: swipe it down, tap outside, or press Escape

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.

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

Swipe, or tap the dots
Type 0912 345 678 — it is sent as +84912345678

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 report

Visit

Where the crew went, and when.

Three steps — Continue validates the one you are on

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
Detail<ResponsiveDialog> as a centred Dialog<ResponsiveDialog> as a Drawer
FiltersChips wrap onto a second lineChips scroll horizontally
Form layoutFormField colSpan 3–6 per rowEvery colSpan collapses to 12
Field text14px16px — 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 AppShell and ResponsiveDialog own 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, not size-8 — the Banner’s × included.
  • Put the primary action in a fixed footer, and pass enterKeyHint and inputMode so the soft keyboard matches the field.
  • Give PhoneInput the 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 KanbanItemHandle on a phone, so a swipe across a card still scrolls.
  • 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 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 maximumScale at 1 or turning userScalable off 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 ComitorLocaleProvider at 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 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.
  • The detail drawer takes focus when it opens — the package’s Drawer defaults autoFocus to 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. So onCloseAutoFocus returns 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 the data-variant every part carries.
  • MobileMenu wires its SheetContent close button to labels.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; pass closeLabel where out of context that is not enough.
  • The offline Banner is a role="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").
  • 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, “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.