Material Design 3 for Flutter
A Material Design 3 design contract for Flutter. Instead of documenting the M3
spec and hoping everyone follows it, this package expresses the spec as Dart types:
APIs take M3SpacingValue instead of double, so an off-scale value is a compile
error, not a design-review comment.
- π Live demo β every token, interactive
- π example/lib/main.dart β the whole API in one copy-pasteable file
- π Official M3 guidelines
Why
| Plain Flutter (anything compiles) | With the contract (only M3 compiles) |
|---|---|
EdgeInsets.all(17.3) |
const M3EdgeInsets.all(M3Spacings.s16) |
SizedBox(height: 14) |
const M3Gap(M3Spacings.s12) |
BorderRadius.circular(15) |
M3BorderRadius.medium β 12dp |
BoxShadow(blurRadius: 4) |
M3ElevationShadows.level2 |
Opacity(opacity: 0.35) |
M3Opacities.disabledContent β 38% |
TextStyle(fontSize: 15) |
M3TypeScale.bodyLarge β 16sp, 24 height |
Duration(milliseconds: 280) + guessed curve |
M3Motion.standard β 300ms + standard easing |
| hand-built focus border | M3FocusRing(child: β¦) β official 3dp ring, 3dp offset |
Everything is const, extension types are erased at compile time, and unused code
tree-shakes away β the contract costs nothing at runtime.
Install
dependencies:
material_design: ^1.7.0
Requires Flutter >=3.27.0 / Dart >=3.6.0 (uses Color.withValues and extension
types).
Quick start
import 'package:flutter/material.dart';
import 'package:material_design/material_design.dart';
MaterialApp(
// Merges the M3 type scale into your theme without losing its colors.
theme: M3TextTheme.applyToTheme(
ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4)),
),
),
home: const HomePage(),
);
Then build with tokens instead of numbers:
Card(
shape: M3Shape.medium, // 12dp corners
child: M3Padding(
padding: const M3EdgeInsets.all(M3Spacings.s16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('Title', style: M3TypeScale.titleMedium),
const M3Gap(M3Spacings.s8), // knows it's inside a Column
Text('Body', style: M3TypeScale.bodyMedium),
],
),
),
);
API tour
Each family below has a page in the live demo and a section in example/lib/main.dart, in this same order.
1. Spacing & layout
M3Spacingsβ the 4dp grid:none,s4β¦s128,infinity, plusM3Spacings.valuesfor galleries.M3Margins(16dp compact / 24dp elsewhere) andM3Spacers.pane(24dp).M3EdgeInsetsβall,symmetric,only,fromLTRB, every parameter anM3SpacingValue.M3EdgeInsetsPatternsships common recipes (card,dialog,listItem,compactPage,expandedPage).M3PaddingβPaddingthat only acceptsM3EdgeInsets.M3Gapβ a spacer that detects whether it sits in aRow,Column,Wrap, or scrollable and orients itself.M3GapUtils.addGaps(children, M3Spacings.s8)interleaves gaps into an existing list.
Column(
children: [
const Text('Header'),
const M3Gap(M3Spacings.s16), // vertical here, horizontal in a Row
const Text('Body'),
],
);
2. Shape & borders
The M3 shape scale has exactly seven stops: none 0 Β· extraSmall 4 Β· small 8 Β·
medium 12 Β· large 16 Β· extraLarge 28 Β· full (pill). Each shape type extends
its Flutter counterpart, so they drop in anywhere:
Card(shape: M3Shape.medium); // RoundedRectangleBorder
Container(
decoration: M3BoxDecoration(
borderRadius: M3BorderRadius.large, // BorderRadius
border: M3Border.thin(colorScheme.outline), // Border, 1dp
),
);
const M3BorderRadius.only(topLeft: M3Radius.large, topRight: M3Radius.none);
Border widths are their own scale β M3BorderWidths: none 0, thin 1, thick 2,
extraThick 4 β with named constructors M3BorderSide.thin/thick/extraThick(color).
3. Elevation & surfaces
M3Elevation is a composite token: each level carries its dp and its shadows.
M3Elevation.level2.dp; // 3.0
M3Elevation.level2.shadows; // ready-made shadow list
M3Elevation.level2.surfaceColor(context); // surface blended with tint at 3dp
colorScheme.surfaceAtElevation(M3Elevation.level2); // same, from the scheme
Levels: 0, 1, 3, 6, 8, 12 dp. Static shadow lists: M3ElevationShadows.level0β¦5.
M3ShapeDecoration pairs an M3Shape with those shadows for Container.decoration.
4. Typography
The 15 M3 styles as const TextStyles with exact spec metrics:
Text('Section', style: M3TypeScale.headlineSmall);
Text('Body', style: M3TypeScale.bodyMedium);
Emphasis is part of the scale, not a copyWith (M3 Expressive, experimental).
Each role has an emphasized counterpart at the same font size and line height, so
swapping it in never changes the vertical rhythm. Weight goes one step up (400β500,
or 500β700 for titles and labels); tracking moves only on the roles the spec adjusts,
so a line can end up marginally wider:
Text('Balance', style: M3TypeScale.titleMedium),
Text(r'R$ 12.480', style: M3EmphasizedTypeScale.headlineLarge),
// Or map any baseline style to its counterpart:
style: isSelected
? M3EmphasizedTypeScale.of(M3TypeScale.bodyLarge)
: M3TypeScale.bodyLarge,
M3TextTheme.applyToTheme(theme) merges them into a ThemeData (see Quick start).
M3TextUtils covers the runtime cases: clampedScaler (bounded text scaling β
last resort, it fights the user's accessibility setting), responsiveDisplay,
dyslexiaFriendly, mono, highContrast, withFontFamily.
4b. Icons
Material Symbols is a variable font with four axes, and Flutter exposes all four as bare doubles. Each one has its scale here:
M3IconSizesβ 20 Β· 24 Β· 32 Β· 40 Β· 48dp.M3IconWeightsβthin100 β¦regular400 β¦bold700, matching the text weight beside the icon.M3IconGradesβonDark-25,normal0,emphasis200. Grade adjusts stroke thickness without changing the icon's width, which is what makes it the axis for optical correction rather than restyling.M3IconFillsβunfilled0 /filled1. Fill is a state: M3 marks the selected destination by moving along this axis, not by swapping glyphs.M3IconOpticalSizesβ tracks the rendered size so stroke weight stays perceptually constant;forIconSize(size)clamps into the 20β48 axis.
M3IconStyle is an IconThemeData whose every axis is a token, so it drops into
any Flutter icon slot:
MaterialApp(
theme: ThemeData(iconTheme: M3IconStyle(color: colorScheme.onSurface)),
);
IconTheme.merge( // merge: keeps the inherited color
data: const M3IconStyle(
size: M3IconSizes.dense,
weight: M3IconWeights.medium,
),
child: const Icon(Icons.star),
);
// Or per icon β the axes are typed wherever they are passed.
Icon(Icons.star, size: M3IconSizes.large, fill: M3IconFills.filled);
Presets: M3IconStyle.standard, .dense, .selected (filled), .onDarkSurface
(grade -25), .disabled (38%). opticalSize defaults to size β the spec
behaviour β so you only pass it to deviate.
color is not defaulted: icon color is a color-scheme role, not an icon token.
Mind the Flutter behaviour behind that β an IconThemeData with a null color puts
Icon on its black fallback, so replacing ThemeData.iconTheme wholesale needs a
color, and restyling a subtree wants IconTheme.merge rather than IconTheme.
The three non-size axes are rendered by the Material Symbols variable font;
Flutter's bundled Icons font is static, so with it the values are carried but
not drawn. The tokens are the same either way β what changes is the font you
ship.
5. Color
Build a scheme with the variant and contrast level as tokens rather than raw values:
final scheme = M3ColorSchemes.fromSeed(
seedColor: brandPurple,
variant: M3SchemeVariant.expressive, // 9 spec variants
contrastLevel: M3ContrastLevels.high, // reduced / standard / medium / high
);
// Better: follow the user's own contrast and brightness settings.
final scheme = M3ColorSchemes.fromContext(context, seedColor: brandPurple);
Brand colors that must survive inside a seeded scheme get harmonized β a bounded hue shift in HCT, the spec algorithm, not an HSL approximation:
final green = M3ColorUtils.harmonize(brandGreen, scheme.primary);
// Or as the four M3 custom-color roles, carried through the theme:
ThemeData(extensions: [
M3ExtendedColors.of(
{'success': brandGreen, 'warning': brandAmber},
harmonizeWith: scheme.primary,
brightness: Brightness.light,
),
]);
final success = M3ExtendedColors.from(context)['success']!;
Text('Saved', style: TextStyle(color: success.onColorContainer));
Real HCT tonal palettes β the same math Material uses, via
material_color_utilities:
final palette = M3TonalPalette.fromSeed(const Color(0xFF6750A4));
palette[M3Tones.t40]; // light-scheme primary
palette[M3Tones.t80]; // dark-scheme primary
final core = M3CorePalette.fromSeed(seed);
core.neutral[M3Tones.t99]; // light surface
core.error[M3Tones.t40]; // spec red, independent of seed
On ColorScheme: stateLayerColor(base, M3InteractionState.hover),
disabledContent(base) (38%), disabledContainer(base) (12%),
surfaceAtElevation(level), isAccessible(fg, bg).
M3ColorUtils adds WCAG contrast math (calculateContrast, meetsWCAGAA/AAA,
adjustForAccessibility) and color manipulation.
Opacity tokens: M3Opacities (38/12/12/50%) and M3StateLayerOpacities
(hover 8%, focus 10%, pressed 10%, dragged 16%).
6. Interaction states & focus
M3StateLayer( // hover/focus/press/drag overlays, M3 precedence
overlayColor: colorScheme.onSurface,
borderRadius: M3BorderRadius.medium,
onTap: () {},
child: content,
);
M3FocusRing( // official 3dp ring at 3dp offset
borderRadius: M3BorderRadius.full,
child: IconButton(onPressed: () {}, icon: const Icon(Icons.star)),
);
M3FocusRing reserves its 6dp inset even when unfocused, so tabbing never shifts
the control; it observes focus and never takes it.
M3VisualDensity provides standard/comfortable/compact,
forPlatform(platform) and forScreenSize(size).
7. Motion
Duration and curve travel together β you never pair them by hand:
AnimatedContainer(
duration: M3Motion.emphasized.duration, // 500ms
curve: M3Motion.emphasized.curve, // emphasized easing
);
Springs (M3 Expressive, experimental). M3 Expressive added a physics model alongside durations: springs are interruptible and velocity-aware, so a gesture handed off mid-flight continues instead of restarting.
// Pick a scheme once; select by intent, never by number.
final spring = M3MotionScheme.expressive.spatial(M3MotionSpeed.fast);
controller.animateWith(
spring.simulation(start: controller.value, end: 1, velocity: flingVelocity),
);
AnimatedContainer(/* β¦ */); // or hand `spring.description` to a physics widget
spatial() springs move things and may overshoot; effects() springs change color
and opacity and never do. Schemes: M3MotionScheme.expressive (Material's default)
and .standard (utilitarian, minimal bounce). Speeds: slow, standard, fast.
Schemes: emphasized, emphasizedIncoming, emphasizedOutgoing, standard,
standardIncoming, standardOutgoing, linear. For const contexts use the flat
aliases (M3Motion.emphasizedDuration). Pick by intent with
M3Motion.durationFor(M3MotionDistance.long) /
M3Motion.curveFor(M3MotionType.incoming). Raw scales: M3MotionDuration.short1β¦extraLong4
(50β1000ms), M3MotionCurve.* (the official cubics).
8. Adaptive & responsive
M3ScreenSize is the M3 window-size-class selector, and everything else keys off it:
final size = M3ScreenSize.of(context); // compact / medium / expanded / large / extraLarge
size.columns; // 4 / 8 / 12
size.isAtLeast(M3ScreenSize.medium);
M3ResponsiveValue<int>(compact: 2, medium: 4, expanded: 6,
builder: (context, cols) => grid(cols));
M3ResponsiveVisibility(visibleOn: const [M3ScreenSize.expanded], child: sidebar);
M3ResponsiveScaffold(destinations: β¦); // bottom bar β rail β drawer, automatically
The three canonical layouts are ready-made, and each handles the compact case the way the spec says β which is not the same way for all three:
// List-detail: on a phone the detail *replaces* the list, and back returns.
M3ListDetailLayout(
list: MailList(onSelect: (id) => setState(() => _selected = id)),
detail: _selected == null ? null : MailDetail(_selected!),
onNavigateBack: () => setState(() => _selected = null),
);
// Supporting pane: on a phone the support *stacks below* β it is part of the
// same task, not a destination you navigate to.
M3SupportingPaneLayout(primary: Editor(), supporting: PropertiesPanel());
// Feed: columns, gutters and margins all follow the window size class.
M3FeedLayout(children: cards);
M3CanonicalLayout exposes the shared policy (displayModeOf, paneWidthFor) if you
need to make the same decision for a layout the package doesn't ship.
Breakpoints (M3Breakpoints: 0/600/840/1200/1600) are tokens too. M3Adaptive
bundles static helpers: responsiveLayout, adaptivePadding, adaptiveNavigation,
showAdaptiveDialog (fullscreen on phones, dialog on desktop), showAdaptiveSheet
(bottom sheet β side panel), adaptiveButton (48dp touch / 32dp mouse targets).
8b. Component measurements
The spec's per-component numbers, so a custom control lands on the same measurements as the built-in one beside it. Values only β this package does not ship M3 components; Flutter's Material library owns those.
M3ButtonHeights.medium; // 56dp β the five Expressive size classes
M3FabSizes.standard; // 56dp
M3AppBarHeights.large; // 152dp
M3NavigationSizes.extendedRailWidth; // 256dp
M3ListItemHeights.twoLine; // 72dp
Note that M3ButtonHeights.extraSmall (32dp) and .small (40dp) are visual
heights below the 48dp touch minimum β expand the tap area, not the box.
9. Accessibility
M3Accessibility.minTouchTarget(context); // 48dp touch, 32dp desktop
M3Accessibility.meetsContrastRequirement(foreground: fg, background: bg);
M3Accessibility.shouldReduceMotion(context);
M3Accessibility.adaptiveDuration(context: context, normal: d); // honors reduce-motion
M3AccessibilityConfig.fromContext(context).applyToTheme(theme) adapts a whole
theme to the user's contrast/motion/text-size settings.
10. M3 Expressive (experimental)
The 2025 M3 Expressive primitives, in the m3e module. Every symbol is prefixed
M3E and annotated @experimental: Material is still iterating on this upstream, so
the analyzer will tell you when you opt in.
-
M3ELoadingIndicator(+.contained()) β the morphing loading indicator that replaces most indeterminate spinners. -
M3EShapesβ the official 35-shape library (circleβ¦heart) asM3ERoundedPolygons, plusM3EMorphfor shape-to-shape animation andtoPath()to draw them. -
M3EShapeBorderβ puts those shapes anywhere Flutter takes aShapeBorder, and morphs for free: lerping two of them runs the real morph algorithm rather than crossfading outlines, so any implicit animation morphs.
// ignore_for_file: experimental_member_use
Card(shape: M3EShapeBorder(M3EShapes.cookie7Sided));
AnimatedContainer(
duration: M3Motion.emphasized.duration,
decoration: ShapeDecoration(
color: color,
shape: M3EShapeBorder(expanded ? M3EShapes.burst : M3EShapes.circle),
),
);
Breaking the contract, deliberately
Sometimes the design system is not the authority β a brand asset really is 18dp.
M3Contract is the one sanctioned way out:
M3EdgeInsets.all(M3Contract.spacing(18)) // off the 4dp grid, on purpose
Factories exist for every scale: spacing, corner, borderWidth, opacity,
iconSize, breakpoint, elevationDp, zIndex. Because every deviation names the
same identifier, compliance is measurable:
grep -rn 'M3Contract\.' lib/ | wc -l # how far has this app drifted from M3?
Zero-drift teams fail CI on any hit; migrating teams watch the number fall. Either
way deviations are visible β which beats an "unbreakable" contract with a silent
as-cast in it. (Extension types are erased at runtime, so a cast always compiles;
the package is honest about that instead of pretending otherwise.)
The rules behind the API
- Primitive replacement. Every scale is an extension type
(
M3SpacingValue,M3CornerValue, β¦) with a library-private constructor. No family quietly still takes a rawdouble. - No token that must be unwrapped. Scalar tokens are
static constvalues used directly β the oldM3SpacingToken.space16.valuepattern is gone; it costconst-ness at every call site. Enums remain only where they're right: composite tokens (M3Motion,M3Elevationβ two fields read together) and selectors (M3ScreenSize,M3InteractionStateβ they name a situation). - Deviation is explicit and greppable β
M3Contract, above.
What this package deliberately does not ship
M3 components. No buttons, menus, toolbars, split buttons, or FAB menus. Flutter's
Material library owns those, and a second FilledButton here would become migration
debt for every consumer the day Flutter changes its own. What the package ships
instead is the contract those components are built from β including their
measurements, in M3ButtonHeights and friends, so a control you build by hand lands
on the same numbers as the built-in one beside it.
The one exception is M3 Expressive widgets Flutter does not have yet
(M3ELoadingIndicator today). Each is a stopgap, marked @experimental, and gets
removed when Flutter ships the real thing β the 2025 Expressive components are
tracked in flutter/flutter#168813
and are deliberately not reimplemented here while that work is in flight.
Architecture
Nine modules, one-directional dependencies; import 'package:material_design/material_design.dart'
gives you all of them:
tokens βββ¬β> shape βββ¬β> interaction
ββ> layout ββ€
ββ> color βββ΄β> adaptive
ββ> typography
motion βββββββββββββββββ> interaction, adaptive
expressive (standalone)
Showcase
A card with hover overlays, keyboard focus, tokenized spacing and motion β compiled
and rendered by test/readme_showcase_test.dart, so this snippet cannot drift from
the API:
import 'package:flutter/material.dart';
import 'package:material_design/material_design.dart';
class PremiumCardShowcase extends StatelessWidget {
const PremiumCardShowcase({super.key});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return M3FocusRing(
borderRadius: M3BorderRadius.large,
child: M3StateLayer(
overlayColor: colorScheme.onSurface,
borderRadius: M3BorderRadius.large,
onTap: () {
// Action handler
},
child: AnimatedContainer(
duration: M3Motion.emphasized.duration,
curve: M3Motion.emphasized.curve,
padding: const M3EdgeInsets.all(M3Spacings.s24),
decoration: M3BoxDecoration(
color: colorScheme.surfaceAtElevation(M3Elevation.level1),
borderRadius: M3BorderRadius.large,
border: M3Border.all(
outlineColor: colorScheme.outlineVariant,
width: M3BorderWidths.thin,
),
boxShadow: M3ElevationShadows.level1,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'COMPILATION SAFE',
style: M3TypeScale.labelMedium.copyWith(
color: colorScheme.primary,
),
),
const M3Gap(M3Spacings.s8),
Text(
'Material 3 Contract Design',
style: M3TypeScale.titleLarge.copyWith(
color: colorScheme.onSurface,
),
),
const M3Gap(M3Spacings.s16),
Text(
'Every spacing, border width, opacity, and text style here '
'flows through an M3 token.',
style: M3TypeScale.bodyMedium.copyWith(
color: colorScheme.disabledContent(colorScheme.onSurface),
),
),
],
),
),
),
);
}
}
Versioning
1.0.x is young and has no deprecation baggage: API corrections ship as renames
with a migration table in the CHANGELOG. Upcoming work (Expressive
spring motion tokens, color scheme variants, emphasized type scale) follows the
same contract rules.
License
BSD 3-Clause β see LICENSE.
Libraries
- material_design
- A Material Design 3 contract for Flutter. Enforces M3 tokens system-wide, replacing free primitives with type-safe M3 equivalents.