Material 3 theming and adaptive UI

Build a colour scheme from a seed, apply typography and component themes, switch to dark mode, and lay out one codebase for phone, tablet and desktop.

ThemeData and colour schemes

final seed = const Color(0xFF3B6EA5);

final lightTheme = ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(seedColor: seed),
  cardTheme: CardThemeData(
    elevation: 0,
    shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
  ),
);

final darkTheme = ThemeData(
  useMaterial3: true,
  colorScheme: ColorScheme.fromSeed(
    seedColor: seed,
    brightness: Brightness.dark,
  ),
);

class App extends StatelessWidget {
  const App({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: lightTheme,
      darkTheme: darkTheme,
      themeMode: ThemeMode.system,
      home: const HomePage(),
    );
  }
}
  • Read colours from Theme.of(context).colorScheme so both brightness modes work without a second palette.
  • Use semantic roles — primary, surface, onSurfaceVariant — instead of literal Colors.grey.
  • Theme.of(context) triggers a rebuild when the theme changes; call it in build, never in initState.
  • Component themes (CardThemeData, InputDecorationTheme) are where repeated styling belongs.

Breakpoints without a device list

class AdaptiveScaffold extends StatelessWidget {
  const AdaptiveScaffold({super.key, required this.body, this.detail});

  final Widget body;
  final Widget? detail;

  @override
  Widget build(BuildContext context) {
    final width = MediaQuery.sizeOf(context).width;
    final isWide = width >= 900;

    if (!isWide || detail == null) {
      return Scaffold(appBar: AppBar(title: const Text('Tasks')), body: body);
    }
    return Scaffold(
      body: Row(
        children: [
          SizedBox(width: 360, child: body),
          const VerticalDivider(width: 1),
          Expanded(child: detail!),
        ],
      ),
    );
  }
}
WidthClassTypical layout
< 600CompactSingle column, bottom navigation
600 - 839MediumTwo columns, navigation rail
≥ 840ExpandedList and detail side by side

Prefer MediaQuery.sizeOf(context) over reading the whole MediaQueryData: the *Of variants rebuild only when that specific value changes.

LayoutBuilder and text scaling

class TagWrap extends StatelessWidget {
  const TagWrap({super.key, required this.tags});

  final List<String> tags;

  @override
  Widget build(BuildContext context) {
    return LayoutBuilder(
      builder: (context, constraints) {
        final columns = (constraints.maxWidth / 140).floor().clamp(1, 6);
        return GridView.count(
          crossAxisCount: columns,
          shrinkWrap: true,
          physics: const NeverScrollableScrollPhysics(),
          childAspectRatio: 2.4,
          children: tags.map((t) => Chip(label: Text(t))).toList(),
        );
      },
    );
  }
}
💡
Test with a large system text scale. Fixed-height SizedBox wrappers and single-line labels clip first, so give rows a minimum height and let them grow with textScaler.

FAQ

Should I use <code>MediaQuery</code> or <code>LayoutBuilder</code>?
Use MediaQuery for screen-level breakpoints and platform padding. Use LayoutBuilder when the available width depends on a parent, such as a pane inside a split view.
How do I support dark mode without maintaining two palettes?
Derive both schemes from one seed colour with ColorScheme.fromSeed, then reference roles rather than raw colours everywhere. Two themes, one set of decisions.

Local persistence: shared_preferences, sqflite, Drift and Hive Performance, images and animations

Last refreshed 2026-09-18.