Skip to content

Designing for Mobile: What Actually Matters Now

Mobile stopped being a special case a decade ago. The hard parts now are viewport units that lie, forms that fight the keyboard, and devices with CPUs unlike yours.

Share
Abstract visualisation of a layout adapting fluidly across changing frame widths

"Designing for mobile" as a distinct activity is a framing left over from when mobile was the exception. It stopped being that a long time ago. Most traffic on most sites is on a phone, and treating the phone as a variant of the real design is backwards, because the phone is the real design and the desktop layout is the variant with more room.

Which means the interesting problems have moved. Fluid grids and media queries are solved, and CSS now handles them with far less code than it used to. What still catches people out is more specific: viewport units that report the wrong height while the browser chrome animates, forms that fight the on-screen keyboard, hover states that stick on touch devices, tap targets that pass review and fail in a moving vehicle, and phones whose processors are nothing like the machine the site was built on.

Those are what this covers.

Stop designing for devices

The habit worth breaking first is breakpoints named after hardware. A stylesheet with @media (max-width: 375px) for iPhone and 768px for iPad encodes assumptions that were shaky when they were written and are now simply wrong. Devices don't cluster at predictable widths, foldables change width at runtime, and Android's resizability rules mean an app or web view can be handed almost any window size.

The better approach is to let the layout find its own breakpoints. Most of what used to require media queries is now expressible directly:

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
  gap: clamp(1rem, 3vw, 2rem);
}

That reflows at whatever width the content actually needs, with no breakpoints at all. The min(18rem, 100%) matters: without it, a 288px minimum will overflow a 320px screen once you account for padding, which is the single most common cause of horizontal scroll on small phones.

For components, container queries are the real answer, because a card in a sidebar has nothing to do with the viewport width. That is a large enough topic that I covered it separately in the piece on CSS architecture, but the short version is that a component should respond to the space it was given rather than to the size of the window.

Where you do still need media queries, express them in rem and place them where the layout breaks rather than where a device is.

The viewport height problem, and its actual fix

This one deserves its own section because it is genuinely confusing, and the fix isn't widely known.

100vh on a mobile browser has never meant what people expect. The browser's address bar and toolbars expand and collapse as you scroll, so the visible area changes while vh does not, which is why full-height sections have historically been either cut off at the bottom or slightly too tall. Years of JavaScript workarounds exist for this.

CSS now has proper units for it:

.hero {
  min-block-size: 100svh;  /* smallest viewport: chrome visible */
}

.overlay {
  block-size: 100dvh;      /* dynamic: tracks chrome as it moves */
}

svh is the height when browser chrome is showing, lvh is the height when it is hidden, and dvh updates live as the chrome animates. The practical guidance: use svh for anything that must be fully visible on load, since it guarantees no clipping, and use dvh for overlays and modals that should track the actual visible area. Avoid dvh on large scroll-linked layouts, because a value that changes during scroll can cause reflow at exactly the wrong moment.

All three are Baseline and safe to use directly.

Safe areas, notches and rounded corners

If you set viewport-fit=cover in your viewport meta tag, which you need for edge-to-edge layouts, your content can end up under the notch, the home indicator or a rounded corner. The environment variables exist for this:

.app-bar {
  padding-inline: max(1rem, env(safe-area-inset-left));
  padding-block-end: max(0.75rem, env(safe-area-inset-bottom));
}

The max() wrapper is the part people miss. env() returns zero on devices without insets, so using it alone gives you no padding at all on an ordinary phone. Wrapping it in max() with your normal padding gives you a sensible default and expands only where the hardware requires it.

The bottom inset matters most, because that is where the home indicator sits and where fixed action buttons tend to go.

Touch targets, with real numbers

The original advice on this, that targets should be "large enough to be easily tapped", is true and unactionable. The numbers:

WCAG 2.2 introduced Target Size (Minimum) at Level AA, requiring 24 by 24 CSS pixels, with an exception where sufficient spacing separates smaller targets. The older Enhanced criterion at AAA asks for 44 by 44. Apple's guidance is 44 by 44 points; Google's Material guidance is 48 by 48 density-independent pixels.

Practically, 44 is a sensible floor, and 48 is more comfortable. But the more useful insight is that the touch target does not have to match the visible element. A 24px icon can carry a 48px hit area:

.icon-button {
  position: relative;
  inline-size: 1.5rem;
  aspect-ratio: 1;
}

.icon-button::after {
  content: "";
  position: absolute;
  inset: -0.75rem;
}

That keeps the visual design tight while making the control genuinely tappable, which is the compromise most interfaces need.

Spacing matters as much as size. Two 48px buttons flush against each other are harder to use accurately than two 40px buttons with 8px between them, because the cost of a near-miss is different: a gap produces no action, while an adjacent target produces the wrong one.

Hover does not exist, and pointers vary

A :hover style on a touch device does not simply do nothing. It typically applies on tap and stays applied until you tap elsewhere, which can leave controls stuck in a highlighted state and menus that open on the first tap and navigate on the second.

Gate hover behaviour on capability rather than on width:

@media (hover: hover) and (pointer: fine) {
  .card:hover { --card-elevation: 2; }
}

Width is a bad proxy for input method. A large touchscreen laptop is wide and coarse-pointered; a phone in desktop mode is neither. pointer: coarse tells you the primary input is imprecise, which is the thing that should drive target sizing, and any-pointer: fine tells you a precise input is available even if it is not primary.

