All Posts
August 28, 202614 min read

Flutter Responsiveness Without Packages: Build Your Own ResponsiveConfig System

FlutterResponsive DesignClean ArchitectureMobile Development
Flutter responsive design showing mobile and tablet layouts adapting to different screen sizes

Introduction

Every week on Flutter forums and Discord servers, junior developers ask the same question: "Which responsive package should I use? flutter_screenutil? responsive_framework? sizer?"

Here is the honest answer from production experience: you do not need any of them.

Third-party responsive packages add dependency weight, lock you into their API, and often conflict with your existing layout code when you try to update Flutter. More importantly, they solve a problem that MediaQuery already solves cleanly - you just need to centralize it properly.

In every production Flutter app I build, I use a custom ResponsiveConfig system built entirely on top of MediaQuery. It gives me:

  • A single initialization point for all screen dimensions
  • Reference-based proportional scaling for both mobile and tablet
  • Clean extensions: 20.w, 14.sp, 10.h - readable anywhere in the codebase
  • Automatic tablet detection with a custom breakpoint
  • Effortless switching between bottom navigation (mobile) and sidebar navigation (tablet)

The full implementation is part of my open-source Flutter Clean Architecture Template available on GitHub.

This blog explains the system end-to-end - the design thinking, the code, and how to use it in practice.


Why Third-Party Packages Fall Short

Before building the solution, it helps to understand the real problem.

The wrong approach - MediaQuery scattered everywhere:

// This pattern is a maintenance nightmare
class ProductCard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final width = MediaQuery.of(context).size.width;
    final height = MediaQuery.of(context).size.height;

    return Container(
      width: width * 0.45,
      height: height * 0.2,
      padding: EdgeInsets.all(width * 0.04),
      child: Text(
        'Product Name',
        style: TextStyle(fontSize: width * 0.04),
      ),
    );
  }
}

Problems with this:

  • MediaQuery.of(context) rebuilds the entire widget on every screen rotation
  • Magic multipliers (0.45, 0.04) have no semantic meaning
  • No tablet-specific logic - a width * 0.45 card on a 12" iPad looks terrible
  • Changes to design sizing require hunting down every raw MediaQuery call

The right approach: centralized, initialized once, accessed via extensions.


The ResponsiveConfig System

The system has three parts:

  1. ResponsiveConfig - Static class initialized once per build, holds all screen data and scaling logic
  2. ResponsiveProvider - Flutter widget that initializes ResponsiveConfig at the top of the widget tree
  3. Responsive extensions - .w, .h, .sp on num for clean in-widget usage

Part 1: ResponsiveConfig

// lib/core/config/responsive_config.dart
import 'package:flutter/material.dart';

class ResponsiveConfig {
  // Current device dimensions - set at init time
  static late double screenWidth;
  static late double screenHeight;
  static late Orientation orientation;
  static late bool isTablet;

  // Reference design sizes (match your Figma/design files)
  // These are the dimensions your designer used to create mockups
  static late double mobileReferenceWidth;
  static late double mobileReferenceHeight;
  static late double tabletReferenceWidth;
  static late double tabletReferenceHeight;

  static bool _initialized = false;

  /// Initialize once at the root of the widget tree via ResponsiveProvider.
  /// Never call this from individual widgets.
  static void initialize({
    required BuildContext context,
    double mobileRefWidth = 390.0,   // iPhone 14 Pro width
    double mobileRefHeight = 844.0,  // iPhone 14 Pro height
    double tabletRefWidth = 820.0,   // iPad Air width
    double tabletRefHeight = 1180.0, // iPad Air height
    double tabletBreakpoint = 600.0, // Width above which device is tablet
  }) {
    final mediaQuery = MediaQuery.of(context);
    final size = mediaQuery.size;

    screenWidth = size.width;
    screenHeight = size.height;
    orientation = mediaQuery.orientation;
    isTablet = size.shortestSide >= tabletBreakpoint;

    mobileReferenceWidth = mobileRefWidth;
    mobileReferenceHeight = mobileRefHeight;
    tabletReferenceWidth = tabletRefWidth;
    tabletReferenceHeight = tabletRefHeight;

    _initialized = true;
  }

