plinth_core

A design-token engine for Flutter. Colour that resolves against a WCAG contrast floor at lookup time, ramps anchored to your own brand colour, and a way to check your existing ThemeData against your tokens instead of replacing it.

This is the foundation Plinth UI is built on — but it is not only for Plinth's widgets. Most apps adopting it already have a UI layer they are not going to rewrite. It themes your widgets just as well.

flutter pub add plinth_core

Four things it does that a colour scheme does not

Colour resolved against a contrast floor, not a fixed shade

Ask for a palette colour as text, and you get a shade that clears 4.5:1 against the background it actually sits on:

final theme = context.plinth;

Text('Overdue', style: TextStyle(color: theme.readableOn('cyan', theme.surface)))

A fixed shade cannot do this, because ramps differ in intrinsic lightness. Of the thirteen built-in ramps, exactly one — violet, at 4.95:1 — clears 4.5:1 on white at shade 6, the shade components default to. cyan is 2.79:1 and yellow is 1.86:1: colours you can see but cannot read.

readableOn walks the ramp from the shade you asked for toward whichever end contrasts, and returns the first one that clears the floor — cyan becomes #157785 at 5.24:1, and violet comes back untouched because it already passed.

The floor is named by what the colour is forPlinthContrast.body (4.5:1) is the default, because that is the table cell most callers are actually painting, not the heading.

Ramps anchored to your colour

Feed in your brand colour, ask for shade 6, get your brand colour back:

PlinthTheme.generateShades(const Color(0xFFFF3B30))[6]  // 0xFFFF3B30

That sounds obvious and most generators do not do it. Normalising a base colour onto a fixed lightness curve turned #FA5252 into #E90707, and the best-matching index landed anywhere from 5 to 8 depending on hue — so there was no shade a caller could reliably ask for. Both endpoints stay put, so shade 0 is still a usable tint.

Colours named by role, not by hue

An app's palette is expense, income, brand — not red, green, blue. Declare the mapping once, with a contrast floor per role:

PlinthTheme.defaultTheme.copyWith(
  semanticColors: const {
    'expense': PlinthSemanticColor('red'),
    'income': PlinthSemanticColor('green'),
  },
)

theme.semantic('expense')      // the fill
theme.semanticText('expense')  // dark enough to read as a label
theme.semanticWash('expense')  // a tint for the row behind it

The three are deliberately different colours. A brand red that looks right as a fill is routinely unreadable as a label — #FF9500 on white is 2.20:1 — and semanticText is the hand-darkening every app ends up doing, done for you and held to a threshold.

Keep your own ThemeData

The question most teams actually have is not "how do I generate a theme", it is "are the two palettes I already have the same colours?"

test('the two palettes agree', () {
  expect(myPlinthTheme.colorSchemeDisagreements(myScheme), isEmpty);
});

You get back a list of every field where your ColorScheme and your tokens disagree, with both values. Assert it is empty in CI and the drift cannot come back silently. Comparison is exact — a tolerance would only decide for you how much drift is acceptable, which is the judgement this exists to surface rather than make.

There is a toColorScheme() for apps willing to hand over the decision. It is the less useful direction, and that is a finding from migrating a real app: a wholesale toThemeData() was the highest-priority item on this package's roadmap, and during the migration it was never reached for once.

Registering it

PlinthTheme is a ThemeExtension, so it rides along with the theme you already have:

MaterialApp(
  theme: ThemeData(extensions: [PlinthTheme.defaultTheme]),
  darkTheme: ThemeData(extensions: [PlinthTheme.darkTheme]),
)

Then read it from any BuildContext with context.plinth.

Light and dark share the same ramps. A shade's role is mirrored for the active brightness rather than the palette being swapped, so a blue button stays the same blue and only the chrome around it changes.

Spacing does not need a context

Colour genuinely varies by theme, so paying a BuildContext for it buys something. Spacing does not, so the scale ships twice:

const SizedBox(height: PlinthSpacing.md)   // still const
theme.spacing[PlinthSize.md]               // when the size is chosen at runtime

The app this was validated against had 355 spacing literals. Routing those through a theme lookup would have traded 355 const widgets for 355 runtime ones and bought nothing. Adopting tokens covers the rest of what that migration cost.

What's in the package

  • PlinthTheme — the ThemeExtension holding colour ramps, semantic roles, spacing, radius and font-size scales, plus the lookups above (readableOn, semantic, wash, contrastingOn, series).
  • PlinthSemanticColor — a role: a ramp, a shade, and the contrast floor it is held to.
  • PlinthRole, PlinthSize (xsxl), PlinthVariant, PlinthDensity — the shared vocabulary.
  • PlinthMaterialBridgecolorSchemeDisagreements, toColorScheme, toTextTheme.
  • context.plinth — the accessor.

The widgets

If you want components rather than only tokens — buttons, inputs, cards, and 114 others — plinth_components is built entirely on this package, and is the evidence that the token layer holds up at scale.

🌐 Live demo · 📖 Full documentation

Libraries

plinth_core
Design tokens and theme foundation for Plinth UI.