Skip to content
Blog

Testing Light and Dark Mode with the Widget Previewer's Brightness Toggle

The Widget Previewer's light/dark toggle plus the brightness parameter makes theme-matrix testing a one-annotation job.

Published on • October 19, 2026

AI Assistant

Dark mode bugs are cheap to prevent and expensive to find in production: a hardcoded Colors.grey[800] on a white card, an icon that vanishes against a dark surface, a Text widget inheriting a theme color with contrast ratio nowhere near AA. The Flutter Widget Previewer — stable as of Flutter 3.47 — attacks this with two complementary tools: a one-click light/dark toggle in the preview toolbar, and a brightness parameter that turns dark-mode coverage into a static, reviewable matrix.

The toolbar toggle

Every preview instance has a control strip, and among zoom and hot restart sits “Toggle between light and dark mode,” which switches the preview’s theme between a light and dark color scheme. One click, no app relaunch, no waiting for a rebuild of anything else.

That’s the fast path for ad-hoc verification: you’re staring at a SettingsTile, flip the toggle, and immediately see that the secondary label was styled with a fixed color. But a toggle is manual. If you have forty components, nobody will toggle forty previews before every release — so the real value is in making brightness a declared property of your previews.

Declaring brightness with the annotation

The @Preview annotation’s brightness parameter sets the initial theme brightness, and you can stack multiple annotations on a single function to generate both variants at once:

import 'package:flutter/widget_previews.dart';
import 'package:flutter/material.dart';

@Preview(
  group: 'Brightness',
  name: 'Example - light',
  brightness: Brightness.light,
)
@Preview(
  group: 'Brightness',
  name: 'Example - dark',
  brightness: Brightness.dark,
)
Widget buttonPreview() => const ButtonShowcase();

Now the previewer shows two instances, both visible in search and grouped under Brightness. Reviewing a component’s dark-mode support becomes scanning the group, not remembering to toggle anything. And because the preview name is searchable and filterable by group name, you can jump straight to every dark variant in the project.

Scaling to a design system with MultiPreview

Hand-stacking two annotations per component gets old. The MultiPreview class exists for exactly this: extend it once, reuse it everywhere.

/// Creates light and dark mode previews.
final class MultiBrightnessPreview extends MultiPreview {
  const MultiBrightnessPreview();

  @override
  List<Preview> get previews => const [
        Preview(group: 'Brightness', name: 'Example - light', brightness: Brightness.light),
        Preview(group: 'Brightness', name: 'Example - dark', brightness: Brightness.dark),
      ];
}

@MultiBrightnessPreview()
Widget buttonPreview() => const ButtonShowcase();

One annotation, two previews — put it in your design-system package and every team consuming that package gets the brightness matrix for free.

When the constant context gets in your way, MultiPreview.transform() runs at runtime and can rewrite each generated preview — including names built from annotation arguments, which a const constructor can’t compute:

final class MultiBrightnessPreview extends MultiPreview {
  const MultiBrightnessPreview({required this.name});

  final String name;

  @override
  List<Preview> get previews => const [
        Preview(brightness: Brightness.light),
        Preview(brightness: Brightness.dark),
      ];

  @override
  List<Preview> transform() {
    final previews = super.transform();
    return previews.map((preview) {
      final builder = preview.toBuilder()
        ..group = 'Brightness'
        // Names from annotation values aren't possible in a
        // const constructor, but are fine when building at runtime.
        ..name = '$name - ${preview.brightness!.name}';
      return builder.toPreview();
    }).toList();
  }
}

@MultiBrightnessPreview(name: 'Example')
Widget buttonPreview() => const ButtonShowcase();

When your widget follows the system

There’s a subtlety the docs’ own example surfaces: some widgets don’t take a fixed brightness — they adapt to MediaQuery.platformBrightnessOf(context). For those, a hard-coded brightness: in the annotation fights the widget. Instead, teach your preview theme to defer to the environment:

final class PreviewMaterialThemeData extends PreviewThemeData {
  const PreviewMaterialThemeData({this.brightness});

  final Brightness? brightness;

  @override
  Widget apply(BuildContext context, Widget child) => Theme(
        data: ThemeData.from(
          colorScheme: ColorScheme.fromSeed(
            seedColor: Colors.lime,
            // Follow the preview's brightness, or the platform's.
            brightness: brightness ?? MediaQuery.platformBrightnessOf(context),
          ),
        ),
        child: child,
      );
}

The theme parameter takes a function returning PreviewThemeData, and the docs note that PreviewThemeData supports sequential theme layering for complex matrix testing — you can layer generic theming under a custom one, which is how you test “our brand theme, on both brightnesses, at large text scale” without combinatorial annotation soup. Pair it with textScaleFactor in the same annotation and you’re testing two accessibility dimensions per component with zero manual clicking.

Custom annotations tie it together

To make brightness discipline stick across a team, wrap the defaults in a project annotation. Extending Preview lets you default theme, wrapper, group, and brightness:

final class const MyCustomPreview({
  super.name,
  super.group,
  super.size,
  super.textScaleFactor,
  super.wrapper,
  super.brightness,
  super.localizations,
}) extends Preview {
  const MyCustomPreview() : theme: PreviewMaterialThemeData.new;
}

With that in place, adding dark-mode coverage for a new widget is a single @MyCustomPreview(name: 'New Tile') line — and the dark variant exists from the moment the widget does.

A practical checklist

  • Toggle dark mode on every new preview at least once; if anything looks wrong, fix it before the preview gets committed as “done.”
  • Move repeated previews to a MultiPreview subclass so brightness variants are declarative.
  • Use wrapper to inject the same InheritedWidget state your real app provides — theme bugs often appear only with real data.
  • Filter previews by group (Brightness) during design review to see the full matrix at a glance.
  • Remember the previewer’s other rules still apply: preview callbacks must be public and constant, dart:io/dart:ffi calls throw in the web-based environment, and unconstrained widgets are auto-clamped (use size for honest results).

Dark mode used to be a release-week audit. With the Widget Previewer’s toggle for spot checks and MultiPreview for the permanent matrix, it becomes a property your component library either has — or visibly doesn’t.

Sources