Steven's Knowledge

Responsive & Adaptive UI

Designing one UI for many viewports — breakpoints, layout primitives, and cross-platform patterns on web, Flutter, and React Native

Responsive & Adaptive UI

A frontend application has to render the same content on a 5-inch phone, a 10-inch tablet, a 13-inch laptop, and a 32-inch monitor — sometimes with the same codebase. "Responsive" used to mean "adjusts to viewport width." It now also means: different input modalities (touch vs pointer), different platform conventions (iOS vs Android vs Web), different network conditions, different accessibility needs.

The discipline is to choose how much to adapt for each axis and at what cost. A single layout that works everywhere has a low ceiling; a custom layout per platform has a high one.

Responsive vs Adaptive

The terms get used interchangeably; the distinction is useful:

  • Responsive: one layout that flexes continuously with viewport size. Driven by relative units, fluid grids, and breakpoints that adjust rather than swap layouts.
  • Adaptive: discrete layouts chosen by breakpoint or platform. A mobile layout and a desktop layout that share no DOM tree.

Most production frontends use a mix. Reusable components are responsive; page-level layouts are often adaptive — a mobile screen and a desktop screen are different enough that forcing one to morph into the other costs more than it saves.

Breakpoints

Pick a small set and stick to them. The values matter less than the team using the same ones everywhere.

A common set (Tailwind defaults):

NameMin widthTypical device
sm640pxLarge phone, small tablet portrait
md768pxTablet portrait
lg1024pxTablet landscape, small laptop
xl1280pxLaptop, small desktop
2xl1536pxDesktop

Pitfalls:

  • Too many breakpoints. Six breakpoints means six things to test on every change. Three to five is the sweet spot.
  • Pixel-perfect targeting. "iPhone 12 is 390px, so let's break at 391px" — devices and orientations change, the break does not survive contact with reality. Pick semantic breaks (one column → two columns), not device-specific ones.
  • Min-width vs max-width inconsistency. Pick one direction (usually min-width / mobile-first) and use it throughout. Mixing both creates ranges that overlap or leave gaps.

Layout Primitives

The reliable units of responsive UI are not custom CSS for every screen — they are a small set of layout primitives composed together.

Stack and Inline

Two primitives cover most layouts:

  • Stack — children laid out vertically, equal spacing.
  • Inline (or Cluster) — children laid out horizontally, wrap when they overflow.

Almost every layout is some nesting of these. A header is an inline of a logo and a nav. A page is a stack of header, content, footer. A card is a stack of image, title, description, actions.

The implementation is one-liner per stack:

// CSS
.stack > * + * { margin-block-start: var(--space); }

// Tailwind
<div className="flex flex-col gap-4">

// Flutter
Column(crossAxisAlignment: CrossAxisAlignment.stretch,
  children: [...].divide(SizedBox(height: 16)))

// React Native
<View style={{ flexDirection: 'column', gap: 16 }}>

Grid

For two-axis layouts:

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: var(--space);
}

The auto-fit + minmax pattern is the entire trick: as the viewport widens, items per row increase; as it narrows, they decrease. No breakpoints needed for the grid itself.

Container queries

For component-level responsiveness, container queries are the right tool — they react to the size of the component's container, not the viewport.

.card-container { container-type: inline-size; }

.card { display: flex; flex-direction: column; }
@container (min-width: 400px) {
  .card { flex-direction: row; }
}

A card placed in a wide sidebar can render differently from the same card in a narrow column on the same page. Browser support is now widespread.

Web: Patterns That Work

Mobile-first, min-width breakpoints

/* Base: mobile */
.layout { flex-direction: column; }

/* Larger screens override */
@media (min-width: 768px) {
  .layout { flex-direction: row; }
}

Mobile-first is the convention because:

  • The base styles cover the most common (mobile) case.
  • Each breakpoint adds, rather than overrides.
  • Easier to reason about than max-width chains.

Fluid typography

:root {
  /* Scales from 1rem at 320px viewport to 1.25rem at 1280px */
  font-size: clamp(1rem, 0.875rem + 0.625vw, 1.25rem);
}

