+1 (415) 843-4662

Adaptive layouts for Ionic apps: tablets, foldables, split-screen and desktop

Phones are no longer the only screen your Ionic app ships to. Google Play grades apps against large-screen quality guidelines and surfaces those grades in tablet and Chromebook search results; foldables change window size mid session; iPadOS multitasking can hand your app a third of the screen without warning. An Ionic app that was only ever laid out at 390x844 looks broken in all three places — stretched lists, a nav stack that wastes two thirds of the width, and modals that fill a 13-inch display.

This tutorial covers the layout work we do on Ionic + Capacitor apps to make them genuinely adaptive: breakpoint strategy, ion-split-pane, adaptive modals, foldable posture, keyboard and pointer input, and how to test it before a store reviewer does. Examples are Ionic Angular; the components and CSS are identical in React and Vue.

1. Stop designing for devices, start designing for window classes

Do not branch on Capacitor.getPlatform() or on a device list. The window your app gets is independent of the hardware: a tablet in split-screen is phone-width, and a Chromebook window can be resized to anything. Use three window classes, matched to Ionic's breakpoints:

ClassWidthIonic breakpointLayout
Compact< 768pxmd and belowSingle pane, tabs or stack
Medium768-1023pxmdSplit pane, collapsible
Expanded>= 1024pxlg / xlSplit pane pinned, wide forms

A tiny service that publishes the class, so components do not each re-invent media queries:

import { Injectable, signal } from '@angular/core';

export type WindowClass = 'compact' | 'medium' | 'expanded';

@Injectable({ providedIn: 'root' })
export class WindowClassService {
  readonly windowClass = signal<WindowClass>(this.classify(window.innerWidth));

  constructor() {
    const mqMedium = window.matchMedia('(min-width: 768px)');
    const mqExpanded = window.matchMedia('(min-width: 1024px)');
    const update = () => this.windowClass.set(this.classify(window.innerWidth));
    mqMedium.addEventListener('change', update);
    mqExpanded.addEventListener('change', update);
  }

  private classify(w: number): WindowClass {
    if (w >= 1024) return 'expanded';
    if (w >= 768) return 'medium';
    return 'compact';
  }
}

Listen to matchMedia change events, not resize: on foldables and in Stage Manager the resize storm fires dozens of times per drag, and re-laying out on each one drops frames.

2. Split pane as the default shell

ion-split-pane is the single highest-value change. With when="md" it collapses to a normal stacked nav below 768px and shows a persistent side pane above it — one component tree, two layouts.

<ion-split-pane contentId="main" when="md">
  <ion-menu contentId="main" type="overlay">
    <ion-header><ion-toolbar><ion-title>Projects</ion-title></ion-toolbar></ion-header>
    <ion-content>
      <ion-list>
        <ion-item
          *ngFor="let p of projects()"
          [routerLink]="['/projects', p.id]"
          routerLinkActive="selected"
          [detail]="false">
          {{ p.name }}
        </ion-item>
      </ion-list>
    </ion-content>
  </ion-menu>

  <ion-router-outlet id="main"></ion-router-outlet>
</ion-split-pane>

Two things people get wrong here:

  • Keep routing as the source of truth. The list item navigates; the detail pane is just the outlet. If you instead hold selectedProject in the shell, deep links and back-button behaviour diverge between layouts.
  • Give the expanded layout a real empty state. On a phone the user always arrives at a detail page. On a tablet they land with the outlet empty, so route /projects to a "select a project" placeholder rather than a blank white pane.

For a three-column layout (list / detail / inspector), nest a second ion-split-pane inside the detail page with when="(min-width: 1200px)" instead of trying to manage a CSS grid by hand.

3. Constrain content width, do not stretch it

The default failure mode on a wide window is a form whose inputs are 1200px wide. Cap the measure and centre it:

.page-measure {
  width: 100%;
  max-width: 680px;
  margin-inline: auto;
  padding-inline: 16px;
}

@media (min-width: 1024px) {
  .card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
    gap: 16px;
  }
}

Prefer auto-fill grids over ion-grid size attributes for card lists: the grid reflows correctly at any width, including the awkward 600-700px windows you get in split-screen, without you enumerating breakpoints.

If your long lists use a virtualiser, recompute item size on window-class change — a fixed item height tuned on a phone leaves gaps once cards go multi-column.

4. Adaptive modals and sheets

A full-screen modal on a 13-inch screen is jarring; a floating card modal on a phone is worse. Ionic gives you both: ion-modal renders as a centred card above md automatically, but sheet modals and popovers need a decision.

