+1 (415) 843-4662

Theming Ionic apps in 2026: design tokens, dark mode, Liquid Glass and Material You

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-transparency and prefers-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/tokens route 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.