The pain this guide solves

Your web app uses salt-theme-gen and your Flutter app uses a completely separate set of hardcoded colors in a theme.dart file. Every brand update requires two separate changes, and the two systems drift apart within weeks.

salt-theme-gen with Flutter

The bridge approach

Flutter uses Dart — there is no npm, no CSS variables, no JavaScript runtime in a Flutter app. You cannot call generateTheme() from Dart directly.

The solution is a build-time token bridge:

Node.js script
  → calls generateTheme()
  → writes theme.dart (Dart constants)
  → Flutter app imports theme.dart

You run the script once during development or in CI. Flutter consumes the output as a static Dart file. Both your web app and Flutter app read from the same source of truth — generateTheme() — and are always in sync.


Step 1 — Token generation script

In your project root (or a shared scripts/ folder), create scripts/generate-flutter-theme.mjs:

import { generateTheme } from 'salt-theme-gen';
import { writeFileSync, mkdirSync } from 'node:fs';

const theme = generateTheme({
  preset: 'ocean',
  spacing: 'default',
  radius: 'default',
  fontSize: 'default',
});

// Parse OKLCH to RGB hex for Flutter's Color(0xFF...)
function oklchToHex(oklch) {
  // oklch strings are already valid CSS — use a regex to extract values
  // For a production bridge, use a color conversion library
  // Here we return the oklch string directly for use with flutter_oklch or similar
  return oklch;
}

function colorToDart(name, value) {
  // Flutter Color from CSS oklch string — requires flutter_color_parser or similar
  // For simplicity, we'll use a hex conversion approach
  return `  static const Color ${name} = Color(0xFF${value.replace('#', '')});`;
}

function spacingToDart(name, value) {
  return `  static const double ${name} = ${value};`;
}

function radiusToDart(name, value) {
  return `  static const double ${name} = ${value};`;
}

function fontSizeToDart(name, value) {
  return `  static const double ${name} = ${value};`;
}

// Generate the Dart file
const light = theme.light;
const dark  = theme.dark;

function camel(str) {
  return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}

function colorsBlock(mode, className) {
  const lines = [`class ${className} {`];

  for (const [k, v] of Object.entries(mode.colors)) {
    // Convert OKLCH to hex using the css-color-converter approach
    // For this bridge, we store the color value string
    lines.push(`  // ${v}`);
    lines.push(`  static const String ${camel(k)} = '${v}';`);
  }

  for (const [intent, states] of Object.entries(mode.states)) {
    for (const [state, val] of Object.entries(states)) {
      lines.push(`  static const String ${camel(intent)}${state.charAt(0).toUpperCase() + state.slice(1)} = '${val}';`);
    }
  }

  lines.push('}');
  return lines.join('\n');
}

function scalesBlock(mode) {
  const spacingLines = Object.entries(mode.spacing)
    .map(([k, v]) => `  static const double ${k} = ${v};`);

  const radiusLines = Object.entries(mode.radius)
    .map(([k, v]) => `  static const double radius${k.charAt(0).toUpperCase() + k.slice(1)} = ${v};`);

  const fontLines = Object.entries(mode.fontSizes)
    .map(([k, v]) => `  static const double font${k.toUpperCase().replace('-', '')} = ${v};`);

  return [
    'class AppSpacing {',
    ...spacingLines,
    '}',
    '',
    'class AppRadius {',
    ...radiusLines,
    '}',
    '',
    'class AppFontSize {',
    ...fontLines,
    '}',
  ].join('\n');
}

const dart = `// Generated by salt-theme-gen — do not edit manually
// Run: node scripts/generate-flutter-theme.mjs

${colorsBlock(light, 'AppColorsLight')}

${colorsBlock(dark, 'AppColorsDark')}

${scalesBlock(light)}
`;

const outDir  = 'lib/theme';
const outFile = `${outDir}/app_tokens.dart`;

mkdirSync(outDir, { recursive: true });
writeFileSync(outFile, dart, 'utf8');
console.log(`✓ Flutter tokens written to ${outFile}`);

