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).colorSchemeso both brightness modes work without a second palette. - Use semantic roles —
primary,surface,onSurfaceVariant— instead of literalColors.grey. Theme.of(context)triggers a rebuild when the theme changes; call it inbuild, never ininitState.- 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!),
],
),
);
}
}| Width | Class | Typical layout |
|---|---|---|
| < 600 | Compact | Single column, bottom navigation |
| 600 - 839 | Medium | Two columns, navigation rail |
| ≥ 840 | Expanded | List 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.Related
Local persistence: shared_preferences, sqflite, Drift and Hive Performance, images and animations
Last refreshed 2026-09-18.