clamp(min, preferred, max) gives smooth scaling without breakpoints. Same pattern works for spacing, container widths, anything that should scale fluidly.

Touch-first interactive targets

Minimum touch target: 44×44 px (iOS HIG) or 48×48 px (Material). Hover states still work on touch devices but cannot be the only way to expose functionality.

button { min-block-size: 2.75rem; min-inline-size: 2.75rem; }

@media (hover: hover) {
  /* Hover-only enhancements */
  button:hover { background: var(--hover); }
}

Detecting capabilities, not devices

Don't sniff user agents. Query capabilities:

@media (hover: hover) { ... }
@media (pointer: fine) { ... }
@media (prefers-reduced-motion: reduce) { ... }
@media (prefers-color-scheme: dark) { ... }

User agent strings lie; capability queries describe what actually matters.

Flutter: Patterns That Work

LayoutBuilder and MediaQuery

MediaQuery tells you the size of the entire app surface; LayoutBuilder tells you the size of a parent constraint. Use LayoutBuilder for components, MediaQuery for app-level layout decisions.

LayoutBuilder(
  builder: (context, constraints) {
    if (constraints.maxWidth >= 600) {
      return WideLayout();
    } else {
      return NarrowLayout();
    }
  },
);
final width = MediaQuery.sizeOf(context).width;
// Use MediaQuery.sizeOf (Flutter 3.10+) over MediaQuery.of — it rebuilds only
// when size changes, not when other media data changes.

Breakpoint helpers

A simple extension keeps breakpoint logic in one place:

extension Breakpoints on BuildContext {
  double get width => MediaQuery.sizeOf(this).width;
  bool get isMobile  => width < 600;
  bool get isTablet  => width >= 600 && width < 1024;
  bool get isDesktop => width >= 1024;
}

// Usage
if (context.isDesktop) {
  return DesktopLayout();
}

Adaptive widgets

For platform-conventional UI, the flutter_adaptive_scaffold package and Material 3's adaptive widgets switch between layouts automatically. Manual approach:

Scaffold(
  body: Row(
    children: [
      if (context.isDesktop)
        const NavigationRail(...),
      const Expanded(child: ContentArea()),
    ],
  ),
  bottomNavigationBar: context.isMobile
      ? const BottomNavigationBar(...)
      : null,
);

The rule: pick the navigation pattern that matches the form factor (bottom nav on mobile, rail on tablet, drawer on desktop), not the same pattern stretched.

Avoid hardcoded sizes

// Bad: fixed at 200px on every device
Container(width: 200, child: ...)

// Better: relative
SizedBox(
  width: MediaQuery.sizeOf(context).width * 0.5,
  child: ...,
)

// Often best: let layout do the work
Expanded(child: ...)

Fixed pixel sizes that work on the development device break on smaller devices. Default to constraints from the parent; reach for explicit sizes only when necessary.

Safe area

Phones with notches and home indicators reserve space the app must avoid.

Scaffold(
  body: SafeArea(
    child: ...,
  ),
);

Scaffold handles it by default for its built-in app bar and bottom nav. Custom layouts must wrap in SafeArea or use MediaQuery.paddingOf(context) manually.

React Native: Patterns That Work

Dimensions vs useWindowDimensions

import { useWindowDimensions } from 'react-native';

function Layout() {
  const { width, height } = useWindowDimensions();
  const isWide = width >= 600;
  return isWide ? <WideLayout /> : <NarrowLayout />;
}

Always prefer useWindowDimensions over Dimensions.get('window'). The hook re-runs on rotation and split-screen; the static call does not.

Platform-specific code

Three idioms, in order of preference:

1. Platform.select for small differences:

const styles = StyleSheet.create({
  text: {
    fontSize: 14,
    fontFamily: Platform.select({ ios: 'San Francisco', android: 'Roboto' }),
  },
});

2. .ios.tsx / .android.tsx for larger differences:

components/
├── Header.tsx           // shared
├── Header.ios.tsx       // iOS override
└── Header.android.tsx   // Android override

