Flutter enables you to deploy a single application across phones, tablets, desktops, and the web. The challenge lies in designing an interface that feels purposeful on each platform.
A phone layout stretched across a desktop window wastes space. A dense desktop dashboard squeezed onto a phone becomes hard to use. A truly adaptive interface needs more than flexible widths—it needs the ability to choose a different composition when the available space changes.
Material Foundation is a lightweight Flutter package built for exactly that job. To show how it works, it provides two widgets:
DynamicLayoutBuilder
Selects a mobile, tablet, or desktop widget based on the available screen width.DynamicScaffold
Puts that same adaptive behavior inside aSafeAreaandScaffold.
In this tutorial, we will use both widgets to build an interface that responds cleanly across mobile, tablet, and desktop screen sizes.
How the package works
Material Foundation uses the constraints supplied by Flutter's LayoutBuilder. By default, it selects layouts at these boundaries:
| Available width | Layout | Breakpoint Parameter |
|---|---|---|
| Less than 740 px | Mobile | maxMobileWidth |
| 740–1199 px | Tablet | maxTabletWidth |
| 1200 px or wider | Desktop | minDesktopWidth |
When the available screen width changes, the builder runs again and displays the appropriate widget.
All boundary values are customizable, so the package can follow your product's content and design system rather than forcing every app into the defaults.
Add Material Foundation to your project
Add the package to pubspec.yaml:
dependencies:
flutter:
sdk: flutter
material_foundation:
git:
url: https://github.com/DylanScottMickelson/material_foundation.git
Then fetch the dependency:
flutter pub get
Import the widget that best fits the level at which you want to adapt the interface:
import 'package:material_foundation/dynamic_layout_builder.dart';
import 'package:material_foundation/dynamic_scaffold.dart';
Build an adaptive screen with DynamicScaffold
The quickest way to create a full adaptive page is to supply three bodies to DynamicScaffold:
import 'package:flutter/material.dart';
import 'package:material_foundation/dynamic_scaffold.dart';
void main() {
runApp(const MaterialFoundationDemo());
}
class MaterialFoundationDemo extends StatelessWidget {
const MaterialFoundationDemo({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Adaptive Dashboard',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const DashboardPage(),
);
}
}
class DashboardPage extends StatelessWidget {
const DashboardPage({super.key});
@override
Widget build(BuildContext context) {
return DynamicScaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
mobileBody: const MobileDashboard(),
tabletBody: const TabletDashboard(),
desktopBody: const DesktopDashboard(),
);
}
}
Each body is a normal Flutter widget. That keeps the adaptive decision separate from the layout details and makes each version easy to read, test, and evolve.
Here is a compact mobile layout with a single-column list:
class MobileDashboard extends StatelessWidget {
const MobileDashboard({super.key});
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
Text('Dashboard', style: TextStyle(fontSize: 28)),
SizedBox(height: 16),
MetricCard(label: 'Active users', value: '1,284'),
MetricCard(label: 'Conversion', value: '8.6%'),
MetricCard(label: 'Revenue', value: '\$24.8K'),
MetricCard(label: 'Sessions', value: '9,412'),
],
);
}
}
On tablets, the same information can use a two-column grid:
class TabletDashboard extends StatelessWidget {
const TabletDashboard({super.key});
@override
Widget build(BuildContext context) {
return GridView.count(
padding: const EdgeInsets.all(24),
crossAxisCount: 2,
childAspectRatio: 2,
children: const [
MetricCard(label: 'Active users', value: '1,284'),
MetricCard(label: 'Conversion', value: '8.6%'),
MetricCard(label: 'Revenue', value: '\$24.8K'),
MetricCard(label: 'Sessions', value: '9,412'),
],
);
}
}
Desktop screens have enough room for navigation beside the content:
class DesktopDashboard extends StatelessWidget {
const DesktopDashboard({super.key});
@override
Widget build(BuildContext context) {
return Row(
children: [
NavigationRail(
selectedIndex: 0,
destinations: const [
NavigationRailDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: Text('Dashboard'),
),
NavigationRailDestination(
icon: Icon(Icons.analytics_outlined),
label: Text('Analytics'),
),
],
),
const VerticalDivider(width: 1),
Expanded(
child: GridView.count(
padding: const EdgeInsets.all(32),
crossAxisCount: 3,
childAspectRatio: 2,
children: const [
MetricCard(label: 'Active users', value: '1,284'),
MetricCard(label: 'Conversion', value: '8.6%'),
MetricCard(label: 'Revenue', value: '\$24.8K'),
MetricCard(label: 'Sessions', value: '9,412'),
],
),
),
],
);
}
}
The MetricCard widget can remain completely unaware of breakpoints:
class MetricCard extends StatelessWidget {
final String label;
final String value;
const MetricCard({
required this.label,
required this.value,
super.key,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(label, style: Theme.of(context).textTheme.labelLarge),
const SizedBox(height: 8),
Text(value, style: Theme.of(context).textTheme.headlineMedium),
],
),
),
);
}
}
This structure adapts the composition, not just the size. Mobile users receive a scrollable list, tablet users get a grid, and desktop users gain persistent side navigation.
Build an adaptive section or widget with DynamicLayoutBuilder
Not every responsive decision belongs at the page level. You may want the same scaffold and navigation everywhere while changing only a single form, toolbar, or content region.
Use DynamicLayoutBuilder directly for those cases:
class DynamicSummary extends StatelessWidget {
const DynamicSummary({super.key});
@override
Widget build(BuildContext context) {
return DynamicLayoutBuilder(
const MobileSummary(),
const TabletSummary(),
const DesktopSummary(),
);
}
}
Choose boundary breakpoints that fit your content
The default boundaries are convenient, but ultimately you should define breakpoints where your layout stops working well. Both widgets allow you to provide your own mobile, tablet, and desktop thresholds:
DynamicScaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
mobileBody: const MobileDashboard(),
tabletBody: const TabletDashboard(),
desktopBody: const DesktopDashboard(),
maxMobileWidth: 600,
maxTabletWidth: 999,
minDesktopWidth: 1000,
)
With these values, widths below 600 use mobile, widths from 601 through 999 use tablet, and widths of 1000 or more use desktop.
Think of maxMobileWidth as where the mobile layout ends, maxTabletWidth as where the tablet layout ends, and minDesktopWidth as where the desktop layout begins.
Preview and test layouts with an explicit width
Both widgets also accept a width value. When supplied, that value takes priority over the available constraint. This is handy for deterministic previews and widget tests:
const DynamicLayoutBuilder(
Text('Mobile'),
Text('Tablet'),
Text('Desktop'),
width: 1200,
)
The example above always builds the desktop widget.
In production screens, omit width so the interface reacts naturally to its parent. In tests, use it to exercise exact boundary values such as 739, 740, 1199, and 1200 without changing the simulated window size.
A few practical details
DynamicScaffold supports drawer and endDrawer, so you can still use standard Flutter navigation patterns. It also exposes resizeToAvoidInset. The package currently defaults that behavior to false; set it to true on screens with text fields if the body should resize when the keyboard appears.
Keep state above the adaptive branch when users should retain it while resizing. For example, a selected filter or loaded data belongs in the page's state management layer, while the mobile, tablet, and desktop widgets should focus on presenting that shared state differently.
Finally, do not feel obligated to make every layout unique. Reuse small components—cards, charts, buttons, and data models—and change only the composition that benefits from the available space.
Wrapping up
The package does not try to replace Flutter's layout system. It adds a focused layer that makes three-layout interfaces straightforward to organize.
Use
DynamicScaffoldto switch complete page bodies.Use
DynamicLayoutBuilderto adapt a single region or component.Keep the default 740 px and 1200 px boundaries, or replace them with values that suit your content.
Use the explicit
widthoverride for predictable previews and tests.
Explore the Material Foundation repository, try the included example application, leave a comment below and let me know what you think, or consider contributing a pull request if you have ideas for the project.

Top comments (0)