Enabling non-mobile targets starts with the Flutter channel. Web development requires the beta channel, reached either by downloading the latest beta from the SDK archive or by switching an existing install with $ flutter channel beta and $ flutter upgrade. Once there, run:
$ flutter config --enable-web
Desktop support sits at a far more experimental stage. Tooling for Linux and Windows is thin — plugin development is especially painful — and the APIs backing desktop targets are intended for proof-of-concept work rather than production. Release builds, which the web side gets through the tried-and-tested dart2js compiler, are not supported at all for Windows and Linux native desktop apps. macOS support is somewhat better than Windows and Linux, though still short of the web and nowhere near the maturity of the mobile platforms.
Desktop targets need the master channel instead, reached through the same steps as beta. Then, substituting linux, windows, or macos for <OS_NAME>, run:
$ flutter config --enable-<OS_NAME>-desktop
flutter doctorsurfaces problems and downloads any tools it is missing as a side effect.flutter upgradebrings the installation current.- Restarting the machine — the oldest tier-1 support answer — sometimes clears whatever blocks the tooling.
Building for browsers and desktops
Web support is in noticeably better shape, and the tooling reflects that. Running:
$ flutter devices
should immediately list an entry along these lines:
Web Server • web-server • web-javascript • Flutter Tools
An installed Chrome browser shows up as an entry too. If Chrome is present but absent from the device list, point the CHROME_EXECUTABLE environment variable at the Chrome executable. When the web server is the only connected device and the project is compatible, flutter run starts a server on localhost:<RANDOM_PORT>, making the app reachable from any browser.
On desktop, after support is enabled, flutter run -d <OS_NAME> runs the app natively on the development machine and flutter build <OS_NAME> produces binaries in the build directory, both using the same <OS_NAME> value as before. Either command presupposes a directory holding what Flutter needs to build for that platform. New projects get it automatically; for existing ones, flutter create . generates it. Because the Linux and Windows APIs are unstable, those platform directories may have to be regenerated if a Flutter update breaks the app.
What counts as a compatible project
A project builds for desktop or the web only when it avoids every plugin lacking a platform-specific implementation for the target in question. To be precise about terminology: a Flutter plugin is a kind of Flutter package that bundles platform-specific code needed for its features.
The url_launcher package, developed by Google, is safe to use anywhere — a useful property, since the web runs on hyperlinks. path_provider, also from Google, rules out web builds: it resolves the local storage path for saving files, which a web app has no use for anyway, though code depending on it must be changed to run on the web. shared_preferences is a workable choice, as it falls back to HTML localStorage on the web. The desktop side follows the same pattern but with fewer options: very few plugins are compatible with desktop platforms, and, as ever, more work is required there than on the web.
Responsive Foundations
Views that hide in drawers on a phone can sit permanently on the left on a wide screen. A ListView of navigation widgets is the usual building block; on a smartphone it typically lives inside a Drawer. BottomNavigationBar and TabBar paired with TabBarView are alternatives, but both demand more rework than the drawer, so the drawer wins here.
Whether a Drawer is passed to the Scaffold at all can be decided by reading MediaQuery.of(context) and comparing its width to a threshold suited to the app:
Scaffold(
appBar: AppBar(/* ... \*/),
drawer: MediaQuery.of(context).size.width < 500 ?
Drawer(
child: Menu(),
) :
null,
body: /* ... \*/
)
Laying out the body
The Scaffold's body follows the same split. Past a certain width it becomes a Row holding the fixed-width Menu and a Content widget that takes the rest; below it, the body is just Content. Content is the GridView shown earlier, kept in its own widget:
class Content extends StatelessWidget {
final List elements = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "A Million Billion Trillion", "A much, much longer text that will still fit"];
@override
Widget build(context) => GridView.builder(
itemCount: elements.length,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 130.0,
crossAxisSpacing: 20.0,
mainAxisSpacing: 20.0,
),
itemBuilder: (context, i) => Card(
child: Center(
child: Padding(
padding: EdgeInsets.all(8.0), child: Text(elements[i])
)
)
)
);
}
Flutter web widgets—particularly inside Rows and Columns—sometimes land outside the visible area, so wrapping in SafeArea and Center is a practical remedy. That gives this body:
SafeArea(
child:Center(
child: MediaQuery.of(context).size.width < 500 ? Content() :
Row(
children: [
Container(
width: 200.0,
child: Menu()
),
Container(
width: MediaQuery.of(context).size.width-200.0,
child: Content()
)
]
)
)
)
Assembled:

import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(context) => MaterialApp(
home: HomePage()
);
}
class HomePage extends StatelessWidget {
@override
Widget build(context) => Scaffold(
appBar: AppBar(title: Text("test")),
drawer: MediaQuery.of(context).size.width < 500 ? Drawer(
child: Menu(),
) : null,
body: SafeArea(
child:Center(
child: MediaQuery.of(context).size.width < 500 ? Content() :
Row(
children: [
Container(
width: 200.0,
child: Menu()
),
Container(
width: MediaQuery.of(context).size.width-200.0,
child: Content()
)
]
)
)
)
);
}
class Menu extends StatelessWidget {
@override
Widget build(context) => ListView(
children: [
FlatButton(
onPressed: () {},
child: ListTile(
leading: Icon(Icons.looks_one),
title: Text("First Link"),
)
),
FlatButton(
onPressed: () {},
child: ListTile(
leading: Icon(Icons.looks_two),
title: Text("Second Link"),
)
)
]
);
}
class Content extends StatelessWidget {
final List<String> elements = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "A Million Billion Trillion", "A much, much longer text that will still fit"];
@override
Widget build(context) => GridView.builder(
itemCount: elements.length,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 130.0,
crossAxisSpacing: 20.0,
mainAxisSpacing: 20.0,
),
itemBuilder: (context, i) => Card(
child: Center(
child: Padding(
padding: EdgeInsets.all(8.0), child: Text(elements[i])
)
)
)
);
}
That covers the general mechanics of responsive UI in Flutter. Much of the application depends on an app's own UI, and there are many viable approaches.
A Two-Screen Responsive App
From a single screen, the next step is a two-screen app with URL-based navigation.
Login, adapted
A mobile login screen is normally modest: a Column with Padding, TextFields for a username and password, and a button. Here it is without any working logic (that would need a TextEditingController for each field):
Scaffold(
body: Container(
padding: const EdgeInsets.symmetric(
vertical: 30.0, horizontal: 25.0
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text("Welcome to the app, please log in"),
TextField(
decoration: InputDecoration(
labelText: "username"
)
),
TextField(
obscureText: true,
decoration: InputDecoration(
labelText: "password"
)
),
RaisedButton(
color: Colors.blue,
child: Text("Log in", style: TextStyle(color: Colors.white)),
onPressed: () {}
)
]
),
),
)
The mobile result is fine, but those wide text fields look jarring on a tablet or a larger screen. A straight fixed width is the wrong answer—phones vary too much. Suppose experimentation points to a maximum width of 500: setting the Container's constraints to 500 does the job, but leaves the content stuck to the left edge, which is arguably worse than stretching. A Center wrapper fixes it:
Center(
child: Container(
constraints: BoxConstraints(maxWidth: 500),
padding: const EdgeInsets.symmetric(
vertical: 30.0, horizontal: 25.0
),
child: Column(/* ... \*/)
)
)
Improving it further needs no LayoutBuilder and no MediaQuery.of(context).size yet. Separating the foreground from the background—giving a color to the area behind the input Container while keeping that container white—looks better. So does stopping the container from stretching top and bottom on large devices, rounding its corners, and animating the transition between the two layouts.
That combination needs a LayoutBuilder and an outer Container, both to set the background color and to add padding on all sides rather than only the sides on larger screens. Making the padding change animated means promoting that outer container to an AnimatedContainer, which requires a duration; half a second is Duration(milliseconds: 500).