The bundler picks the right file at build time. Use this when the implementations are substantially different.

3. Platform.OS checks for tiny conditionals:

if (Platform.OS === 'ios') {
  // ...
}

Use sparingly; if there are many checks in one file, switch to .ios.tsx / .android.tsx.

Safe area

import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context';

<SafeAreaProvider>
  <SafeAreaView edges={['top', 'bottom']} style={{ flex: 1 }}>
    <App />
  </SafeAreaView>
</SafeAreaProvider>

The community react-native-safe-area-context is more reliable than the built-in SafeAreaView and supports per-edge configuration.

Tablets and foldables

isTablet is not a built-in concept; derive from dimensions:

const TABLET_BREAKPOINT = 600;
const { width } = useWindowDimensions();
const isTablet = width >= TABLET_BREAKPOINT;

For foldables, listen for size changes; React Native's useWindowDimensions triggers re-renders correctly when the device folds/unfolds.

Cross-Platform Considerations

A senior engineer working on a cross-platform product has to make choices that affect all three:

One codebase or three?

ApproachCostCeiling
Single codebase, single layoutLowLow — feels generic
Single codebase, adaptive layoutsMediumMedium-high
Per-platform codebasesHighHigh — feels native everywhere

For Flutter and React Native, "single codebase, adaptive layouts" is the practical default: 80% of the code is shared, the platform-specific 20% is concentrated and visible.

Platform conventions matter

Don't ship the iOS UI on Android (or vice versa). Users notice:

  • Navigation patterns. iOS uses swipe-back; Android uses hardware/gesture back.
  • Lists. iOS uses grouped tables; Android uses flat lists with dividers.
  • Typography. Each platform has its own scale and weight conventions.
  • Date/time pickers. Use the native ones, not a custom JS implementation.
  • Modal patterns. Bottom sheets on Android, full-screen modals or action sheets on iOS.

For Flutter, Material widgets on Android, Cupertino on iOS — or Material 3 everywhere and accept a slight non-native feel on iOS. For RN, use platform-aware components from a UI kit (NativeBase, Tamagui) or write the adaptation yourself.

Web from a mobile app

If the product also has a web version, decide early:

  • Shared component library (Tamagui, Radix + a wrapper, custom design system) — the design tokens and primitives are reused; layouts differ.
  • Shared business logic only — the UI is rewritten per platform, shipped via a monorepo for shared services.

The first is more work upfront and a higher ceiling. The second ships faster and has a lower one. Most teams pick the second and regret it; few pick the first and regret it.

Testing Responsive UI

Component tests can render at multiple sizes:

test.each([
  { name: 'mobile',  width: 375 },
  { name: 'tablet',  width: 768 },
  { name: 'desktop', width: 1280 },
])('renders correctly at $name width', ({ width }) => {
  Object.defineProperty(window, 'innerWidth', { value: width });
  render(<Page />);
  // assertions...
});

For Flutter:

testWidgets('shows desktop layout at wide width', (tester) async {
  tester.view.physicalSize = const Size(1920, 1080);
  tester.view.devicePixelRatio = 1.0;
  addTearDown(tester.view.resetPhysicalSize);

  await tester.pumpWidget(MyApp());
  expect(find.byType(NavigationRail), findsOneWidget);
});

Visual regression (Chromatic, Percy, Flutter goldens) is the right tool for catching layout regressions automatically. Manual testing remains essential — automated tools cannot evaluate "this looks right."

Pre-Commit Checklist

For any change that touches layout:

  • Tested at the smallest supported viewport (typically 320×568 or smaller mobile).
  • Tested at the largest realistic viewport (laptop or desktop).
  • Touch targets ≥ 44×44 px on touch devices.
  • No fixed pixel sizes that break smaller screens.
  • Safe area handled on mobile.
  • Platform conventions followed (iOS swipe back, Android back button, etc.).
  • Reduced-motion preference respected if there are animations.
  • Dark mode (or relevant theme variants) tested if the app supports them.

On this page