Mobile Patterns
One hand, one thumb, an unreliable network and a screen the system partly owns. Every mobile rule follows from those four facts.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
The floor. A fingertip contact patch is roughly 8–10mm, and 44px is what covers it with room for the imprecision of a moving hand.
Safe area respected: the bar sits above the home indicator.
The thumb zone
The arc a right thumb sweeps from the bottom corner. Everything a user does often belongs inside it; anything destructive belongs outside it.
Navigation goes to the bottom, not the top.
The primary action is full-width at the bottom of a form.
Back lives at the top-left because the system put it there — and an edge swipe does the same job in the comfortable zone.
Destructive actions go where the thumb does not rest by accident.
Swipe actions
Drag the row left. It is fast for people who know it, invisible to everyone else — so the same two actions must also exist somewhere visible.
Pull to refresh
Drag the list down. The indicator tracks the finger, so the gesture explains itself before it commits — and it still needs a visible refresh control elsewhere.
Safe areas
The home indicator owns the bottom band on a gesture-navigation phone. A bar that ignores it puts its targets under the swipe that closes the app.
Adapting desktop layouts
Master–detail becomes list-then-page with a back control. Two squeezed columns serve neither.
Interaction states
Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.
Every part, every measurement, and the reason it is that number.
- Deployment finished
- Billing webhook failed
- New comment from Grace
- Sprint 14 closed
- Invoice #4021 paid
- Backup completed
A phone screen and the zones it is really divided into. The layout on the left only works because it agrees with the map on the right.
- Status bar / notchenv(safe-area-inset-top)
The system’s. Content underneath it is clipped on some devices and hidden behind a camera on others. Never hardcode its height — it differs per handset.
- Top bar44–56px
Title and at most two actions. It is in the hard-to-reach zone, which is why frequent actions do not live here — only Back does, because the system put it there.
- Content wellthe scrolling region
One scroll context. Nested scrolling areas on touch are near-impossible to control with a thumb and should be designed out.
- Row height44px minimum
The floor for anything tappable. Padding counts toward it — the visible mark can be small provided the hit area is not.
- Thumb arcbottom third
The comfortable zone for a one-handed grip. Everything used often belongs inside it; anything destructive belongs outside it.
- Bottom navigation56px + inset
Three to five destinations, inside the arc, with labels. The inset is added below the bar, not absorbed into its height.
- Home indicatorenv(safe-area-inset-bottom)
Roughly 34px on a gesture-navigation phone, and it is not yours. A target here competes with the swipe that closes the app.
- Edge gutters16px
Content clears the edge because a curved screen distorts the last few pixels — and because the hand wrapping the phone rests there.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Spacing
| Token | Value | Used for |
|---|---|---|
| touch target | The floor for anything tappable | |
| safe-area-inset-top | Status bar and notch | |
| safe-area-inset-bottom | Home indicator | |
| edge gutter | Content inset from the screen edge | |
| row height | List rows | |
| bottom bar | Navigation |
Typography
| Token | Value | Used for |
|---|---|---|
| --text-body | Body text. 16px on inputs, to stop iOS zooming |
Motion
| Token | Value | Used for |
|---|---|---|
| duration | Page transitions | |
| gesture threshold | Swipe and pull commit points |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Type | Min width | Touch target | When to use |
|---|---|---|---|---|---|---|
| Touch target | 44px | — | — | 44px | 44px | The floor. Padding counts. |
| Comfortable target | 48px | — | — | — | 48px | Primary actions and anything used one-handed while walking. |
| List row | 44–56px | 0 16px | — | — | — | Single or two-line rows. |
| Top bar | 44–56px | — | — | — | — | Plus the top safe-area inset. |
| Bottom nav | 56px | — | — | — | — | Plus the bottom safe-area inset. |
| Edge gutter | — | 16px | — | — | — | Both sides. Never flush to a curved edge. |
| Input font size | — | — | 16px | — | — | Below 16px, iOS zooms the page on focus and does not zoom back. |
| Swipe threshold | — | — | — | 64px | — | Past this, the action commits on release. |
| Pull threshold | 56px | — | — | — | — | Past this, the refresh fires on release. |
padding-block-end: max(1rem, env(safe-area-inset-bottom))input { font-size: 16px }Not a checklist to run at the end. These are the requirements the component was built from.
Contrast
- Mobile screens get used outdoors. Contrast that passes indoors at 4.5:1 can be unreadable in sunlight, so treat the minimum as a minimum rather than a target.
- Do not lower contrast to fit more on screen. If it does not fit at readable contrast, it does not fit.
- Test with the display at low brightness and with a smudged screen. Both are the normal condition, not an edge case.
Keyboard
| External keyboard | Phones and tablets support them. Every control still needs to be reachable and focusable. |
| Switch control | Steps through focusable elements one at a time — which is why gesture-only actions exclude people entirely. |
| Escape / Back | The system back gesture must dismiss the topmost layer, exactly as Escape does on desktop. |
Screen readers
- VoiceOver and TalkBack take over the touch gestures entirely. Any custom gesture must have a real control behind it, or it does not exist under a screen reader.
- Announce refresh states. A spinner conveys nothing; "Refreshing" and then "Updated, 6 items" conveys the same thing the animation does for everyone else.
- Group list rows sensibly. A row read as six separate fragments is exhausting to move through at speed.
- Respect the system text size. A user at 200% text has told the operating system something important, and a layout that clips at that size is a reflow failure.
Focus & touch
- The software keyboard covers up to half the screen. Scroll the focused field into the remaining space, and never let the keyboard hide the submit button — that is the single most common mobile form failure. Use dvh rather than vh so the layout tracks the visible viewport instead of an idealised one.
- This entire page is the touch section. The short version: 44px targets, act on pointer-up, respect the safe areas, never require a gesture, keep frequent actions in the bottom third, and keep destructive ones out of it.
| Attribute | Applied to | Notes |
|---|---|---|
| Target size | Every control | WCAG 2.5.8 sets 24px as the AA floor; 44px is the practical one. The spacing exception only helps if the neighbours are genuinely far enough away. |
| Pointer cancellation | Every button | WCAG 2.5.2: fire on pointer-up, not pointer-down, so a user can slide off a mis-aimed control to abort. Never act on touch-start. |
| Gesture alternative | Swipe and long-press actions | WCAG 2.5.1 requires a single-pointer path to anything a gesture does. A visible button satisfies it. |
| Orientation | The app | WCAG 1.3.4: do not lock to portrait. Someone with a mounted device may have no choice about how it is held. |
| aria-live | Refresh and offline states | A spinner that means nothing to a screen reader needs "Refreshing" and "Updated" announced politely. |
Example usage
1// Adapt the container, not just the width. Master–detail becomes2// list-then-page — two squeezed columns serve neither side.3const isPhone = useMediaQuery('(max-width: 639px)')45return isPhone6 ? selected7 ? <DetailPage item={selected} onBack={() => setSelected(null)} />8 : <List onSelect={setSelected} />9 : <MasterDetail selected={selected} onSelect={setSelected} />1011// Every gesture needs a visible twin. The swipe is the accelerator;12// the button is the interface.13<SwipeableRow14 onSwipeLeft={archive}15 actions={<IconButton label="Archive" icon={<Archive />} onClick={archive} />}16/>1718// Act on pointer-up so a mis-aimed tap can be slid off and aborted.19// WCAG 2.5.2, and also just how people expect buttons to behave.20<button onPointerUp={submit}>Retry now</button> // not onPointerDown2122// The keyboard covers up to half the screen. dvh tracks the visible23// viewport; vh is an idealised one the user may never actually have.24<div className="h-dvh">…</div>CSS
/* The floor. Padding counts toward it, so a 16px icon is fine. */
.ds-touch-target {
min-block-size: 44px;
min-inline-size: 44px;
display: grid;
place-items: center;
}
/* The system's bands are not yours, and their size differs per
device. max() keeps a sensible minimum on handsets with no inset. */
.ds-screen {
padding-block-start: env(safe-area-inset-top);
padding-inline: max(16px, env(safe-area-inset-left));
}
.ds-bottom-bar {
padding-block-end: max(12px, env(safe-area-inset-bottom));
}
/* Below 16px, iOS zooms on focus and never zooms back. */
input, select, textarea { font-size: 16px; }
/* Let the browser own the axis it needs; claim only the other one.
touch-action: none on a scrollable region breaks scrolling. */
.ds-swipe-row { touch-action: pan-y; }
.ds-pull-list { touch-action: pan-x; }
/* dvh tracks the visible viewport as the address bar collapses and
the keyboard opens. vh does not. */
.ds-app { block-size: 100dvh; }
/* Tap highlight is the platform's; ours is a real pressed state. */
.ds-button {
-webkit-tap-highlight-color: transparent;
transition: transform 90ms ease-out;
}
.ds-button:active { transform: scale(0.97); }
/* No hover on touch — so anything hidden behind it must come back. */
@media (hover: none) {
.ds-row__actions { display: flex; }
}Component API
Gesture contract
| Prop | Type | Default | Description |
|---|---|---|---|
| threshold | number | 64–110px | Travel before the action commits. Far enough that a scroll never triggers it by accident. |
| touchAction* | 'pan-x' | 'pan-y' | — | Claim one axis and leave the browser the other. `none` on a scrollable region breaks scrolling. |
| alternative* | ReactNode | — | The visible control that does the same thing. Not optional — WCAG 2.5.1. |
| onCancel | () => void | — | Sliding back below the threshold must abort cleanly. Pointer cancellation, WCAG 2.5.2. |
Safe area
| Prop | Type | Default | Description |
|---|---|---|---|
| viewport-fit=cover* | meta tag | — | Required before env(safe-area-inset-*) reports anything but zero. |
| env(safe-area-inset-top) | CSS | — | Status bar and notch. Differs per device — never hardcode it. |
| env(safe-area-inset-bottom) | CSS | — | Home indicator, roughly 34px on gesture-navigation phones. |
Professional tips
- Test one-handed on the largest phone you support. The top-left corner of a 6.7-inch screen is genuinely out of reach, and no amount of desk testing reveals that.
- Test with a thumb, not an index finger. Pointing at a screen you are holding flat is a completely different level of precision from how the device is actually used.
- Add viewport-fit=cover to the meta tag or env(safe-area-inset-*) reports zero everywhere and the layout looks fine right up until it ships.
- Prefer a bottom sheet to a dialog on every mobile surface. It arrives where the thumb is and leaves the same way.
- Undo beats confirm. A snackbar with Undo costs one tap when wrong; a confirmation dialog costs one tap every single time.
- Keep tap animations under about 100ms. Anything slower reads as lag rather than as feedback, and on touch the feedback is all the user has.
Performance
- Mobile CPUs are several times slower than the laptop you are testing on, and the network is worse. Budget accordingly rather than optimistically.
- Animate transform and opacity only. Anything else drops frames on a mid-range Android, which is the median device for most products.
- Virtualise long lists and keep images sized. Layout shift on a phone moves the thing someone was about to tap.
- Use passive scroll listeners. A non-passive touchmove handler blocks scrolling on the main thread and produces the classic sticky-scroll feel.
- Serve smaller images to smaller screens. A 2000px hero on a 390px phone is bandwidth spent on a device that has the least of it.
- Cache aggressively and render from cache first. On a slow connection, showing yesterday’s list instantly beats showing a spinner for four seconds.
Common mistakes
- Targets under 44px, inherited straight from the desktop layout.
- Ignoring safe areas, so the last row sits under the home indicator.
- Gestures with no visible equivalent, invisible to most users and impossible under a screen reader.
- Hover-dependent interfaces, where the first tap fires the thing underneath.
- Inputs under 16px, causing iOS to zoom and never zoom back.
- vh instead of dvh, so the keyboard covers the submit button.
- Horizontally scrolling tables that a thumb cannot control.
- Destructive actions in the comfortable zone, where accidental taps land.
- Acting on pointer-down, so a mis-aimed tap cannot be aborted.
Real-world recommendations
- Check the analytics before arguing about this. In most products mobile is the majority of sessions, and it is usually still designed second.
- The median device is a mid-range Android two or three years old, not the phone in your pocket. Test on one; the difference is not subtle.
- Every gesture you add is a feature for the people who find it and nothing at all for everyone else. Add them freely — just never as the only route.
- One-handed use while walking, in sunlight, on a bad connection is the real environment. Any of those alone breaks a design tested at a desk.
- The safe-area inset is the most commonly skipped rule on this page, and it is the one users notice immediately — a button they cannot press without closing the app.