Mihr UI logoMihr UI

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.

Dart
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:

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:

Text colors: Error/warning/success shift from shade600 to shade400 (lighter on dark bg)
Backgrounds: white becomes grayDark-950; subtle tints (50, 100) become deep darks (900, 950)
Hover states: Light mode goes +100 shade (darker); dark mode goes −100 shade (lighter)
Borders: Light uses light grays (200–300); dark uses dark grays (700–800)
Solid fills (brand/error): Typically stay at shade600 in both modes, with hover shifting ±100
TokenLightDarkPattern
text.primarygray-900grayDark-50Near-black → near-white
bg.primarywhitegrayDark-950White → near-black
text.errorPrimaryerror-600error-400600 → 400 (lighter for dark bg)
fg.brandPrimarybrand-600brand-500600 → 500 (slight lighten)
bg.primaryHovergray-50grayDark-800Hover: +shade (darker) → -shade (lighter)
border.primarygray-300grayDark-700Mid-light → mid-dark
bg.brandPrimarybrand-50brand-500Tint → mid-tone
bg.brandSectionbrand-800grayDark-900Brand → 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.

TokenLightDarkBehavior
text.brandPrimarybrand-900grayDark-50Brand heading → neutral white
text.brandSecondarybrand-700grayDark-300Brand accent → neutral gray
text.brandTertiarybrand-600grayDark-400Brand tint → neutral gray
fg.brandPrimaryAltbrand-600grayDark-300Brand icon → neutral
fg.brandSecondaryAltbrand-500grayDark-600Brand accent → subtle neutral
border.brandAltbrand-600grayDark-700Brand border → neutral border
bg.brandSectionbrand-800grayDark-900Brand section → dark neutral

The _alt suffix

Tokens ending with Alt swap their source between light and dark mode:

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:

Light
shade50 → source.shade50
shade100 → source.shade100
shade200 → source.shade200
shade500 → source.shade500
shade700 → source.shade700
Dark (inverted)
shade50 → source.shade950
shade100 → source.shade900
shade200 → source.shade800
shade500 → source.shade500
shade700 → source.shade300

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:

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:

my_card.dart
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),
    ],
  ),
);
Light mode resolves to
bg: gray-50 (#FAFAFA)
border: gray-200 (#E9EAEB)
title: gray-900 (#181D27)
desc: gray-600 (#535862)
icon: success-600 (#079455)
Dark mode resolves to
bg: grayDark-900 (#13161B)
border: grayDark-800 (#22262F)
title: grayDark-50 (#F7F7F7)
desc: grayDark-400 (#94979C)
icon: success-500 (#17B26A)