  /// Scale a width dimension proportionally to screen width.
  /// Based on the design's reference width for the current device class.
  static double scaleWidth(double designWidth) {
    assert(_initialized, 'ResponsiveConfig.initialize() must be called first');
    final refWidth = isTablet ? tabletReferenceWidth : mobileReferenceWidth;
    return designWidth * (screenWidth / refWidth);
  }

  /// Scale a height dimension proportionally to screen height.
  static double scaleHeight(double designHeight) {
    assert(_initialized, 'ResponsiveConfig.initialize() must be called first');
    final refHeight = isTablet ? tabletReferenceHeight : mobileReferenceHeight;
    return designHeight * (screenHeight / refHeight);
  }

  /// Scale a font size.
  /// Uses the average of width and height scaling for better readability.
  static double scaleFontSize(double designFontSize) {
    assert(_initialized, 'ResponsiveConfig.initialize() must be called first');
    final refWidth = isTablet ? tabletReferenceWidth : mobileReferenceWidth;
    final refHeight = isTablet ? tabletReferenceHeight : mobileReferenceHeight;
    final widthScale = screenWidth / refWidth;
    final heightScale = screenHeight / refHeight;
    // Average of both scales gives better font rendering across all screen sizes
    return designFontSize * ((widthScale + heightScale) / 2);
  }
}

Part 2: ResponsiveProvider

This widget initializes ResponsiveConfig exactly once when the app launches and again on every orientation change:

// lib/core/config/responsive_provider.dart
import 'package:flutter/material.dart';
import 'responsive_config.dart';

class ResponsiveProvider extends StatelessWidget {
  final Widget child;

  const ResponsiveProvider({super.key, required this.child});

  @override
  Widget build(BuildContext context) {
    // Called on every build - handles orientation changes automatically
    ResponsiveConfig.initialize(context: context);
    return child;
  }
}

Wrap your MaterialApp with it:

// lib/app/my_app.dart
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: ResponsiveProvider(
        child: const HomeScreen(),
      ),
      // Or wrap the builder for global coverage:
      builder: (context, child) => ResponsiveProvider(child: child!),
    );
  }
}

Part 3: Responsive Extensions

The extensions are what make this system feel natural to use. Instead of calling ResponsiveConfig.scaleWidth(20), you write 20.w:

// lib/core/extensions/responsive_extension.dart
import '../config/responsive_config.dart';

extension ResponsiveExtension on num {
  /// Scale as a width dimension
  /// Usage: 20.w (scales 20 dp proportionally to screen width)
  double get w => ResponsiveConfig.scaleWidth(toDouble());

  /// Scale as a height dimension
  /// Usage: 10.h (scales 10 dp proportionally to screen height)
  double get h => ResponsiveConfig.scaleHeight(toDouble());

  /// Scale as a font size
  /// Usage: 14.sp (scales 14 font size proportionally)
  double get sp => ResponsiveConfig.scaleFontSize(toDouble());

  /// Alias for width scaling - useful for padding/margin values
  double get dp => w;
}

Using the System in Widgets

With the system in place, here is how your widgets look:

// WITHOUT ResponsiveConfig - fragile, hardcoded
class ProductCard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      width: 180,
      height: 220,
      padding: const EdgeInsets.all(16),
      child: Text(
        'Product Name',
        style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
      ),
    );
  }
}

