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:
| Class | Width | Ionic breakpoint | Layout |
|---|---|---|---|
| Compact | < 768px | md and below | Single pane, tabs or stack |
| Medium | 768-1023px | md | Split pane, collapsible |
| Expanded | >= 1024px | lg / xl | Split 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
selectedProjectin 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
/projectsto 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-visiblering — 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: noneis a desktop-hostile default.
7. Testing it before the store does
Add these to the release checklist — they take minutes and catch nearly everything:
- 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.
- Enable "force activities to be resizable" in Android developer options and repeat the pass.
- iPadOS: Slide Over, Split View at both widths, and Stage Manager with a manually resized window; rotate in each.
- Browser:
ionic serveand drag the window slowly across 768px and 1024px, watching for layout thrash and lost scroll position. - 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.