Anything that only reveals itself on hover needs a touch-accessible equivalent, including tooltips, which are one of the most common things to be silently unusable on a phone.

Forms are where mobile actually goes wrong

Forms are the highest-friction part of most mobile experiences, and attributes improve them more than design.

The keyboard should match the field:

<input type="email" inputmode="email" autocomplete="email" enterkeyhint="next">
<input type="tel" inputmode="tel" autocomplete="tel">
<input inputmode="numeric" pattern="[0-9]*" autocomplete="one-time-code">

inputmode controls which keyboard appears, which saves users switching layouts to find the @ symbol. autocomplete with the correct token, it lets the browser and password manager fill fields reliably, and getting these tokens right is one of the largest usability improvements available for the effort. enterkeyhint labels the return key, so it says Next through a sequence and Done at the end.

Then the iOS zoom problem, which is worth knowing precisely. Safari on iOS zooms the page when focus enters an input with a font size below 16px. It is not a bug, and you can't disable it without also disabling pinch zoom, which harms accessibility. The fix is simply not to go below the threshold:

input, select, textarea {
  font-size: max(16px, 1rem);
}

Also worth handling: when the keyboard opens, it shrinks the visible viewport, so a fixed footer positioned with vh can end up floating in the middle of the screen. Using dvh, or positioning relative to the layout rather than the viewport, avoids it.

The constraint is the CPU, not the connection

The original version of this advice said mobile users have slower connections, which is increasingly untrue and was never the whole story. On a mid-range Android phone, the binding constraint is usually processing.

JavaScript has to be downloaded, parsed, compiled and executed, and the last three scale with device capability rather than bandwidth. A bundle that costs 200 milliseconds of main-thread work on a development laptop can cost well over a second on a £150 phone. That gap is what Interaction to Next Paint exposes, and it is why so many sites that score well in a lab test perform poorly in field data.

What follows from that: ship less JavaScript rather than compressing more of it, since compression reduces transfer size but not parse and execute cost. Break up long tasks so the main thread can respond to input between them. Be sceptical of any library whose job a platform feature could do, which is now a longer list than it used to be. And keep third-party scripts under review, because they are frequently the largest source of main-thread blocking and the least examined.

Test on throttled CPU by default, not as an occasional exercise. Chrome DevTools' 4x or 6x CPU throttling more closely approximates a real mid-range phone than anything you will see unthrottled.

Scrolling, gestures and motion

A few things that reliably annoy people on touch devices.

Scroll chaining, where scrolling to the end of a modal continues scrolling the page behind it, is fixed with one declaration:

.modal-body { overscroll-behavior: contain; }

Custom scroll behaviour is generally worth avoiding. Momentum scrolling is deeply tuned per platform, and users have precise expectations, so scroll hijacking that feels novel on desktop feels broken on a phone.

scroll-snap is the exception worth using, because it is native, smooth and does what carousels used to need JavaScript for.

And motion needs an opt-out. Vestibular sensitivity is real, and parallax and large transitions are worse on a handheld screen:

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

Typography, updated

The original recommended Arial or Helvetica. That advice predates every phone in use today, and neither is the right default now.

Use the system font stack unless you have a real branding reason not to, because it renders in the platform's own optimised text engine, costs no download, and looks native. If you do load a web font, use font-display: swap so text is readable during the download, and preload the one font actually used above the fold.

For sizing, fluid type removes most breakpoint work:

body {
  font-size: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  line-height: 1.6;
}

Keep line length in the 45 to 75 character range, which on a phone usually means a single column with generous side padding rather than anything clever. And never suppress zoom with user-scalable=no or a maximum-scale under 5, which breaks a fundamental accessibility affordance for a cosmetic gain.

Testing, on the right things

Emulated mobile in a desktop browser catches layout problems and almost nothing else. It cannot tell you about touch accuracy, keyboard behaviour, momentum scrolling or real performance.

The things worth actually doing: test at 320px width, which is still the sensible floor and where overflow bugs surface. Test with the keyboard open, not just the form rendered. Test on a real mid-range Android device if you possibly can, because it is a different experience from a flagship. Test at multiple container widths rather than only viewport widths, since components that use container queries will not be exercised by resizing the window at all. Run through the flow one-handed, which surfaces problems no automated check will.

The testing tools piece covers how to automate what can be automated, and Playwright's mobile emulation with real WebKit is the closest you get without hardware.

Worth noting that window size is now genuinely dynamic rather than fixed at launch. Foldables change dimensions mid-session, and Android 17's resizability changes mean apps can be resized arbitrarily, which I cover in more detail when writing about testing for resizability. The web equivalent is straightforward but easy to forget: your layout must survive a width change at runtime, not only at load.

The short version

Design for constraints rather than devices, and let intrinsic layout find your breakpoints. Use svh and dvh instead of vh. Wrap env() safe area insets in max(). Make touch targets at least 44px including invisible hit area, and space them. Gate hover on (hover: hover). Set inputmode and autocomplete on every form field, and never let an input's font size drop below 16px. Assume the CPU is your constraint and ship less JavaScript accordingly.

None of that is about making a smaller version of a desktop site, which is the framing worth abandoning. It is about building for the device most people are actually holding.