dtcg_theme 0.7.1
dtcg_theme: ^0.7.1 copied to clipboard
Turns W3C Design Tokens (DTCG) files into a type-safe Flutter theme: a ThemeExtension per mode, and a build that fails on a broken reference or a pair below your WCAG target.
dtcg_theme #
Turns W3C Design Tokens (DTCG) files into a type-safe Flutter theme.
You keep the design system where designers can edit it — .tokens.json exported
from Figma Variables, Tokens Studio or Style Dictionary — and this generates the
Dart: a ThemeExtension with one constant per mode, and a real Flutter type for
every token.
final AppTokens tokens = AppTokens.of(context);
Container(
padding: EdgeInsets.all(tokens.spaceLg),
decoration: BoxDecoration(
color: tokens.colorSurface,
borderRadius: BorderRadius.circular(tokens.radiusMd),
boxShadow: tokens.elevationCard,
border: Border.fromBorderSide(tokens.strokeDivider),
),
child: Text('Hello', style: tokens.textTitle),
);
No hex codes, no magic numbers, and no runtime dependency: the generated file imports nothing but Flutter.
It also refuses to generate a theme that is wrong — an alias that points at nothing, a token one mode forgot, or a colour pair below the WCAG contrast you asked for.
Installation #
dev_dependencies:
dtcg_theme: ^0.1.0
It is a dev_dependency — it generates code and is not shipped with your app.
Requires Dart 3.8.
Usage #
Write a dtcg.yaml next to your pubspec.yaml:
base:
- tokens/palette.tokens.json # read first
modes:
light: tokens/themes/light.tokens.json
dark: tokens/themes/dark.tokens.json
output: lib/theme/app_tokens.g.dart
class_name: AppTokens
root_font_size: 16 # what one rem is worth
Then:
dart run dtcg_theme
While you are working on the tokens, leave it running instead:
dart run dtcg_theme --watch
It regenerates on every save, keeps going when a file is momentarily broken, and picks up a mode you add to the configuration without a restart.
Register the generated tokens with your theme:
MaterialApp(
theme: ThemeData(extensions: <ThemeExtension<dynamic>>[AppTokens.light]),
darkTheme: ThemeData(extensions: <ThemeExtension<dynamic>>[AppTokens.dark]),
);
modes is optional. Without it a single constant named defaults is generated.
A ColorScheme from your tokens #
Map the slots Flutter requires and a ColorScheme is generated alongside the
tokens, one per mode:
color_scheme:
primary: color.primary
onPrimary: color.on-primary
secondary: color.accent
onSecondary: color.on-accent
error: color.danger
onError: color.on-danger
surface: color.surface
onSurface: color.on-surface
outline: color.outline # any other slot is optional
ThemeData(colorScheme: AppTokens.light.colorScheme);
Brightness follows the mode name — anything containing dark is dark — and can
be stated outright:
brightness:
midnight: dark
A slot left out, pointing at a token that does not exist, or pointing at something that is not a colour stops the build and says so.
A TextTheme from your typography tokens #
text_theme:
titleLarge: text.title
bodyMedium: text.body
ThemeData(textTheme: AppTokens.light.textTheme);
Every slot is optional, but it has to be a real one — a typo lists the fifteen
TextTheme slots — and it has to point at a typography token.
Keeping the checked-in file honest #
dart run dtcg_theme --check
Fails when the generated file is out of date, which is what you want in CI.
What each token type becomes #
$type |
Dart | Interpolated by lerp |
|---|---|---|
color |
Color |
Color.lerp |
dimension |
double (logical pixels) |
lerpDouble |
number |
double |
lerpDouble |
duration |
Duration |
lerpDuration |
fontFamily |
List<String> |
snaps at the halfway point |
fontWeight |
FontWeight |
FontWeight.lerp |
cubicBezier |
Cubic (a Curve) |
snaps at the halfway point |
typography |
TextStyle |
TextStyle.lerp |
shadow |
List<BoxShadow> |
BoxShadow.lerpList |
border |
BorderSide |
BorderSide.lerp |
transition |
a generated <Class>Transition |
its own lerp |
gradient |
Gradient (linear, radial or sweep) |
Gradient.lerp |
px and rem are both understood; rem is multiplied by root_font_size,
because Flutter has no root em.
A shadow token is always a List<BoxShadow>, whether it holds one shadow or
several layers, because that is what Flutter's boxShadow takes:
{ "elevation": { "$type": "shadow", "card": { "$value": [
{ "color": "#0F172A14", "offsetY": "1px", "blur": "2px" },
{ "color": "#0F172A1F", "offsetY": "4px", "blur": "12px", "spread": "-2px" }
] } } }
Colours may be hex or the spec's object form. A hex fallback is used when
present; otherwise the components are converted to sRGB, because that is what
Flutter paints:
{ "$value": { "colorSpace": "oklch", "components": [0.72, 0.19, 25], "alpha": 1 } }
Understood colour spaces: srgb, srgb-linear, hsl, hwb, display-p3,
a98-rgb, prophoto-rgb, rec2020, lab, lch, oklab, oklch, xyz,
xyz-d50, xyz-d65. Colours outside the sRGB gamut are clipped into it — a
screen can do nothing else, and the alternative is refusing a file a designer
exported in good faith.
A gradient becomes a LinearGradient by default. DTCG carries no geometry,
so a gradient runs left to right unless the token says otherwise through the
spec's own escape hatch:
{ "brand": { "$type": "gradient", "hero": {
"$value": [
{ "color": "{palette.teal.700}", "position": 0 },
{ "color": "{palette.teal.300}", "position": 1 }
],
"$extensions": { "dtcg_theme": { "begin": "topLeft", "end": "bottomRight" } }
} } }
begin and end take the names of Flutter's Alignment constants.
The same extension chooses the shape. radial takes center and radius;
sweep takes center, startAngle and endAngle, in degrees — the
generator converts them to the radians Flutter wants:
{ "$extensions": { "dtcg_theme": {
"shape": "radial", "center": "topCenter", "radius": 0.9
} } }
Because a token may be linear in one mode and radial in another, the generated
field is typed Gradient and interpolated with Gradient.lerp, which cross
fades when the two ends are different shapes.
A border becomes a BorderSide, so it drops straight into
Border.fromBorderSide or an OutlineInputBorder:
{ "stroke": { "$type": "border", "divider": { "$value": {
"color": "{palette.slate.300}", "width": "1px", "style": "solid"
} } } }
style must be solid. Flutter's BorderSide draws nothing else — a dashed
or dotted border needs a custom painter, which no generated constant can be, so
one stops the build instead of quietly coming out solid. strokeStyle on its
own is refused for the same reason, with that reason in the message.
A transition is the one type Flutter has no class for, so the generator
writes a small immutable one beside the tokens, named after your token class:
{ "motion": { "$type": "transition", "theme": { "$value": {
"duration": "{motion.duration.theme}",
"delay": "0ms",
"timingFunction": "{motion.easing.standard}"
} } } }
MaterialApp(
themeAnimationDuration: AppTokens.light.motionTheme.duration,
themeAnimationCurve: AppTokens.light.motionTheme.curve,
);
Three loose tokens let the duration and the curve drift apart in review; one token cannot.
Retiring a token #
$deprecated becomes @Deprecated on the generated field, carrying the reason
straight into the IDE:
{ "color": { "brand-legacy": {
"$value": "{palette.teal.700}",
"$deprecated": "Renamed to color.primary in v2. Kept for one release."
} } }
A group may deprecate everything under it, and a token inside may give its own
reason. One caveat worth knowing: the generated file lives in your package,
so the analyser only reports uses of it when
deprecated_member_use_from_same_package is enabled in your
analysis_options.yaml. Without that, the annotation still strikes the member
through in the IDE, but nothing fails the build.
Aliases work everywhere, including inside composite tokens:
{
"color": { "$type": "color", "brand": { "$value": "#0F766E" } },
"semantic": { "primary": { "$value": "{color.brand}" } }
}
A token that is only an alias takes the type of what it points at, so the
semantic layer needs no $type of its own.
Figma Variables and Tokens Studio #
Figma Variables exports plain DTCG already — $value, $type, {alias}
references — and needs nothing special. Tokens Studio writes either its own
legacy shape (value, type, description) or DTCG, depending on how it is
configured; both are read. Exports are normalised before parsing, so a file
straight out of either plugin usually works:
- unprefixed
typeandvaluekeys, alongside$typeand$value; - Tokens Studio type names —
spacing,sizing,borderRadius,fontSizes,fontFamilies,fontWeights,lineHeights,letterSpacing,opacity,boxShadow— mapped onto DTCG types, and Figma'sFLOATontonumber; - weight names as they are written there:
SemiBoldreads assemi-bold; boxShadowoffsets given asxandy;- a percentage line height (
"140%") as the unitless number DTCG means, andAUTOdropped rather than guessed; $themesand$metadataignored.
A token set in Tokens Studio is effectively one JSON file, so the usual layout
— global.json, light.json, dark.json — needs nothing but base and
modes. When the sets are instead exported into one file, it asks which to
read, and a configured path names it after a #:
base:
- tokens.json#global
modes:
light: tokens.json#light
dark: tokens.json#dark
Two things are refused rather than guessed. Maths ("{spacing.base} * 2",
"rgba({color.brand}, 0.5)") — this reads token files, it does not evaluate
them, so resolve it in the design tool and export the result. And inner
shadows, because Flutter's BoxShadow only draws outside a box.
These dialects are implemented from their documented formats and from exports described in public issues; they have not been verified against a real export from either tool. If yours fails, the error names the file and the token path — please open an issue with it.
Contrast, checked before the app runs #
A design system that generates its own theme can also prove the theme is readable. List the pairs that matter and every mode is checked against WCAG 2.1 at build time:
contrast:
- foreground: color.on-surface
background: color.surface
level: AAA
- foreground: color.muted
background: color.surface
level: AA
level takes AA (4.5:1), AA-large (3:1), AAA (7:1), AAA-large (4.5:1),
or a ratio of your own. A failure names the mode, the pair and both numbers:
TokenError at <contrast> → color.outline: in mode "light", "color.outline" on
"color.surface" has a contrast ratio of 1.48:1, below the 4.5:1 that AA needs
A translucent foreground is composited over its background first, so it is measured as it will actually be seen rather than as its hex claims.
This is the check that catches the pair which is fine in light and grey on grey in dark — the one nobody notices until a user reports it.
What it refuses to build #
The point of generating code is to fail before the app runs, so the build stops on:
- a reference to a token that does not exist, naming the reference;
- an alias cycle, printing the whole loop;
- a token with no
$typethat no group and no alias can supply; - a value that does not match its type, saying what was expected;
- a token defined in one mode and missing from another, naming the tokens;
- a colour pair below the contrast level you asked for, in any mode;
- the same token holding different types in different modes;
- two tokens whose names would collide as Dart fields.
Every message names the file and the token path.
Not supported yet #
Honest list, rather than a surprise at build time:
- Cupertino themes; the generated class is a
ThemeExtension, which works in both, but nothing maps tokens ontoCupertinoThemeDatafor you; - dashed and dotted borders, and inner shadows — Flutter draws neither;
strokeStyleas a token type of its own; it is read inside aborder;- gradient stops given as anything but a position between 0 and 1;
build_runner; this is a CLI, deliberately, so the package can stay free of the analyzer and never fight the Flutter SDK over dependency versions.
Example #
/example is a Flutter app whose entire look — colours, spacing, radii, type
scale, shadow and even the light/dark crossfade duration and curve — comes from
the token files in example/tokens, with no hard-coded values in the UI code.
License #
MIT.