Run it from your project root:

node scripts/generate-flutter-theme.mjs

OKLCH is not natively supported in Flutter. For a production bridge, convert OKLCH to hex before writing the Dart file. Install a color conversion package:

npm install culori

Update the script to convert colors:

import { generateTheme } from 'salt-theme-gen';
import { formatHex, parse } from 'culori';
import { writeFileSync, mkdirSync } from 'node:fs';

const theme = generateTheme({ preset: 'ocean' });

function toHex(oklchStr) {
  try {
    const color = parse(oklchStr);
    const hex = formatHex(color);
    return hex ? hex.replace('#', '') : 'FFFFFF';
  } catch {
    return 'FFFFFF';
  }
}

function dartColor(hex) {
  // Flutter Color constructor takes 0xAARRGGBB
  return `const Color(0xFF${hex.toUpperCase()})`;
}

function camel(str) {
  return str.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
}

function colorsClass(mode, className) {
  const lines = [`class ${className} {`];

  for (const [k, v] of Object.entries(mode.colors)) {
    const hex = toHex(v);
    lines.push(`  static const Color ${camel(k)} = ${dartColor(hex)};`);
  }

  for (const [intent, states] of Object.entries(mode.states)) {
    for (const [state, val] of Object.entries(states)) {
      const hex = toHex(val);
      const name = `${camel(intent)}${state.charAt(0).toUpperCase() + state.slice(1)}`;
      lines.push(`  static const Color ${name} = ${dartColor(hex)};`);
    }
  }

  lines.push('}');
  return lines.join('\n');
}

function spacingClass(mode) {
  const lines = ['class AppSpacing {'];
  for (const [k, v] of Object.entries(mode.spacing))
    lines.push(`  static const double ${k} = ${v};`);
  lines.push('}');
  return lines.join('\n');
}

function radiusClass(mode) {
  const lines = ['class AppRadius {'];
  for (const [k, v] of Object.entries(mode.radius))
    lines.push(`  static const double ${k} = ${v};`);
  lines.push('}');
  return lines.join('\n');
}

function fontSizeClass(mode) {
  const lines = ['class AppFontSize {'];
  for (const [k, v] of Object.entries(mode.fontSizes))
    lines.push(`  static const double ${k} = ${v};`);
  lines.push('}');
  return lines.join('\n');
}

const dart = [
  '// Generated by salt-theme-gen — do not edit manually',
  '// Run: node scripts/generate-flutter-theme.mjs',
  "import 'package:flutter/material.dart';",
  '',
  colorsClass(theme.light, 'AppColorsLight'),
  '',
  colorsClass(theme.dark, 'AppColorsDark'),
  '',
  spacingClass(theme.light),
  '',
  radiusClass(theme.light),
  '',
  fontSizeClass(theme.light),
].join('\n');

mkdirSync('lib/theme', { recursive: true });
writeFileSync('lib/theme/app_tokens.dart', dart, 'utf8');
console.log('✓ lib/theme/app_tokens.dart written');

Step 3 — Generated Dart file

Running the script produces lib/theme/app_tokens.dart:

// Generated by salt-theme-gen — do not edit manually
import 'package:flutter/material.dart';

class AppColorsLight {
  static const Color primary    = Color(0xFF2563EB);
  static const Color secondary  = Color(0xFFD97706);
  static const Color background = Color(0xFFF9FAFB);
  static const Color surface    = Color(0xFFFFFFFF);
  static const Color text       = Color(0xFF111827);
  static const Color muted      = Color(0xFF6B7280);
  static const Color border     = Color(0xFFE5E7EB);
  static const Color danger     = Color(0xFFDC2626);
  static const Color success    = Color(0xFF16A34A);
  static const Color warning    = Color(0xFFD97706);
  static const Color info       = Color(0xFF0284C7);
  static const Color onPrimary  = Color(0xFFFFFFFF);
  static const Color onDanger   = Color(0xFFFFFFFF);
  static const Color onSuccess  = Color(0xFFFFFFFF);
  // ... all 21 colors + 32 state colors
}

