Dark mode
Mihr UI handles dark mode automatically through semantic token remapping. You never need to write conditional color logic — the same token names resolve to appropriate values in each mode.
Dark mode does not simply invert colors. It uses carefully tuned shade mappings optimized for contrast, readability, and visual comfort on dark backgrounds.
How it works
Pass MihrTheme.dark() as the darkTheme and Flutter handles the switch based on system brightness or your themeMode setting.
MaterialApp(
theme: MihrTheme.light(config: MihrThemeConfig(brand: myBrand)),
darkTheme: MihrTheme.dark(config: MihrThemeConfig(brand: myBrand)),
themeMode: ThemeMode.system, // or .light / .dark
);Both themes share the same brand/error/warning/success palettes. The key difference is the neutral gray palette and the shade mappings within each semantic extension.
Gray vs GrayDark
Mihr UI ships two gray palettes:
MihrColors.gray— Default neutral for light mode. Slightly cool-toned.MihrColors.grayDark— Dark-mode-optimized neutral with adjusted contrast ratios for readability on dark backgrounds. Lighter mid-tones, deeper darks.
In dark mode factories, the gray parameter defaults to MihrColors.grayDark automatically. You can override it with any ColorScale.
Shade shift rules
Dark mode remaps shades according to these general patterns:
shade600 to shade400 (lighter on dark bg)white becomes grayDark-950; subtle tints (50, 100) become deep darks (900, 950)shade600 in both modes, with hover shifting ±100| Token | Light | Dark | Pattern |
|---|---|---|---|
text.primary | gray-900 | grayDark-50 | Near-black → near-white |
bg.primary | white | grayDark-950 | White → near-black |
text.errorPrimary | error-600 | error-400 | 600 → 400 (lighter for dark bg) |
fg.brandPrimary | brand-600 | brand-500 | 600 → 500 (slight lighten) |
bg.primaryHover | gray-50 | grayDark-800 | Hover: +shade (darker) → -shade (lighter) |
border.primary | gray-300 | grayDark-700 | Mid-light → mid-dark |
bg.brandPrimary | brand-50 | brand-500 | Tint → mid-tone |
bg.brandSection | brand-800 | grayDark-900 | Brand → gray (dark bg section) |
Brand → Gray fallback
In dark mode, brand-tinted text tokens fall back to neutral gray. This is intentional — colored text on dark backgrounds often fails WCAG AA contrast requirements, so brand text becomes neutral white/gray for readability.
Brand solid fills (buttons, toggles) keep their color. Only text and subtle foreground tokens switch to gray.
| Token | Light | Dark | Behavior |
|---|---|---|---|
text.brandPrimary | brand-900 | grayDark-50 | Brand heading → neutral white |
text.brandSecondary | brand-700 | grayDark-300 | Brand accent → neutral gray |
text.brandTertiary | brand-600 | grayDark-400 | Brand tint → neutral gray |
fg.brandPrimaryAlt | brand-600 | grayDark-300 | Brand icon → neutral |
fg.brandSecondaryAlt | brand-500 | grayDark-600 | Brand accent → subtle neutral |
border.brandAlt | brand-600 | grayDark-700 | Brand border → neutral border |
bg.brandSection | brand-800 | grayDark-900 | Brand section → dark neutral |
The _alt suffix
Tokens ending with Alt swap their source between light and dark mode:
bg.primaryAlt: Light →white, Dark →grayDark-900(primary shifts to secondary)bg.secondaryAlt: Light →gray-50, Dark →grayDark-950(secondary shifts to primary)fg.brandPrimaryAlt: Light →brand-600, Dark →grayDark-300(brand shifts to gray)bg.brandPrimaryAlt: Light →brand-50, Dark →grayDark-900(brand bg shifts to neutral bg)
Use _alt tokens when you need a component to de-emphasize its brand identity in dark mode (e.g., footer banners, marketing sections, active tabs).
Utility color inversion
UtilityColors (used for badges, tags, and charts) invert their shade positions in dark mode:
This ensures light badge backgrounds become dark and vice versa, while maintaining appropriate contrast in each mode. shade500 stays the same as the midpoint.
Alpha color swap
AlphaColors swap their base colors in dark mode:
whitetokens: Light →#FFFFFF, Dark →#0C0E12(gray-950)blacktokens: Light →#000000, Dark →#FFFFFF
This ensures overlays and scrims maintain correct visual weight — a “white overlay” creates a lightening effect in light mode and a darkening effect in dark mode.
Example comparison
The same widget code produces correct results in both modes without any conditional logic:
Container(
decoration: BoxDecoration(
color: context.bgColors.secondary,
border: Border.all(color: context.borderColors.secondary),
borderRadius: MihrRadius.borderXl,
),
child: Column(
children: [
Text(
'Title',
style: MihrTypography.textLg.semibold.copyWith(
color: context.textColors.primary,
),
),
Text(
'Description',
style: MihrTypography.textSm.regular.copyWith(
color: context.textColors.tertiary,
),
),
Icon(Icons.check, color: context.fgColors.successPrimary),
],
),
);