In Flutter, how do you add custom design tokens to ThemeData with ThemeExtension, and why must you implement copyWith and lerp?
answer
- tokens the ColorScheme has no role for
- class Tokens extends ThemeExtension<Tokens>
- ThemeData(extensions: [...])
- Theme.of(context).extension<Tokens>() is nullable
- lerp runs during theme animation
basics
~10 sSubclass ThemeExtension<T> with your token fields, implement copyWith and lerp, register instances in ThemeData.extensions for each theme, and read them with Theme.of(context).extension<T>(). lerp lets the tokens animate when the theme changes.
solid answer
~40 sWhen the design has tokens `ColorScheme` has no role for, say a points-badge color or a card radius, I write `class LoyaltyTokens extends ThemeExtension<LoyaltyTokens>` with those fields. I override `copyWith`, so callers can derive a variant, and `lerp`, which `ThemeData.lerp` calls while `AnimatedTheme` animates between themes. I register a light and a dark instance through `ThemeData(extensions: [...])`, and widgets read `Theme.of(context).extension<LoyaltyTokens>()`, which is nullable, so I add a small accessor that throws a clear error if it is missing. Extensions are stored in a map keyed by type, so one instance per type. A `lerp` that returns `other` or `this` does not crash, but the tokens then snap instead of fading with the rest of the theme.
go deeper
Know that ThemeExtension exists for app-specific tokens and that you read one with Theme.of(context).extension<T>().
Explain the two required methods, how lerp is called during AnimatedTheme transitions, and why the lookup returns a nullable value.
Design a small token set as one or a few extensions, guard missing registrations, and review that every theme variant registers every extension.
Decide which tokens belong in ColorScheme, which in extensions and which in component themes, so the design vocabulary stays small and each value has one home.
## The problem ThemeExtension solves `ThemeData` covers Material's vocabulary: color roles, text styles, component themes. Real designs add their own: a success color for a check-in, a gradient for a membership tier, a corner radius shared by every card, a spacing scale. Without a home in the theme these end up as global constants, which cannot differ between light and dark and do not animate when the theme changes. **`ThemeExtension<T>`** is Flutter's official slot for such **design tokens**. It is an abstract class in the Material library declared as `ThemeExtension<T extends ThemeExtension<T>>`, and it requires two methods: - **`copyWith()`** — return a copy with some fields replaced. - **`lerp(covariant ThemeExtension<T>? other, double t)`** — return a value interpolated between `this` and `other` at position `t` from 0.0 to 1.0. ## Defining and registering an extension ```dart import 'dart:ui' show lerpDouble; import 'package:flutter/material.dart'; @immutable class LoyaltyTokens extends ThemeExtension<LoyaltyTokens> { const LoyaltyTokens({required this.pointsBadge, required this.cardRadius}); final Color pointsBadge; final double cardRadius; @override LoyaltyTokens copyWith({Color? pointsBadge, double? cardRadius}) { return LoyaltyTokens( pointsBadge: pointsBadge ?? this.pointsBadge, cardRadius: cardRadius ?? this.cardRadius, ); } @override LoyaltyTokens lerp(LoyaltyTokens? other, double t) { if (other is! LoyaltyTokens) { return this; } return LoyaltyTokens( pointsBadge: Color.lerp(pointsBadge, other.pointsBadge, t)!, cardRadius: lerpDouble(cardRadius, other.cardRadius, t)!, ); } } ``` `lerpDouble` lives in `dart:ui` and is deliberately not re-exported by `package:flutter/material.dart`, hence the separate import. Register one instance per theme: ```dart final ThemeData light = ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF00696B)), extensions: const <ThemeExtension<dynamic>>[ LoyaltyTokens(pointsBadge: Color(0xFFB8860B), cardRadius: 16), ], ); ``` and the dark theme gets its own `LoyaltyTokens` with dark-appropriate values. ## Reading an extension `ThemeData.extension<T>()` looks the type up in the theme's `extensions` map and returns **`T?`**. It is null when the active theme never registered that type, which is easy to hit with a local `Theme` built from a fresh `ThemeData`. A common pattern is a small extension method or static helper that reads it and fails with a readable message, rather than scattering `!` across widgets. ## Why copyWith and lerp matter 1. **`lerp` drives animation.** When `MaterialApp` switches between `theme` and `darkTheme`, or a parent rebuilds with a new theme, `AnimatedTheme` interpolates with `ThemeData.lerp`. That method calls each extension's `lerp` with the matching extension from the other theme. Extensions present only in the target theme are added as they are. 2. **A lazy `lerp` still compiles.** Returning `this` or `other` without interpolating is legal, but your tokens then jump at one end of the 200 ms animation while the Material colors fade, a visible glitch. 3. **`copyWith` supports local variation.** A screen can wrap a subtree in `Theme(data: theme.copyWith(extensions: [...]), ...)` using `tokens.copyWith(cardRadius: 0)` without redefining every field. ## Storage details that bite | Detail | Consequence | |---|---| | Stored as `Map<Object, ThemeExtension<dynamic>>` keyed by `type` | Two instances of one class: the later one wins | | `ThemeData.copyWith(extensions: ...)` replaces the whole map | Passing one extension drops the others unless you pass them all | | `extension<T>()` returns `T?` | Missing registration shows up as a null error at the read site | ## Several extensions or one Nothing stops you from putting every token into a single extension, but large extensions become hard to review. A common split is by concern: one extension for extra colors, one for shapes and radii, one for spacing. Each is small, each has an obvious `lerp`, and a screen reads only what it needs. Spacing values that never differ between light and dark may not need a theme at all; a plain constants class is honest about that. Keep the rule simple: if a value can differ per theme or should animate with it, it belongs in an extension. ## ThemeExtension versus the alternatives - **Global constants** — simple, but no per-mode values and no animation. - **Overriding `ColorScheme` roles** — tempting ("use tertiary for points"), but it bends Material components that also read those roles. - **A separate `InheritedWidget` of tokens** — works, but duplicates what `Theme` already does and does not animate with it. - **`ThemeExtension`** — per-theme values, animation for free, one lookup path. ## Common mistakes - Forgetting to register the extension in `darkTheme`, so reads return null only in dark mode. - Mutable fields in the extension; themes are compared and interpolated, so they should be immutable. - Using an extension for values Material already models, such as a primary color, instead of setting `colorScheme`.
- In Flutter, what happens to a ThemeExtension whose lerp simply returns other during a light-to-dark switch?Nothing crashes. `ThemeData.lerp` still calls it at every animation tick, but because it always returns the target, the extension's colors jump to their dark values on the first frame while Material's colors fade over the theme animation. Interpolate each field with `Color.lerp`, `lerpDouble` or the relevant type's `lerp` to avoid the mismatch.
- In Flutter, why does ThemeData.copyWith(extensions: [newTokens]) sometimes make other extensions disappear?`copyWith` replaces the whole extensions map when `extensions` is non-null. If the theme had `LoyaltyTokens` and `SpacingTokens` and you pass only a new `LoyaltyTokens`, `SpacingTokens` is gone. Pass the full list, typically by reading `theme.extensions.values` and replacing the one you changed.
A ThemeExtension is like adding your own labelled drawer to a standard filing cabinet: the cabinet already has drawers for colors and fonts, and yours travels with it when the cabinet is swapped for the dark-mode one, as long as you teach the movers (lerp) how to carry it.
saying these in an interview costs you the question
- Storing custom colors as top-level constants that cannot differ in dark mode
- Returning this from lerp and assuming the theme animation still looks right
- Assuming extension<T>() never returns null
- Registering two instances of the same extension type expecting both to be kept
- Repurposing ColorScheme.tertiary to mean a loyalty-points color