class AppColorsDark {
  static const Color primary    = Color(0xFF3B82F6);
  static const Color background = Color(0xFF111827);
  static const Color surface    = Color(0xFF1F2937);
  // ... dark variants
}

class AppSpacing {
  static const double xs  = 4;
  static const double sm  = 8;
  static const double md  = 12;
  static const double lg  = 16;
  static const double xl  = 24;
  static const double xxl = 40;
}

class AppRadius {
  static const double sm   = 4;
  static const double md   = 8;
  static const double lg   = 14;
  static const double xl   = 20;
  static const double pill = 9999;
}

class AppFontSize {
  static const double xs   = 11;
  static const double sm   = 13;
  static const double md   = 15;
  static const double lg   = 18;
  static const double xl   = 22;
  static const double xxl  = 28;
  static const double xxl3 = 38;
}

Never edit this file manually — it is regenerated by the script.


Step 4 — ThemeData in Flutter

Create lib/theme/app_theme.dart:

import 'package:flutter/material.dart';
import 'app_tokens.dart';

class AppTheme {
  static ThemeData get light => ThemeData(
    useMaterial3: true,
    brightness: Brightness.light,
    colorScheme: ColorScheme.light(
      primary:   AppColorsLight.primary,
      secondary: AppColorsLight.secondary,
      surface:   AppColorsLight.surface,
      error:     AppColorsLight.danger,
      onPrimary: AppColorsLight.onPrimary,
      onError:   AppColorsLight.onDanger,
    ),
    scaffoldBackgroundColor: AppColorsLight.background,
    cardColor:  AppColorsLight.surface,
    dividerColor: AppColorsLight.border,
    textTheme: _textTheme(AppColorsLight.text, AppColorsLight.muted),
    elevatedButtonTheme: _buttonTheme(
      AppColorsLight.primary,
      AppColorsLight.onPrimary,
    ),
    cardTheme: CardTheme(
      color: AppColorsLight.surface,
      elevation: 0,
      shape: RoundedRectangleBorder(
        borderRadius: BorderRadius.circular(AppRadius.lg),
        side: BorderSide(color: AppColorsLight.border),
      ),
    ),
    inputDecorationTheme: InputDecorationTheme(
      filled: true,
      fillColor: AppColorsLight.surface,
      border: OutlineInputBorder(
        borderRadius: BorderRadius.circular(AppRadius.md),
        borderSide: BorderSide(color: AppColorsLight.border),
      ),
      focusedBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(AppRadius.md),
        borderSide: BorderSide(color: AppColorsLight.primary, width: 2),
      ),
    ),
  );

  static ThemeData get dark => ThemeData(
    useMaterial3: true,
    brightness: Brightness.dark,
    colorScheme: ColorScheme.dark(
      primary:   AppColorsDark.primary,
      secondary: AppColorsDark.secondary,
      surface:   AppColorsDark.surface,
      error:     AppColorsDark.danger,
      onPrimary: AppColorsDark.onPrimary,
    ),
    scaffoldBackgroundColor: AppColorsDark.background,
    cardColor:  AppColorsDark.surface,
    dividerColor: AppColorsDark.border,
    textTheme: _textTheme(AppColorsDark.text, AppColorsDark.muted),
    elevatedButtonTheme: _buttonTheme(
      AppColorsDark.primary,
      AppColorsDark.onPrimary,
    ),
  );

  static TextTheme _textTheme(Color text, Color muted) => TextTheme(
    displayLarge:  TextStyle(fontSize: AppFontSize.xxl3, fontWeight: FontWeight.w800, color: text),
    headlineMedium:TextStyle(fontSize: AppFontSize.xxl,  fontWeight: FontWeight.w700, color: text),
    titleLarge:    TextStyle(fontSize: AppFontSize.xl,   fontWeight: FontWeight.w600, color: text),
    bodyLarge:     TextStyle(fontSize: AppFontSize.md,   color: text, height: 1.6),
    bodyMedium:    TextStyle(fontSize: AppFontSize.sm,   color: muted, height: 1.5),
    labelSmall:    TextStyle(fontSize: AppFontSize.xs,   color: muted),
  );

  static ElevatedButtonThemeData _buttonTheme(Color bg, Color fg) =>
    ElevatedButtonThemeData(
      style: ElevatedButton.styleFrom(
        backgroundColor: bg,
        foregroundColor: fg,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(AppRadius.md),
        ),
        padding: EdgeInsets.symmetric(
          horizontal: AppSpacing.lg,
          vertical: AppSpacing.sm,
        ),
        textStyle: const TextStyle(
          fontSize: 15,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
}

Step 5 — Wire into MaterialApp

// lib/main.dart
import 'package:flutter/material.dart';
import 'theme/app_theme.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'My App',
      theme:      AppTheme.light,
      darkTheme:  AppTheme.dark,
      themeMode:  ThemeMode.system, // follows OS preference
      home: const HomeScreen(),
    );
  }
}

