Every Ionic upgrade conversation in 2026 eventually turns into a theming
conversation. iOS 26 moved the system UI to translucent "Liquid Glass"
materials, Android's Material 3 Expressive pushes wallpaper-derived dynamic
colour down to the app layer, and both platforms now assume your app honours
dark mode, high-contrast and larger text out of the box. Meanwhile most Ionic
codebases we inherit still theme with a variables.scss written in the Ionic
4 era, a pile of !important overrides, and a dark mode that is one media
query nobody has looked at in three years.
This tutorial rebuilds theming properly: one token layer, platform-adaptive surfaces, real dark mode with a user switch, Android dynamic colour through a small Capacitor plugin, and a glass treatment on iOS that degrades safely everywhere else. Examples are framework-agnostic CSS plus a little TypeScript, so they work in Ionic Angular, React and Vue.
1. Stop theming components. Theme tokens.
The mistake in almost every legacy Ionic theme is styling components
directly — ion-button { --background: #3f6fd8 } in forty places. Replace it
with two layers: primitives (raw values, never used directly in a
component) and semantic tokens (what the value means). Components only
ever read semantic tokens.
/* theme/primitives.css — raw palette, no meaning attached */
:root {
--brand-500: #3f6fd8;
--brand-600: #3159bb;
--neutral-0: #ffffff;
--neutral-50: #f6f7f9;
--neutral-900:#14161a;
--danger-500: #d6353b;
--radius-m: 12px;
--space-3: 12px;
}
/* theme/semantic.css — meaning, mapped to primitives */
:root {
--surface-page: var(--neutral-50);
--surface-card: var(--neutral-0);
--surface-raised: var(--neutral-0);
--text-primary: var(--neutral-900);
--text-muted: #5b6472;
--accent: var(--brand-500);
--accent-pressed: var(--brand-600);
--border-hairline: rgba(20, 22, 26, 0.12);
}
Then bind Ionic's own variables to the semantic layer once, in
theme/variables.scss, and never again:
:root {
--ion-background-color: var(--surface-page);
--ion-text-color: var(--text-primary);
--ion-color-primary: var(--accent);
--ion-color-primary-shade: var(--accent-pressed);
--ion-item-background: var(--surface-card);
--ion-toolbar-background: var(--surface-raised);
}
The payoff arrives in the next three sections: dark mode, dynamic colour and high contrast are now each a re-mapping of the semantic layer, not a sweep through your components.
2. Dark mode that a user can actually control
Ionic ships @ionic/core/css/palettes/dark.class.css, which applies dark
values when a .ion-palette-dark class is on <html>. Use the class
palette, not the media-query palette — you need a manual override for the
"Light / Dark / System" setting users expect.
// theme/palette.ts
import { Preferences } from '@capacitor/preferences';
import { StatusBar, Style } from '@capacitor/status-bar';
import { Capacitor } from '@capacitor/core';
export type PaletteChoice = 'light' | 'dark' | 'system';
const KEY = 'palette';
const query = window.matchMedia('(prefers-color-scheme: dark)');
function apply(dark: boolean) {
document.documentElement.classList.toggle('ion-palette-dark', dark);
// keep the native chrome in sync, or you get black text on a black bar
if (Capacitor.isNativePlatform()) {
StatusBar.setStyle({ style: dark ? Style.Dark : Style.Light }).catch(() => {});
}
// tells form controls, scrollbars and the WebView itself which scheme is active
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
}
export async function initPalette() {
const { value } = await Preferences.get({ key: KEY });
const choice = (value as PaletteChoice) ?? 'system';
apply(choice === 'dark' || (choice === 'system' && query.matches));
query.addEventListener('change', async (e) => {
const { value: cur } = await Preferences.get({ key: KEY });
if ((cur ?? 'system') === 'system') apply(e.matches);
});
}
export async function setPalette(choice: PaletteChoice) {
await Preferences.set({ key: KEY, value: choice });
apply(choice === 'dark' || (choice === 'system' && query.matches));
}
Call initPalette() before the first paint (in main.ts / main.tsx, awaited
ahead of mounting) or users see a white flash on every cold start. Then the
dark values are just the semantic layer again:
.ion-palette-dark {
--surface-page: #0e1013;
--surface-card: #171a1f;
--surface-raised: #1d2127;
--text-primary: #f2f4f7;
--text-muted: #a2abba;
--accent: #7fa2f0; /* lift brand colour for contrast on dark */
--border-hairline: rgba(255, 255, 255, 0.14);
}
Do not reuse the light brand colour on dark surfaces. #3f6fd8 on #0e1013
is about 3.4:1 — below the 4.5:1 WCAG 2.2 threshold we cover in our
accessibility audit tutorial.
Lifting it to #7fa2f0 gets you past 7:1.
3. Liquid Glass on iOS 26 — and a safe fallback
iOS 26's system surfaces are translucent and blur what is behind them. An
opaque toolbar now reads as distinctly "not a real iOS app". You can get very
close with backdrop-filter, which the iOS WebView supports, but only apply
it where there is genuinely content scrolling underneath:
/* glass only on iOS-mode Ionic, and only where the browser can blur */
@supports (backdrop-filter: blur(20px)) {
.ios .glass-toolbar {
--background: rgba(255, 255, 255, 0.72);
backdrop-filter: saturate(180%) blur(20px);
border-bottom: 0.5px solid var(--border-hairline);
}
.ios.ion-palette-dark .glass-toolbar {
--background: rgba(22, 24, 28, 0.66);
}
}
/* respect the accessibility setting that turns translucency off */
@media (prefers-reduced-transparency: reduce) {
.ios .glass-toolbar {
--background: var(--surface-raised);
backdrop-filter: none;
}
}
Three rules we apply on client projects:
- Never put glass over static content. Blur costs GPU on every frame; if nothing moves behind it, the user pays for nothing.
- Text on glass needs a contrast check against the worst-case backdrop, not against your mock. Screenshot the toolbar over your darkest photo.
- Honour
prefers-reduced-transparencyandprefers-reduced-motion. Both map to real iOS accessibility switches and both are cheap to support.
On Android, skip glass entirely. md mode should look like Material, not like
iOS wearing a costume — Ionic's platform modes exist precisely so you do not
have to ship one visual language to both stores.
4. Material 3 dynamic colour on Android
Android users expect app accents to pick up their wallpaper palette. The
system exposes those as @android:color/system_accent1_* resources; a
fifteen-line Capacitor plugin hands them to the WebView.
// android/app/src/main/java/com/example/DynamicColorPlugin.kt
package com.example
import android.os.Build
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
@CapacitorPlugin(name = "DynamicColor")
class DynamicColorPlugin : Plugin() {
@PluginMethod
fun getPalette(call: PluginCall) {
val res = JSObject()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
fun hex(id: Int) = String.format("#%06X", 0xFFFFFF and context.getColor(id))
res.put("supported", true)
res.put("accent", hex(android.R.color.system_accent1_600))
res.put("accentLight", hex(android.R.color.system_accent1_200))
res.put("neutral", hex(android.R.color.system_neutral1_50))
res.put("neutralDark", hex(android.R.color.system_neutral1_900))
} else {
res.put("supported", false)
}
call.resolve(res)
}
}
// theme/dynamic-color.ts
import { registerPlugin, Capacitor } from '@capacitor/core';
interface DynamicColorPlugin {
getPalette(): Promise<{ supported: boolean; accent?: string; accentLight?: string;
neutral?: string; neutralDark?: string }>;
}
const DynamicColor = registerPlugin<DynamicColorPlugin>('DynamicColor');
export async function applyDynamicColor(enabled: boolean) {
const root = document.documentElement;
if (!enabled || Capacitor.getPlatform() !== 'android') {
root.style.removeProperty('--accent');
return;
}
const p = await DynamicColor.getPalette().catch(() => ({ supported: false }));
if (!p.supported) return;
const dark = root.classList.contains('ion-palette-dark');
root.style.setProperty('--accent', (dark ? p.accentLight : p.accent)!);
}
Because components read --accent and nothing else, one setProperty call
retints the whole app. Two caveats: make it a setting the user can turn
off (brand teams have opinions), and never let dynamic colour drive
--danger or any status colour — a wallpaper-green error state is a bug.
If you have not written a plugin before, our
custom Capacitor plugin tutorial
walks through the project wiring in full.
5. Dynamic type, high contrast and reduced motion
The same token layer absorbs the remaining system preferences. Size in rem
against a root that scales, and let the platform preferences re-map tokens:
:root { font-size: 16px; }
@media (prefers-contrast: more) {
:root {
--text-muted: var(--neutral-900);
--border-hairline: rgba(20, 22, 26, 0.45);
}
.ion-palette-dark { --text-muted: #ffffff; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
On iOS, add <meta name="viewport" content="viewport-fit=cover"> plus
-webkit-text-size-adjust: 100%; and test at the largest accessibility text
size — layouts built on fixed px heights collapse there, and that is the
single most common accessibility rejection we see in review.
6. Keeping the theme honest
A design system rots without a guard. What we put in client repos:
- A
/dev/tokensroute rendering every semantic token as a swatch with its computed value in both palettes — reviewers can eyeball a whole theme change in one screenshot. - A stylelint rule banning hex literals outside
theme/primitives.css. It is the only thing that actually stops token drift. - Screenshot tests (Playwright, per our E2E testing tutorial) across four states: light, dark, high contrast, largest text.
- A contrast check in CI that walks the token pairs and fails the build below 4.5:1.
What this usually takes
On a mature Ionic app, extracting tokens and rewiring variables.scss is one
to two days. Dark mode with the user switch and native chrome sync is another
day. Glass, dynamic colour and the preference media queries land in a third.
The result is that the next OS design refresh is a token file edit instead of
a redesign — which is exactly why we do it before, not after, a major
platform release.
Want a second pair of eyes on your theme layer? Our Ionic consultants do fixed-scope theming audits, and getting in touch with a repo link is enough to start.