All postsOne theme, three shells: Material, Cupertino, bare

One theme, three shells: Material, Cupertino, bare

July 26, 2026

Most Flutter component libraries only run one way: inside a MaterialApp, reading Theme.of(context), assuming a ColorScheme is somewhere above them. That is one app shell out of three Flutter actually ships. fossui's components do not pick a side. The same FossButton, the same FossSwitch, the same FossTextField render identically whether the app root is MaterialApp, CupertinoApp, or a bare WidgetsApp, because they read their own theme first.

This is how that actually works, down to the resolution order, with a full example of one screen rendered unchanged under all three shells.

Three shells, one of which has a theme system already

Theme.of(context) walks up the tree looking for a Theme InheritedWidget, which MaterialApp installs for you. Skip MaterialApp and there is nothing to find:

CupertinoApp(
  home: Builder(
    builder: (context) {
      final theme = Theme.of(context); // throws: no Material ancestor
      return const SizedBox();
    },
  ),
);

CupertinoApp has its own CupertinoThemeData, a separate system with a different shape (primaryColor, barBackgroundColor, no radius or spacing scale). A bare WidgetsApp has no theme system at all. So a design system that only reads Theme.of(context) is a Material-only design system, whatever the docs call it.

How fossui breaks the dependency

Every fossui widget reads tokens through one accessor, context.fossTheme, never Theme.of(context) directly. The accessor tries three sources, in order, and returns the first hit:

extension FossThemeContext on BuildContext {
  FossThemeData get fossTheme =>
      FossTheme.maybeOf(this) ??
      Theme.of(this).extension<FossThemeData>() ??
      FossThemeData.light;
}
  1. FossTheme, a plain InheritedWidget. No Material required. This is how the Cupertino and bare shells supply the theme.
  2. A FossThemeData registered in ThemeData.extensions. Material's own extension mechanism, ThemeExtension, built for exactly this: attaching third-party tokens to ThemeData without Material needing to know they exist.
  3. FossThemeData.light, so a component never crashes for want of a theme. It falls back quietly, which is convenient and also the first common mistake below.

Same call, three possible sources, and every component uses it identically. The component tree does not know or care which shell it is under.

Register under any shell

MaterialApp

Wrap the theme as an extension. toThemeData() does exactly one thing: it returns ThemeData(extensions: [this]). It does not restyle a single Material widget, so an ElevatedButton two lines below a FossButton looks exactly as it did before you added fossui.

MaterialApp(
  theme: FossThemeData.light.toThemeData(),
  darkTheme: FossThemeData.dark.toThemeData(),
  home: const HomePage(),
);

CupertinoApp

CupertinoApp has no extensions slot, Cupertino's theme system is unrelated. Supply fossui's theme as a widget instead, in builder, so it wraps every route:

CupertinoApp(
  builder: (context, child) => FossTheme(
    data: FossThemeData.light,
    child: child!,
  ),
  home: const HomePage(),
);

A bare WidgetsApp

No Material, no Cupertino, just widgets.dart. Same FossTheme wrapper. A bare WidgetsApp does need a pageRouteBuilder if you pass home directly (an assert enforces it), and PageRouteBuilder itself lives in widgets.dart, so this stays Material-free:

WidgetsApp(
  color: const Color(0xFFFFFFFF),
  pageRouteBuilder: <T>(settings, builder) => PageRouteBuilder<T>(
    settings: settings,
    pageBuilder: (context, animation, secondaryAnimation) => builder(context),
  ),
  builder: (context, child) => FossTheme(
    data: FossThemeData.light,
    child: child ?? const SizedBox.shrink(),
  ),
  home: const HomePage(),
);
        MaterialApp                    CupertinoApp / bare WidgetsApp
             │                                     │
  theme: FossThemeData                   FossTheme(data: ..., child: ...)
      .light.toThemeData()                        │
             │                                     │
             └───────────────────┬─────────────────┘

                        context.fossTheme

                    same FossButton, FossSwitch,
                    FossTextField... unchanged

Whichever shell you pick, HomePage itself never imports material.dart and never checks which shell it is under. That is the point: the fork happens once, at the root, not inside every screen.

Read tokens the same way everywhere

context.fossTheme returns a FossThemeData with six token bundles, identical whichever source resolved it:

final t = context.fossTheme;

t.colors.primary   // Color
t.radii.lg         // double   (10)
t.spacing(2)       // double   (8)
t.typography.sm    // TextStyle
t.shadows.md       // List<BoxShadow>
t.motion.overlay   // Duration

Twenty-six color roles (background / foreground, card / cardForeground, primary / primaryForeground, status roles like destructive and success, plus border, input, ring), a five-step radius scale, a spacing unit, a six-step type scale, four shadow levels, and named motion durations for the things that animate (overlay, drawer, toast, caretBlink, and so on). Same shape under every shell, because it is the same FossThemeData object.

Retheme globally

FossThemeSpec layers a compact set of overrides over a base theme: enumerated color roles plus single seeds for radius, spacing, shadowColor, and fontFamily. This works identically regardless of which shell you registered under, since retheme operates on FossThemeData, not on MaterialApp:

final theme = FossThemeData.light.retheme(
  const FossThemeSpec(
    primary: Color(0xFF16A34A),
    ring: Color(0xFF16A34A),
    radius: 22,
    fontFamily: 'Plus Jakarta Sans',
  ),
);

// Material
MaterialApp(theme: theme.toThemeData());