ThemeMode.system automatically switches between AppTheme.light and AppTheme.dark based on the device OS setting — no additional code needed.


Using tokens in widgets

import 'package:flutter/material.dart';
import '../theme/app_tokens.dart';

class TokenCard extends StatelessWidget {
  final String title;
  final String body;

  const TokenCard({super.key, required this.title, required this.body});

  @override
  Widget build(BuildContext context) {
    final isDark = Theme.of(context).brightness == Brightness.dark;
    final colors = isDark ? AppColorsDark() : AppColorsLight();

    return Container(
      padding: const EdgeInsets.all(AppSpacing.xl),
      decoration: BoxDecoration(
        color: isDark ? AppColorsDark.surface : AppColorsLight.surface,
        border: Border.all(
          color: isDark ? AppColorsDark.border : AppColorsLight.border,
        ),
        borderRadius: BorderRadius.circular(AppRadius.lg),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            title,
            style: Theme.of(context).textTheme.titleLarge,
          ),
          SizedBox(height: AppSpacing.sm),
          Text(
            body,
            style: Theme.of(context).textTheme.bodyMedium,
          ),
        ],
      ),
    );
  }
}

Add to CI pipeline

In your CI workflow, run the generation script before flutter build:

# .github/workflows/flutter.yml
- name: Generate theme tokens
  run: node scripts/generate-flutter-theme.mjs

- name: Flutter build
  run: flutter build apk --release

This guarantees the Dart token file is always regenerated from the latest generateTheme() call before the Flutter app is compiled.


Keeping web and Flutter in sync

The key insight: both your web app and Flutter app call generateTheme() with the same options. As long as you run both generation scripts from the same configuration, the tokens are always identical.

// Shared config — import in both scripts
export const themeConfig = {
  preset: 'ocean',
  spacing: 'default',
  radius: 'default',
  fontSize: 'default',
};
// scripts/generate-web-theme.mjs
import { themeConfig } from './theme-config.mjs';
const theme = generateTheme(themeConfig);
// → writes CSS variables

// scripts/generate-flutter-theme.mjs
import { themeConfig } from './theme-config.mjs';
const theme = generateTheme(themeConfig);
// → writes Dart constants

One config file. Two output formats. One brand.


Checklist

  • npm install culori salt-theme-gen in project root ✓
  • scripts/generate-flutter-theme.mjs converts OKLCH → hex → Dart ✓
  • lib/theme/app_tokens.dart generated and committed (or CI-generated) ✓
  • lib/theme/app_theme.dart references token constants in ThemeData
  • MaterialApp uses theme:, darkTheme:, themeMode: ThemeMode.system
  • CI runs generation script before flutter build
  • Shared themeConfig keeps web and Flutter tokens identical ✓

Committing the generated file: Whether to commit app_tokens.dart is a team decision. Committing it means Flutter devs without Node.js can build the app. Not committing it means CI always regenerates from source. Both are valid — just document which approach your team uses.