flutterbits

Semantic tokens & theming

How theming works by semantic indirection — components reference roles, the theme resolves them, and swapping the theme reskins the whole app.

A theme is one FwTokens bundle: the 32 semantic colors, the radius set, the shadow scale, and the typography (family names + tracking). Components never reference raw values — they reference roles — and the active FwTokens resolves them. That indirection is the whole trick.

Semantic indirection

// A component references roles…
final t = context.fw;
const Text('Subscribe').tw.px(4).py(2).bg(t.colors.primary).text(t.colors.primaryForeground);

Because the widget reads primary/primaryForeground (not a fixed color), swapping the active FwTokens reskins it — along with every other role-using widget — from a single change. This is the same model as shadcn's theming: change the theme, the components follow.

Providing a theme

FwTheme is an InheritedWidget that publishes a bundle to everything below it; context.fw reads it (falling back to the FwThemeExtension bridge inside a MaterialApp).

FwTheme(tokens: FwTokens.light, child: const HomeScreen());

FwTokens.light and FwTokens.dark are the built-in stock shadcn-neutral themes. Switching between them is the host app's job — flutterwindcss just resolves whichever bundle is active.

Animating theme changes

Swap FwTheme for FwAnimatedTheme and every context.fw-styled descendant crossfades when the tokens change — colors, radii, shadows, and typography all interpolate (FwTokens.lerp). Perfect for a light ↔ dark toggle:

class App extends StatefulWidget {
  const App({super.key});
  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  bool _dark = false;

  @override
  Widget build(BuildContext context) {
    return WidgetsApp(
      color: const Color(0xFF000000),
      builder: (context, _) => FwAnimatedTheme(
        tokens: _dark ? FwTokens.dark : FwTokens.light,
        duration: const Duration(milliseconds: 200),
        child: HomeScreen(onToggleDark: () => setState(() => _dark = !_dark)),
      ),
    );
  }
}

Using a custom (pasted) theme

To use any tweakcn/shadcn theme instead of the stock one, generate a theme.dart with the Theme generator — paste the CSS, copy the output. It defines two const FwTokens (lightTheme / darkTheme) you hand straight to FwTheme / FwAnimatedTheme:

import 'theme.dart'; // generated

FwAnimatedTheme(tokens: isDark ? darkTheme : lightTheme, child: const HomeScreen());

Every color in a FwTokens is required — there are no defaults — so a generated theme always carries all 32 roles. That's why a pasted theme round-trips with nothing dropped.

You're not forced into a theme

Semantic tokens (context.fw.colors.primary) are opt-in. The .tw utilities that take raw values need no theme at all — so you can use flutterwindcss with the raw palette and your own fonts, no FwTheme and no generated theme.dart:

// No FwTheme anywhere — raw palette + a literal font.
Text('Hi').tw
    .px(4).py(2)
    .bg(FwPalette.blue.shade600)
    .text(FwPalette.slate.shade50)
    .rounded(8)
    .font('Inter');
  • Raw palette colours, spacing, sizing, radius, borders, effects, transforms — all take literal values; no theme required.
  • font('Family') sets a literal font family; no theme required (see Fonts).
  • The theme-role sugars (fontSans/fontSerif/fontMono, roundedMd/shadowMd) prefer a theme, but fall back to the stock FwTokens.light defaults when none is present — they never force a theme on you.
  • Reading tokens where a theme is optional? Use context.fwOrNull (returns null instead of throwing). Only context.fw (and semantic tokens) require a theme.

This is also why the engine works under a MaterialApp — provide tokens through an FwThemeExtension and the same components resolve through it; bring your own fonts via the Material textTheme.

Next steps

On this page