dtcg_theme 0.7.1 copy "dtcg_theme: ^0.7.1" to clipboard
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 #

Pub Version License: MIT pub points likes

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 type and value keys, alongside $type and $value;
  • Tokens Studio type names — spacing, sizing, borderRadius, fontSizes, fontFamilies, fontWeights, lineHeights, letterSpacing, opacity, boxShadow — mapped onto DTCG types, and Figma's FLOAT onto number;
  • weight names as they are written there: SemiBold reads as semi-bold;
  • boxShadow offsets given as x and y;
  • a percentage line height ("140%") as the unitless number DTCG means, and AUTO dropped rather than guessed;
  • $themes and $metadata ignored.

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 $type that 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 onto CupertinoThemeData for you;
  • dashed and dotted borders, and inner shadows — Flutter draws neither;
  • strokeStyle as a token type of its own; it is read inside a border;
  • 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.

0
likes
160
points
109
downloads

Documentation

API reference

Publisher

verified publisherbomsamdi.com

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#design-tokens #theme #codegen #figma #accessibility

License

MIT (license)

Dependencies

args, yaml

More

Packages that depend on dtcg_theme