import { ModalController } from '@ionic/angular/standalone';

async openEditor(project: Project) {
  const compact = this.windowClass.windowClass() === 'compact';
  const modal = await this.modalCtrl.create({
    component: ProjectEditorComponent,
    componentProps: { project },
    // Phone: bottom sheet with a half-height stop. Tablet/desktop: card modal.
    breakpoints: compact ? [0, 0.5, 1] : undefined,
    initialBreakpoint: compact ? 0.5 : undefined,
    cssClass: compact ? undefined : 'wide-card-modal',
  });
  await modal.present();
}
.wide-card-modal {
  --width: 720px;
  --height: 80%;
  --border-radius: 12px;
}

Same rule for action sheets: on compact use ion-action-sheet, on expanded prefer ion-popover anchored to the button that opened it, because a sheet sliding up from the bottom of a large window is 900px away from where the user is looking.

5. Foldables: posture, hinges and configuration changes

Two concrete problems on foldables. First, the app is recreated or resized when the device unfolds — if you are holding UI state outside the router or a store, it is gone. Test this explicitly; it is the same class of bug as an Android configuration change.

Second, content can land under the hinge. The CSS viewport segments feature exposes the fold to the WebView on supporting Android builds, and the horizontal-viewport-segments media query lets you avoid it:

@media (horizontal-viewport-segments: 2) {
  .split-layout {
    display: grid;
    /* left segment | hinge gutter | right segment */
    grid-template-columns: env(viewport-segment-width 0 0) auto 1fr;
  }
  .hinge-gutter { width: 100%; }
}

Where the query is unsupported the layout falls back to the width-based rules from section 1, which is the right default — so treat segment support as an enhancement, never a requirement. Keep the fold-aware CSS in one file so it is easy to verify and easy to delete if the app never ships on foldables.

Also confirm android:resizeableActivity is not disabled and remove any leftover screenOrientation="portrait" lock in AndroidManifest.xml. A rotation lock is the most common reason a Play large-screen quality check fails, and it makes the app letterbox on tablets.

6. Hardware keyboard and pointer

On a Chromebook, an iPad with a Magic Keyboard, or a desktop build, your app gets real input devices. The cheap wins:

// Global shortcuts, registered in the shell component.
@HostListener('document:keydown', ['$event'])
onKeydown(e: KeyboardEvent) {
  const meta = e.metaKey || e.ctrlKey;
  if (meta && e.key === 'k') { e.preventDefault(); this.openSearch(); }
  if (e.key === 'Escape') { this.modalCtrl.dismiss().catch(() => {}); }
  if (e.key === '/' && !this.isTyping(e)) { e.preventDefault(); this.focusSearch(); }
}

Then:

  • Verify every interactive element is reachable by Tab and has a visible :focus-visible ring — Ionic ships one, so do not delete it in a CSS reset.
  • Add hover affordances behind @media (hover: hover) so phones do not get sticky hover states.
  • Keep tap targets at least 44x44px even when a pointer is present; users mix touch and trackpad on the same device.
  • Confirm text selection and right-click work on content users will want to copy. A global user-select: none is a desktop-hostile default.

7. Testing it before the store does

Add these to the release checklist — they take minutes and catch nearly everything:

  1. Android: run on a tablet or resizable emulator, then use split-screen at both ratios, then unfold a foldable emulator while a modal is open.
  2. Enable "force activities to be resizable" in Android developer options and repeat the pass.
  3. iPadOS: Slide Over, Split View at both widths, and Stage Manager with a manually resized window; rotate in each.
  4. Browser: ionic serve and drag the window slowly across 768px and 1024px, watching for layout thrash and lost scroll position.
  5. Screenshot the three window classes and keep them in the PR; layout regressions are far easier to catch visually than in unit tests.

If you run Playwright for E2E, pin a couple of specs to tablet and desktop viewports so a split-pane regression fails CI rather than a store review.

What this usually costs

For a typical Ionic app with 20-40 screens, retrofitting adaptive layout is one to three weeks: a day or two for the shell and split pane, most of the time in per-screen width caps and modal decisions, and a day for the device test matrix. Doing it as part of an Ionic 8/9 upgrade is cheaper than doing it separately, because you are already touching every page template.

If you want a second pair of eyes on the shell before you commit to it, our Ionic consultants do short adaptive-layout reviews — get in touch with a screenshot of your current tablet layout and we will tell you what we would change first.