class LoginPage extends StatelessWidget {
@override
Widget build(context) =>
Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
return AnimatedContainer(
duration: Duration(milliseconds: 500),
color: Colors.lightGreen[200],
padding: constraints.maxWidth < 500 ? EdgeInsets.zero : EdgeInsets.all(30.0),
child: Center(
child: Container(
padding: EdgeInsets.symmetric(
vertical: 30.0, horizontal: 25.0
),
constraints: BoxConstraints(
maxWidth: 500,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5.0),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text("Welcome to the app, please log in"),
TextField(
decoration: InputDecoration(
labelText: "username"
)
),
TextField(
obscureText: true,
decoration: InputDecoration(
labelText: "password"
)
),
RaisedButton(
color: Colors.blue,
child: Text("Log in", style: TextStyle(color: Colors.white)),
onPressed: () {
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (context) => HomePage()
)
);
}
)
]
),
),
)
);
}
)
);
}
The RaisedButton's onPressed here navigates to a HomePage—for instance, the GridView plus menu or drawer view built above.
Named routes
Web apps conventionally map URLs to screens, so https://appurl/login differs from https://appurl/somethingelse. Flutter's named routes serve two purposes: URL-driven screen selection in a web app, and predefined, name-addressable routes in any app. The MaterialApp constructor changes accordingly:
MaterialApp(
initialRoute: "/login",
routes: {
"/login": (context) => LoginPage(),
"/home": (context) => HomePage()
}
);
Navigation then uses Navigator.pushNamed(context, routeName) and Navigator.pushReplacementNamed(context, routeName) in place of Navigator.push(context, route) and Navigator.pushReplacement(context, route). The demo app reflects this, though named routes aren't visible in DartPad—try it locally with flutter run, or see the example in action.

import 'package:flutter/material.dart';
void main() => runApp(MyApp());
class MyApp extends StatelessWidget {
@override
Widget build(context) =>
MaterialApp(
initialRoute: "/login",
routes: {
"/login": (context) => LoginPage(),
"/home": (context) => HomePage()
}
);
}
class LoginPage extends StatelessWidget {
@override
Widget build(context) =>
Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
return AnimatedContainer(
duration: Duration(milliseconds: 500),
color: Colors.lightGreen[200],
padding: constraints.maxWidth < 500 ? EdgeInsets.zero : const EdgeInsets.all(30.0),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(
vertical: 30.0, horizontal: 25.0
),
constraints: BoxConstraints(
maxWidth: 500,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5.0),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
Text("Welcome to the app, please log in"),
TextField(
decoration: InputDecoration(
labelText: "username"
)
),
TextField(
obscureText: true,
decoration: InputDecoration(
labelText: "password"
)
),
RaisedButton(
color: Colors.blue,
child: Text("Log in", style: TextStyle(color: Colors.white)),
onPressed: () {
Navigator.pushReplacementNamed(
context,
"/home"
);
}
)
]
),
),
)
);
}
)
);
}
class HomePage extends StatelessWidget {
@override
Widget build(context) => Scaffold(
appBar: AppBar(title: Text("test")),
drawer: MediaQuery.of(context).size.width < 500 ? Drawer(
child: Menu(),
) : null,
body: SafeArea(
child:Center(
child: MediaQuery.of(context).size.width < 500 ? Content() :
Row(
children: [
Container(
width: 200.0,
child: Menu()
),
Container(
width: MediaQuery.of(context).size.width-200.0,
child: Content()
)
]
)
)
)
);
}
class Menu extends StatelessWidget {
@override
Widget build(context) => ListView(
children: [
FlatButton(
onPressed: () {},
child: ListTile(
leading: Icon(Icons.looks_one),
title: Text("First Link"),
)
),
FlatButton(
onPressed: () {},
child: ListTile(
leading: Icon(Icons.looks_two),
title: Text("Second Link"),
)
),
FlatButton(
onPressed: () {Navigator.pushReplacementNamed(
context, "/login");},
child: ListTile(
leading: Icon(Icons.exit_to_app),
title: Text("Log Out"),
)
)
]
);
}
class Content extends StatelessWidget {
final List<String> elements = ["Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "A Million Billion Trillion", "A much, much longer text that will still fit"];
@override
Widget build(context) => GridView.builder(
itemCount: elements.length,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 130.0,
crossAxisSpacing: 20.0,
mainAxisSpacing: 20.0,
),
itemBuilder: (context, i) => Card(
child: Center(
child: Padding(
padding: EdgeInsets.all(8.0), child: Text(elements[i])
)
)
)
);
}
Go deeper
- Desktop shells, GitHub — the current, always up-to-date state of Flutter on desktop
- Desktop support for Flutter — the fully supported desktop platforms
- Web support for Flutter — information about Flutter for the web
- All Samples — a curated list of Flutter samples and apps