// WITH ResponsiveConfig - scales perfectly on every device
class ProductCard extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Container(
      width: 180.w,    // Scales proportionally to screen width
      height: 220.h,   // Scales proportionally to screen height
      padding: EdgeInsets.all(16.w),
      child: Text(
        'Product Name',
        style: TextStyle(
          fontSize: 16.sp,              // Font scales correctly
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }
}

On an iPhone SE (375pt wide), 180.w scales down slightly. On an iPad (820pt wide), it scales up proportionally. The UI always looks like it was designed for that exact screen.


Adaptive Navigation: Mobile vs Tablet

The most impactful use of ResponsiveConfig.isTablet is navigation layout. Mobile apps use BottomNavigationBar. Tablet apps use NavigationRail or a side drawer.

// lib/app/shell/app_shell.dart
class AppShell extends StatefulWidget {
  const AppShell({super.key});

  @override
  State<AppShell> createState() => _AppShellState();
}

class _AppShellState extends State<AppShell> {
  int _selectedIndex = 0;

  static const _pages = [
    HomeTab(),
    SearchTab(),
    ProfileTab(),
    SettingsTab(),
  ];

  static const _destinations = [
    NavigationDestination(icon: Icon(Icons.home_outlined), label: 'Home'),
    NavigationDestination(icon: Icon(Icons.search), label: 'Search'),
    NavigationDestination(icon: Icon(Icons.person_outline), label: 'Profile'),
    NavigationDestination(icon: Icon(Icons.settings_outlined), label: 'Settings'),
  ];

  @override
  Widget build(BuildContext context) {
    // ResponsiveConfig.isTablet is already initialized by ResponsiveProvider
    if (ResponsiveConfig.isTablet) {
      return _buildTabletLayout();
    }
    return _buildMobileLayout();
  }

  Widget _buildMobileLayout() {
    return Scaffold(
      body: _pages[_selectedIndex],
      bottomNavigationBar: NavigationBar(
        selectedIndex: _selectedIndex,
        onDestinationSelected: (index) => setState(() => _selectedIndex = index),
        destinations: _destinations,
      ),
    );
  }

  Widget _buildTabletLayout() {
    return Scaffold(
      body: Row(
        children: [
          NavigationRail(
            extended: ResponsiveConfig.screenWidth > 900, // Full labels on wide tablets
            selectedIndex: _selectedIndex,
            onDestinationSelected: (index) => setState(() => _selectedIndex = index),
            destinations: _destinations
                .map((d) => NavigationRailDestination(icon: d.icon, label: Text(d.label)))
                .toList(),
          ),
          const VerticalDivider(width: 1),
          Expanded(child: _pages[_selectedIndex]),
        ],
      ),
    );
  }
}

This pattern gives you:

  • Mobile: full-width content + bottom navigation bar
  • Tablet (narrow): sidebar with icons only + full-width content
  • Tablet (wide): expanded sidebar with icons and labels + full-width content

Reference Size Guidelines

Your reference sizes should match the device your designer used for mockups. Common choices:

DeviceWidthHeight
iPhone SE (small)375667
iPhone 14 Pro (standard)390844
iPhone 14 Plus (large)428926
iPad Air (tablet)8201180
iPad Pro 12.9" (large tablet)10241366

Set mobileRefWidth and mobileRefHeight in ResponsiveConfig.initialize() to match your Figma frame size.


Comparing to flutter_screenutil

Featureflutter_screenutilCustom ResponsiveConfig
External dependencyYesNo
Tablet supportLimitedFull, custom breakpoint
API.w, .h, .sp, .r.w, .h, .sp
InitializationScreenUtil.init()ResponsiveConfig.initialize()
Navigation adaptationNoBuilt-in isTablet flag
Customizable scalingLimitedFull control
Package updates neededYesNo

The custom system gives you full control, no external dependencies, and cleaner tablet support with no real tradeoffs.


Conclusion

Responsiveness is not a feature you add at the end of development. It is architecture. When you scatter MediaQuery.of(context).size.width * 0.45 across dozens of widgets, you create a maintenance debt that compounds with every new screen size that hits the market.

The ResponsiveConfig system solves this cleanly:

  • Initialize once, access everywhere
  • Extensions that read like design specs: 20.w, 14.sp, 10.h
  • A single isTablet boolean that drives your entire navigation architecture
  • Zero external dependencies

If you are building Flutter apps for real users across real devices - phones, tablets, foldables - adopt this pattern from day one. Your future self, and every junior developer who joins your team, will thank you.

The full implementation is part of my Flutter Clean Architecture Template on GitHub - production-ready, fully documented, and free to use.


Need to optimize your app for tablets, foldables, or web? I build pixel-perfect, adaptive, and package-free responsive layouts in Flutter that scale seamlessly across all screen sizes. Book a meeting to discuss responsive UI optimization.


Written by Moeen Ahmad, Senior Software Engineer working across mobile apps, backend systems, cloud deployments, and AI-powered products. I write about practical engineering, real project lessons, and building software that actually ships.

Share

Interested in working together?

Let's discuss your project and explore how I can help bring it to life.