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
Step 2 — Using a hex-based bridge (recommended)
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-genin project root ✓scripts/generate-flutter-theme.mjsconverts OKLCH → hex → Dart ✓lib/theme/app_tokens.dartgenerated and committed (or CI-generated) ✓lib/theme/app_theme.dartreferences token constants inThemeData✓MaterialAppusestheme:,darkTheme:,themeMode: ThemeMode.system✓- CI runs generation script before
flutter build✓ - Shared
themeConfigkeeps 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.