// Cupertino or bare
FossTheme(data: theme, child: const MyApp());

One retheme call, then pass the result down whichever wrapper your shell needs.

Toggling light and dark without themeMode

Under MaterialApp, themeMode handles the light/dark switch for you. CupertinoApp and bare WidgetsApp have no such concept, since FossTheme is just a widget, so you own the switch yourself, the same way you would own any other piece of app state:

class ThemedApp extends StatefulWidget {
  const ThemedApp({super.key});

  @override
  State<ThemedApp> createState() => _ThemedAppState();
}

class _ThemedAppState extends State<ThemedApp> {
  bool _dark = false;

  @override
  Widget build(BuildContext context) {
    return CupertinoApp(
      builder: (context, child) => FossTheme(
        data: _dark ? FossThemeData.dark : FossThemeData.light,
        child: child!,
      ),
      home: HomePage(onToggleTheme: () => setState(() => _dark = !_dark)),
    );
  }
}

FossTheme.updateShouldNotify compares the FossThemeData by value, so this setState rebuilds every fossui widget below it in one frame, same as flipping themeMode does under Material.

Common mistakes

  • Forgetting the FossTheme wrapper under Cupertino or bare. Nothing crashes. context.fossTheme silently falls through to FossThemeData.light, so the app renders in light mode and looks "fine" until you go looking for why dark mode never applied. If a rethemed color or a dark palette is not showing up, check that FossTheme is actually an ancestor before checking anything else.
  • Calling Theme.of(context) directly from a bare WidgetsApp. This is the exact call fossui avoids. It throws immediately with no Material ancestor. Route everything through context.fossTheme, never Theme.of.
  • Expecting FossTheme to carry general app theming. It carries fossui's six token bundles and nothing else, no TextTheme, no IconThemeData. It is a narrow, single-purpose InheritedWidget, not a Material ThemeData replacement.
  • Wrapping FossTheme below MaterialApp.builder instead of using theme:. Both work, FossTheme always wins over the ThemeData.extensions path in the resolution order, but doing it via theme: is simpler under Material and keeps the extension registered for any package that expects it there.

Why this works: the one Material import

fossui is framework-agnostic, but not by refusing to touch Material at all. One file imports exactly two symbols from it:

import 'package:flutter/material.dart' show Theme, ThemeExtension;

ThemeExtension is the mechanism FossThemeData implements, and it happens to live in material.dart with no widgets.dart equivalent. Theme.of is the lookup used only as the second, optional resolution step. Neither drags in a single Material widget, a ColorScheme, or a MaterialApp requirement. That scoped show import is the entire surface area where fossui touches Material, and it is why the same six token bundles resolve whether or not Material is in the tree at all.

A full example: same screen, three shells

One HomePage, unmodified, rendered under all three app shells. No Scaffold (Material-only), no CupertinoPageScaffold (Cupertino-only): a plain ColoredBox and SafeArea instead, so the screen itself has no shell dependency either.

class HomePage extends StatefulWidget {
  const HomePage({this.onToggleTheme, super.key});

  final VoidCallback? onToggleTheme;

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  bool _notify = true;

  @override
  Widget build(BuildContext context) {
    final t = context.fossTheme;
    return ColoredBox(
      color: t.colors.background,
      child: SafeArea(
        child: Padding(
          padding: t.spacing.all(6),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              FossText.title('Settings'),
              SizedBox(height: t.spacing(4)),
              Row(
                children: [
                  FossSwitch(
                    value: _notify,
                    onChanged: (v) => setState(() => _notify = v),
                  ),
                  SizedBox(width: t.spacing(3)),
                  const Text('Email notifications'),
                ],
              ),
              SizedBox(height: t.spacing(6)),
              FossButton(onPressed: widget.onToggleTheme, child: const Text('Save')),
            ],
          ),
        ),
      ),
    );
  }
}

Now three entry points, each a handful of lines, each producing the identical screen:

// main_material.dart
void main() => runApp(
  MaterialApp(
    theme: FossThemeData.light.toThemeData(),
    darkTheme: FossThemeData.dark.toThemeData(),
    home: const HomePage(),
  ),
);

// main_cupertino.dart
void main() => runApp(
  CupertinoApp(
    builder: (context, child) => FossTheme(
      data: FossThemeData.light,
      child: child!,
    ),
    home: const HomePage(),
  ),
);

// main_bare.dart
void main() => runApp(
  WidgetsApp(
    color: const Color(0xFFFFFFFF),
    pageRouteBuilder: <T>(settings, builder) => PageRouteBuilder<T>(
      settings: settings,
      pageBuilder: (context, animation, secondaryAnimation) => builder(context),
    ),
    builder: (context, child) => FossTheme(
      data: FossThemeData.light,
      child: child ?? const SizedBox.shrink(),
    ),
    home: const HomePage(),
  ),
);

HomePage never changes across the three files. That is the actual claim behind "one theme, three shells": not that fossui merely runs on Cupertino or a bare WidgetsApp, but that the same components, the same screen, and the same theme object work under all three without a single line changing in between.

What to expect

fossui is young and mobile-first: mobile is what gets tested. Web and desktop compile under all three shells and should work, but they are less exercised, so verify on the platforms you ship. The resolution order and the FossTheme mechanism are stable regardless, since neither is platform-specific, only component polish is.

If you want the token catalog in full, the theming docs cover all six families with every field. If you are migrating an existing Material app instead of starting fresh, the migration guide covers the widget-by-widget swap.