Web & Desktop
Flutter web and desktop — responsive layout, input adaptation, platform channels, rendering backends
Web & Desktop
Flutter compiles to web (JS/Wasm), Windows (Win32), macOS (Cocoa), and Linux (GTK). "Multi-platform" does not mean write-once-run-everywhere. It means one codebase with platform-aware adaptation at the UI, input, and integration layers.
Platform Differences at a Glance
| Dimension | Mobile (iOS / Android) | Web | Desktop (Win / macOS / Linux) |
|---|---|---|---|
| Input | Touch, gestures | Mouse, keyboard, touch (tablets) | Mouse, keyboard, trackpad |
| Window | Fixed screen, no resize | Browser viewport, responsive | Resizable window, multi-window |
| Navigation | Push/pop stack, swipe back | URL-based, browser back/forward | Menu bar, keyboard shortcuts |
| Storage | SQLite, SharedPreferences | localStorage, IndexedDB | File system, SQLite |
| Deployment | App stores (review process) | Static hosting, instant updates | Installers (DMG, MSI, deb, snap) |
| Performance | GPU-accelerated, Impeller | CanvasKit download, JS overhead | Native-like, GPU-accelerated |
| Platform APIs | Camera, sensors, biometrics | Limited browser APIs | System tray, file system, notifications |
| Bundle size | ~5-15 MB (release APK) | ~2 MB (CanvasKit) + app code | ~20-40 MB |
The table above is a simplification. The real challenge is not any single row but the interaction between rows -- a desktop app needs keyboard shortcuts AND window management AND menu bars AND file system access, all of which mobile apps ignore entirely.
Responsive Layout
LayoutBuilder + MediaQuery Breakpoint Strategy
Define breakpoints once, reference everywhere:
abstract class Breakpoints {
static const double mobile = 600;
static const double tablet = 900;
static const double desktop = 1200;
}
enum ScreenSize { mobile, tablet, desktop }
ScreenSize getScreenSize(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
if (width < Breakpoints.mobile) return ScreenSize.mobile;
if (width < Breakpoints.desktop) return ScreenSize.tablet;
return ScreenSize.desktop;
}Use MediaQuery.sizeOf(context) instead of MediaQuery.of(context).size. The former subscribes only to size changes; the latter rebuilds on any MediaQuery field change (padding, text scale, etc.).
Adaptive Layout Pattern
A responsive scaffold that switches navigation style by screen width:
class AdaptiveScaffold extends StatelessWidget {
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
final List<NavigationDestination> destinations;
final Widget body;
const AdaptiveScaffold({
super.key,
required this.selectedIndex,
required this.onDestinationSelected,
required this.destinations,
required this.body,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// Desktop: permanent drawer
if (constraints.maxWidth >= Breakpoints.desktop) {
return Scaffold(
body: Row(
children: [
NavigationDrawer(
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
children: [
const SizedBox(height: 16),
...destinations.map((d) => NavigationDrawerDestination(
icon: d.icon,
label: Text(d.label),
)),
],
),
const VerticalDivider(width: 1),
Expanded(child: body),
],
),
);
}
// Tablet: navigation rail
if (constraints.maxWidth >= Breakpoints.mobile) {
return Scaffold(
body: Row(
children: [
NavigationRail(
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
labelType: NavigationRailLabelType.all,
destinations: destinations.map((d) =>
NavigationRailDestination(
icon: d.icon,
label: Text(d.label),
),
).toList(),
),
const VerticalDivider(width: 1),
Expanded(child: body),
],
),
);
}
// Mobile: bottom navigation
return Scaffold(
body: body,
bottomNavigationBar: NavigationBar(
selectedIndex: selectedIndex,
onDestinationSelected: onDestinationSelected,
destinations: destinations,
),
);
},
);
}
}ConstrainedBox and Responsive Grids
Cap content width on large screens to avoid unreadably wide lines:
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: GridView.builder(
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 400,
mainAxisSpacing: 16,
crossAxisSpacing: 16,
),
itemCount: items.length,
itemBuilder: (_, i) => ItemCard(items[i]),
),
),
)SliverGridDelegateWithMaxCrossAxisExtent automatically adjusts column count as the window resizes. Prefer it over SliverGridDelegateWithFixedCrossAxisCount for responsive layouts.
Handling Orientation Changes
On mobile, orientation changes trigger a full relayout. On desktop, users simply resize the window. Both go through LayoutBuilder:
LayoutBuilder(
builder: (context, constraints) {
final isLandscape = constraints.maxWidth > constraints.maxHeight;
return isLandscape
? Row(children: [sidebar, Expanded(child: content)])
: Column(children: [content, sidebar]);
},
)Do not use OrientationBuilder for responsive desktop layouts. It only reports orientation (portrait vs landscape), not the actual available space. LayoutBuilder gives you the exact constraints and works uniformly across all platforms.
Input Adaptation
Mouse Hover Effects
Desktop and web users expect hover feedback. Use MouseRegion for arbitrary hover detection and InkWell for material hover:
MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
transform: _hovered
? (Matrix4.identity()..scale(1.02))
: Matrix4.identity(),
child: Card(child: content),
),
)Right-Click Context Menus
ContextMenuRegion(
contextMenuBuilder: (context, primaryAnchor, [secondaryAnchor]) {
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: TextSelectionToolbarAnchors(primaryAnchor: primaryAnchor),
buttonItems: [
ContextMenuButtonItem(
label: 'Copy',
onPressed: () {
ContextMenuController.removeAny();
_handleCopy();
},
),
ContextMenuButtonItem(
label: 'Delete',
onPressed: () {
ContextMenuController.removeAny();
_handleDelete();
},
),
],
);
},
child: ListTile(title: Text(item.name)),
)Keyboard Shortcuts
Register global or scoped keyboard shortcuts with Shortcuts and Actions:
Shortcuts(
shortcuts: {
SingleActivator(LogicalKeyboardKey.keyN, control: true):
const CreateNewIntent(),
SingleActivator(LogicalKeyboardKey.keyS, control: true):
const SaveIntent(),
SingleActivator(LogicalKeyboardKey.keyZ, control: true):
const UndoIntent(),
SingleActivator(LogicalKeyboardKey.delete):
const DeleteIntent(),
},
child: Actions(
actions: {
CreateNewIntent: CallbackAction<CreateNewIntent>(
onInvoke: (_) => _createNew(),
),
SaveIntent: CallbackAction<SaveIntent>(
onInvoke: (_) => _save(),
),
UndoIntent: CallbackAction<UndoIntent>(
onInvoke: (_) => _undo(),
),
DeleteIntent: CallbackAction<DeleteIntent>(
onInvoke: (_) => _delete(),
),
},
child: Focus(
autofocus: true,
child: child,
),
),
)
// Intent declarations
class CreateNewIntent extends Intent { const CreateNewIntent(); }
class SaveIntent extends Intent { const SaveIntent(); }
class UndoIntent extends Intent { const UndoIntent(); }
class DeleteIntent extends Intent { const DeleteIntent(); }On macOS, use meta: true instead of control: true for Cmd-key shortcuts. Use SingleActivator(LogicalKeyboardKey.keyS, meta: true) for Cmd+S. To support both platforms, register both bindings or check the platform at runtime.
Focus Traversal
Desktop apps must support Tab-key navigation. Flutter handles this automatically for most widgets, but complex layouts need explicit traversal groups:
FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: Column(
children: [
FocusTraversalOrder(
order: const NumericFocusOrder(1),
child: TextField(decoration: InputDecoration(labelText: 'Name')),
),
FocusTraversalOrder(
order: const NumericFocusOrder(2),
child: TextField(decoration: InputDecoration(labelText: 'Email')),
),
FocusTraversalOrder(
order: const NumericFocusOrder(3),
child: ElevatedButton(onPressed: _submit, child: Text('Submit')),
),
],
),
)Web-Specific Concerns
Rendering Backends
Flutter web supports three rendering backends:
| Backend | Build Flag | Download Size | Text Rendering | Use Case |
|---|---|---|---|---|
| HTML | --web-renderer html | Smallest (~1 MB) | Native browser text | Text-heavy apps, fast load |
| CanvasKit | --web-renderer canvaskit | ~2 MB additional | Pixel-perfect, same as mobile | Graphically rich apps, games |
| Skwasm (Wasm) | --wasm (Flutter 3.22+) | Similar to CanvasKit | Pixel-perfect | Best performance, modern browsers only |
# Build with specific renderer
flutter build web --web-renderer canvaskit
# Build with Wasm (requires Flutter 3.22+)
flutter build web --wasmCanvasKit downloads a ~2 MB WebAssembly binary on first load. This is cached by the browser on subsequent visits, but the initial load can be slow on poor connections.
Skwasm compiles Dart directly to WebAssembly, bypassing the JavaScript intermediate step. It requires browsers with WasmGC support (Chrome 119+, Firefox 120+). As of 2025, Safari support is still incomplete.
The HTML renderer was deprecated in Flutter 3.22 and removed in later versions. New projects should choose between CanvasKit (broad compatibility) and Skwasm/Wasm (best performance, modern browsers). Check the Flutter docs for the latest renderer status.
SEO and Discoverability
Flutter web renders to a canvas element. Search engine crawlers see an empty <div id="flutter_target">. This makes Flutter web fundamentally unsuitable for content sites that rely on organic search traffic.
Flutter web makes sense for:
- Internal enterprise tools and dashboards
- Progressive Web Apps (PWAs) behind authentication
- Interactive data visualization and games
- Admin panels and back-office tools
- Porting existing Flutter mobile apps to web
Flutter web does NOT make sense for:
- Marketing sites, blogs, documentation
- E-commerce product pages
- Any page that needs to rank in search results
For hybrid approaches, build the public-facing site with a web framework (Next.js, Astro) and embed Flutter for the authenticated app portion.
Navigation and URLs
Browser users expect URLs to reflect app state, the back button to work, and deep links to reach specific content:
// go_router setup with URL-synced navigation
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (_, __) => const HomeScreen(),
routes: [
GoRoute(
path: 'projects/:projectId',
builder: (_, state) => ProjectScreen(
id: state.pathParameters['projectId']!,
),
routes: [
GoRoute(
path: 'tasks/:taskId',
builder: (_, state) => TaskScreen(
projectId: state.pathParameters['projectId']!,
taskId: state.pathParameters['taskId']!,
),
),
],
),
],
),
],
);
// Navigate programmatically -- URL updates automatically
context.go('/projects/abc123/tasks/task456');
// Use in MaterialApp
MaterialApp.router(routerConfig: router)By default, Flutter web uses hash routing (/#/projects/abc123). To use clean path routing (/projects/abc123), configure usePathUrlStrategy():
import 'package:flutter_web_plugins/url_strategy.dart';
void main() {
usePathUrlStrategy();
runApp(const MyApp());
}Path routing requires server-side configuration to redirect all paths to index.html (SPA fallback).
Performance
Initial load time is the primary web concern. Strategies:
- Use deferred loading to split code into separately-loadable chunks
- Pre-cache CanvasKit in a service worker
- Show a lightweight HTML loading indicator in
index.htmlbefore Flutter boots - Use tree shaking (
--tree-shake-iconsis on by default in release builds)
// Deferred loading: split a heavy feature into its own chunk
import 'package:app/features/reports/reports_screen.dart'
deferred as reports;
Future<void> openReports() async {
await reports.loadLibrary();
navigator.push(reports.ReportsScreen());
}For images on web, prefer WebP (30% smaller than PNG) and SVG for icons. Avoid large PNGs.
Web APIs and JavaScript Interop
Dart on web provides dart:js_interop for calling JavaScript. Since dart:io does not exist on web, platform-specific code requires conditional imports.
// js_interop_example.dart
import 'dart:js_interop';
@JS('window.localStorage.getItem')
external JSString? _getItem(JSString key);
@JS('window.localStorage.setItem')
external void _setItem(JSString key, JSString value);
String? getLocalStorageItem(String key) {
return _getItem(key.toJS)?.toDart;
}
void setLocalStorageItem(String key, String value) {
_setItem(key.toJS, value.toJS);
}Desktop-Specific Concerns
Window Management
The window_manager package provides full window control:
import 'package:window_manager/window_manager.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await windowManager.ensureInitialized();
const windowOptions = WindowOptions(
size: Size(1280, 720),
minimumSize: Size(800, 500),
center: true,
title: 'My Desktop App',
backgroundColor: Colors.transparent,
titleBarStyle: TitleBarStyle.hidden, // for custom title bar
);
windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.show();
await windowManager.focus();
});
runApp(const MyApp());
}For a custom title bar (frameless window):
class CustomTitleBar extends StatelessWidget {
const CustomTitleBar({super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onPanStart: (_) => windowManager.startDragging(),
child: Container(
height: 40,
color: Theme.of(context).colorScheme.surface,
child: Row(
children: [
const SizedBox(width: 16),
Text('My App', style: Theme.of(context).textTheme.titleSmall),
const Spacer(),
IconButton(
icon: const Icon(Icons.minimize, size: 18),
onPressed: () => windowManager.minimize(),
),
IconButton(
icon: const Icon(Icons.crop_square, size: 18),
onPressed: () async {
if (await windowManager.isMaximized()) {
windowManager.unmaximize();
} else {
windowManager.maximize();
}
},
),
IconButton(
icon: const Icon(Icons.close, size: 18),
onPressed: () => windowManager.close(),
),
],
),
),
);
}
}Menu Bar
macOS apps require a native menu bar. Use PlatformMenuBar for macOS and a custom widget for Windows/Linux:
PlatformMenuBar(
menus: [
PlatformMenu(
label: 'File',
menus: [
PlatformMenuItem(
label: 'New',
shortcut: const SingleActivator(
LogicalKeyboardKey.keyN,
meta: true,
),
onSelected: _createNew,
),
PlatformMenuItem(
label: 'Open...',
shortcut: const SingleActivator(
LogicalKeyboardKey.keyO,
meta: true,
),
onSelected: _openFile,
),
const PlatformMenuItemGroup(members: [
PlatformMenuItem(label: 'Save'),
PlatformMenuItem(label: 'Save As...'),
]),
PlatformMenuItem(
label: 'Quit',
shortcut: const SingleActivator(
LogicalKeyboardKey.keyQ,
meta: true,
),
onSelected: () => exit(0),
),
],
),
PlatformMenu(
label: 'Edit',
menus: [
PlatformMenuItem(
label: 'Undo',
shortcut: const SingleActivator(
LogicalKeyboardKey.keyZ,
meta: true,
),
onSelected: _undo,
),
PlatformMenuItem(
label: 'Redo',
shortcut: const SingleActivator(
LogicalKeyboardKey.keyZ,
meta: true,
shift: true,
),
onSelected: _redo,
),
],
),
],
child: const MyApp(),
)PlatformMenuBar renders a native macOS menu bar. On Windows and Linux, it has no visible effect -- you need a custom MenuBar widget for those platforms.
File System Access
Desktop apps can access the file system directly via dart:io and present native open/save dialogs with file_picker:
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:desktop_drop/desktop_drop.dart';
class FileEditor extends StatefulWidget {
const FileEditor({super.key});
@override
State<FileEditor> createState() => _FileEditorState();
}
class _FileEditorState extends State<FileEditor> {
String? _filePath;
String _content = '';
bool _isDragging = false;
Future<void> _openFile() async {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['txt', 'md', 'json'],
);
if (result != null) {
final file = File(result.files.single.path!);
setState(() {
_filePath = file.path;
_content = file.readAsStringSync();
});
}
}
Future<void> _saveFile() async {
if (_filePath == null) {
final path = await FilePicker.platform.saveFile(
dialogTitle: 'Save file',
fileName: 'untitled.txt',
);
if (path == null) return;
_filePath = path;
}
File(_filePath!).writeAsStringSync(_content);
}
@override
Widget build(BuildContext context) {
return DropTarget(
onDragEntered: (_) => setState(() => _isDragging = true),
onDragExited: (_) => setState(() => _isDragging = false),
onDragDone: (details) {
setState(() => _isDragging = false);
final file = File(details.files.first.path);
setState(() {
_filePath = file.path;
_content = file.readAsStringSync();
});
},
child: Container(
decoration: BoxDecoration(
border: _isDragging
? Border.all(color: Colors.blue, width: 2)
: null,
),
child: Column(
children: [
Row(
children: [
TextButton(onPressed: _openFile, child: const Text('Open')),
TextButton(onPressed: _saveFile, child: const Text('Save')),
],
),
Expanded(
child: TextField(
controller: TextEditingController(text: _content),
maxLines: null,
expands: true,
onChanged: (v) => _content = v,
),
),
],
),
),
);
}
}Platform Integration
App lifecycle -- detect when the app is minimized, resumed, or about to close:
class _AppState extends State<MyApp> {
late final AppLifecycleListener _lifecycleListener;
@override
void initState() {
super.initState();
_lifecycleListener = AppLifecycleListener(
onExitRequested: () async {
// Return AppExitResponse.cancel to prevent closing
final shouldExit = await _showUnsavedChangesDialog();
return shouldExit
? AppExitResponse.exit
: AppExitResponse.cancel;
},
onStateChange: (state) {
if (state == AppLifecycleState.paused) {
_autoSave();
}
},
);
}
@override
void dispose() {
_lifecycleListener.dispose();
super.dispose();
}
}Platform-specific packaging:
| Platform | Format | Tool |
|---|---|---|
| macOS | .dmg, .app | flutter build macos, then create-dmg or Xcode archiving |
| Windows | .msix, .exe installer | flutter build windows, then msix package or Inno Setup |
| Linux | .deb, .rpm, .snap, .AppImage | flutter build linux, then flutter_distributor or manual packaging |
Conditional Platform Code
The Platform Detection Problem
dart:io provides Platform.isWindows, Platform.isMacOS, etc. But dart:io does not exist on web -- importing it crashes at runtime. The kIsWeb constant from package:flutter/foundation.dart detects web, but you cannot combine it with Platform checks in the same file if that file might run on web.
Conditional Imports
The solution is conditional imports -- Dart's mechanism for providing different implementations per platform:
// storage_service.dart (the public API)
import 'storage_stub.dart'
if (dart.library.html) 'storage_web.dart'
if (dart.library.io) 'storage_native.dart';
abstract class StorageService {
factory StorageService() = StorageServiceImpl;
Future<void> write(String key, String value);
Future<String?> read(String key);
Future<void> delete(String key);
}// storage_stub.dart (fallback, satisfies the analyzer)
import 'storage_service.dart';
class StorageServiceImpl implements StorageService {
@override
Future<void> write(String key, String value) =>
throw UnsupportedError('Stub');
@override
Future<String?> read(String key) =>
throw UnsupportedError('Stub');
@override
Future<void> delete(String key) =>
throw UnsupportedError('Stub');
}// storage_web.dart (web implementation using localStorage)
import 'dart:js_interop';
import 'storage_service.dart';
@JS('window.localStorage.setItem')
external void _setItem(JSString key, JSString value);
@JS('window.localStorage.getItem')
external JSString? _getItem(JSString key);
@JS('window.localStorage.removeItem')
external void _removeItem(JSString key);
class StorageServiceImpl implements StorageService {
@override
Future<void> write(String key, String value) async {
_setItem(key.toJS, value.toJS);
}
@override
Future<String?> read(String key) async {
return _getItem(key.toJS)?.toDart;
}
@override
Future<void> delete(String key) async {
_removeItem(key.toJS);
}
}// storage_native.dart (mobile/desktop implementation using dart:io)
import 'dart:io';
import 'dart:convert';
import 'package:path_provider/path_provider.dart';
import 'storage_service.dart';
class StorageServiceImpl implements StorageService {
Future<File> get _file async {
final dir = await getApplicationSupportDirectory();
return File('${dir.path}/storage.json');
}
Future<Map<String, String>> _readAll() async {
final f = await _file;
if (!f.existsSync()) return {};
return Map<String, String>.from(jsonDecode(f.readAsStringSync()));
}
@override
Future<void> write(String key, String value) async {
final data = await _readAll();
data[key] = value;
final f = await _file;
f.writeAsStringSync(jsonEncode(data));
}
@override
Future<String?> read(String key) async {
final data = await _readAll();
return data[key];
}
@override
Future<void> delete(String key) async {
final data = await _readAll();
data.remove(key);
final f = await _file;
f.writeAsStringSync(jsonEncode(data));
}
}This pattern -- abstract interface plus platform-specific implementations via conditional imports -- is the foundation for all cross-platform code that touches platform APIs.
Platform-Adaptive Widgets
For UI differences that are simpler than full conditional imports:
Widget buildPlatformAction(BuildContext context) {
final screenSize = getScreenSize(context);
return switch (screenSize) {
ScreenSize.mobile => FloatingActionButton(
onPressed: _create,
child: const Icon(Icons.add),
),
ScreenSize.tablet || ScreenSize.desktop => FilledButton.icon(
onPressed: _create,
icon: const Icon(Icons.add),
label: const Text('Create New'),
),
};
}Testing Multi-Platform
Widget Tests
Widget tests run in a headless environment that has neither dart:io platform detection nor real window sizes. Use MediaQuery overrides and dependency injection to test responsive behavior:
testWidgets('shows bottom nav on mobile width', (tester) async {
// Set a mobile-width surface
tester.view.physicalSize = const Size(400, 800);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(const MaterialApp(home: AdaptiveScaffold(...)));
expect(find.byType(NavigationBar), findsOneWidget);
expect(find.byType(NavigationRail), findsNothing);
});
testWidgets('shows rail on tablet width', (tester) async {
tester.view.physicalSize = const Size(800, 600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(const MaterialApp(home: AdaptiveScaffold(...)));
expect(find.byType(NavigationRail), findsOneWidget);
expect(find.byType(NavigationBar), findsNothing);
});Golden Tests per Platform
Golden test expectations may differ by platform (font rendering, scrollbar styles). Generate separate golden files:
testWidgets('desktop layout matches golden', (tester) async {
tester.view.physicalSize = const Size(1400, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
await tester.pumpWidget(const MaterialApp(home: DashboardScreen()));
await tester.pumpAndSettle();
await expectLater(
find.byType(DashboardScreen),
matchesGoldenFile('goldens/dashboard_desktop.png'),
);
});CI Matrix for Multi-Platform Builds
# .github/workflows/build.yml
strategy:
matrix:
include:
- os: ubuntu-latest
target: linux
- os: macos-latest
target: macos
- os: windows-latest
target: windows
- os: ubuntu-latest
target: web
steps:
- uses: subosito/flutter-action@v2
- run: flutter build ${{ matrix.target }}
- run: flutter testIntegration tests (flutter test integration_test/) run on real platform APIs and are the only way to verify platform channels, file system access, and window management. Run them per platform in CI.
Anti-Patterns
Assuming Touch Input on All Platforms
Touch targets need to be at least 48x48 on mobile, but on desktop users have pixel-precise mouse input. Adapt:
// Do not hardcode large touch targets for desktop
final minTargetSize = switch (getScreenSize(context)) {
ScreenSize.mobile => 48.0,
ScreenSize.tablet => 40.0,
ScreenSize.desktop => 32.0,
};Fixed-Size Layouts
// WRONG: breaks on any screen that is not exactly this size
SizedBox(width: 375, height: 812, child: content)
// RIGHT: fill available space, constrain maximum
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1200),
child: content,
)Using dart:io on Web
// CRASHES at runtime on web -- dart:io does not exist
import 'dart:io';
final isDesktop = Platform.isMacOS || Platform.isWindows;
// CORRECT: use conditional imports or kIsWeb
import 'package:flutter/foundation.dart' show kIsWeb;
if (kIsWeb) {
// web-specific path
}Ignoring Keyboard Navigation on Desktop
Desktop users navigate with Tab, Enter, Escape, and arrow keys. If your app swallows focus or breaks traversal order, keyboard-only users (and accessibility tools) cannot use it. Always test your app by navigating with only the keyboard.
Shipping CanvasKit for Text-Heavy Web Apps
CanvasKit renders text to a canvas, which means:
- No native text selection (Flutter reimplements it, imperfectly)
- No browser-native find-in-page (Ctrl+F)
- No accessibility tree for screen readers (requires separate semantics overlay)
- 2 MB download before the app can render
For text-heavy apps, evaluate whether Flutter web is the right choice at all.
Multi-platform Flutter is not a shortcut -- it is a leverage multiplier. You still need to understand each platform's conventions and user expectations. The payoff is sharing business logic, state management, and large portions of UI code while adapting the edges to feel native on every target.