A design-token engine for Flutter — and 115 components built on it, so you can see that it holds up.
- Colour resolves against a WCAG 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.
- Ramps are anchored to your own colour. Feed in
#FF3B30, ask for shade 6, get#FF3B30back — not something near it. - Name colours by role —
expense,income,brand— rather than by hue, each with its own contrast floor. - Keep your existing
ThemeData.colorSchemeDisagreementsreports where yourColorSchemeand your tokens disagree, instead of asking you to replace one with the other.
Mantine-inspired rather than a port: 112 components track
@mantine/core because its answer was right, and three exist because
Flutter asked a question a web library never had to.
🌐 Live demo · 🎛️ Widgetbook gallery
📖 Component reference · 🧭 Adopting tokens · 🧱 Showcase blocks · 🧪 Testing guide · 🔍 Pre-1.0 audit · 🗺️ Roadmap
📝 The semantics tree was right. The link was unusable. — a Flutter accessibility bug no semantics test can find, and the ten-line test that catches the category.
Both are the real apps in this repo, built for the web and deployed on
every push to main by .github/workflows/pages.yml. The demo is the
curated tour — every component, plus the composed blocks, each with its
source. The gallery is the exhaustive one: every state of every
component, with knobs to poke at them.
Testing either demo with a screen reader? Press
Tabonce from the top of the page andEnterbefore anything else.Flutter web builds its accessibility tree only on demand, behind an invisible "Enable accessibility" button, and until that is activated the semantics are not in the page at all — every control is silent. The web gives no way to detect a screen reader (deliberately: it would fingerprint users and disclose a disability to every site), so Flutter uses a button no mouse will ever find and every screen reader will, and treats reaching it as the signal.
These demos are left at that default on purpose, so they behave like any Flutter web app rather than flattering this one. An app that would rather always pay the cost can call
SemanticsBinding.instance.ensureSemantics()at startup and skip the button entirely.It needs redoing after every reload. Full instructions, including how to confirm it worked without a screen reader, are in B0C_SCREEN_READER_PASS.md; what was heard when it was run is in B0C_FINDINGS.md.
All three packages are published on pub.dev. plinth_components is the
one most apps want, and it brings plinth_core (the theme) with it:
flutter pub add plinth_componentsAdd plinth_hooks as well if you use any of the overlay components —
PlinthModal, PlinthDrawer, PlinthPopover, PlinthMenu, and
PlinthDialog each take a PlinthDisclosureController from it:
flutter pub add plinth_hooksEvery component resolves its colors, spacing, and radii from a
PlinthTheme registered as a ThemeData extension. This is the one
required step — skip it and the components have no tokens to read.
import 'package:flutter/material.dart';
import 'package:plinth_components/plinth_components.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: ThemeData(extensions: [PlinthTheme.defaultTheme]),
darkTheme: ThemeData(extensions: [PlinthTheme.darkTheme]),
home: const SignUpPage(),
);
}
}Registering darkTheme is all dark mode takes. The palette ramps are
shared between the two — a blue button is the same blue either way —
and only the neutral chrome inverts.
class SignUpPage extends StatelessWidget {
const SignUpPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: SizedBox(
width: 360,
child: PlinthCard(
child: PlinthStack(
children: [
const PlinthTitle('Create an account', order: 3),
const PlinthTextInput(
label: 'Email',
placeholder: 'you@example.com',
),
const PlinthTextInput(label: 'Password', obscureText: true),
PlinthButton(
onPressed: () {},
fullWidth: true,
child: const Text('Sign up'),
),
],
),
),
),
),
);
}
}PlinthTheme.defaultTheme is a starting point, not a fixed identity.
copyWith covers the usual reskin:
final theme = PlinthTheme.defaultTheme.copyWith(
primaryColor: 'violet',
defaultRadius: PlinthSize.lg,
);Every color: prop on a component is a key into the palette ramps
('blue', 'red', 'teal', and ten more), and primaryColor is the
key components fall back to when you don't pass one. Sizes and spacing
go through the shared PlinthSize scale (xs … xl) rather than raw
pixel values, so spacing, radius, and fontSizes can be retuned in
one place.
docs/COMPONENTS.md is the full prop reference
for all 115 components, and the theme-token table there explains which
color method to reach for (shaded, contrastingOn, readableOn)
when you build your own widget on the same foundation.
Sooner or later you'll want to patch plinth_core locally — to debug
something, or to try a token change before asking for it. Adding it as
a path dependency does not work, and the error is not obviously
about what it is about:
Because your_app depends on plinth_components ^1.0.1 which
depends on plinth_core ^1.0.1, plinth_core from hosted is
required. So, because your_app depends on plinth_core from path,
version solving failed.
This is the first thing an adopter hits, before writing any Dart. Use
dependency_overrides, which is root-only and exists for exactly this:
# your_app/pubspec.yaml
dependencies:
plinth_components: ^1.0.1
dependency_overrides:
plinth_core:
path: ../plinth_ui/packages/plinth_core
# If you're patching the widgets too, override both — otherwise
# plinth_components still comes from pub.dev and won't see your
# changes.
plinth_components:
path: ../plinth_ui/packages/plinth_componentsLoosening our version constraints would not have helped, which is
worth saying because it is the natural first guess. Pub allows one
source per package in a resolution, so a path dependency in your app
conflicts with the hosted one plinth_components pulls in no matter how
wide the version range is — plinth_core: any fails the same way.
dependency_overrides is the mechanism, not a workaround for a
constraint we set too tightly.
Remove the overrides before you publish or ship; they are ignored for anyone consuming your package, so an override that silently became load-bearing is a bug you won't see until someone else builds your code.
Encouraging data point from doing this for real: plinth_components 0.16.1, compiled against plinth_core 0.2.0, ran clean against
plinth_core 1.0.0-beta.1 — a major version later. The core's API has
stayed genuinely backward compatible.
plinth_ui/
melos.yaml # workspace config
.github/workflows/ci.yml # analyze + format + test on every push/PR (see below)
.github/workflows/regenerate-goldens.yml # manual — regenerates goldens on Linux to match CI
packages/
plinth_core/ # PlinthTheme, design tokens (colors, spacing, radius)
plinth_components/ # Widgets — see docs/COMPONENTS.md for the full,
│ up-to-date list with props for each one
plinth_hooks/ # PlinthDisclosureController (useDisclosure equivalent)
example/ # Showcase app — run this to see the components live
widgetbook/ # Isolated component gallery (manual/non-codegen setup)
docs/
ROADMAP.md # The single plan — who it is for, what is
│ done, what is next, and what is declined
COMPONENTS.md # Per-component prop reference
ADOPTING_TOKENS.md # What adopting tokens costs an app, and
│ the five patterns every adopter hits
TESTING.md # How to verify this locally — SDK setup
│ through golden tests
PUBLISHING.md # Release order, and the mistake that has
│ already shipped once
SHOWCASE.md # The example app's composed blocks
ADOPTION_REQUIREMENTS.md # What a real app needed and could not get.
│ All 19 closed
PHASE_MINUS_1_FINDINGS.md # What happened when plinth_core was tried
│ against that app — including the
│ predictions it falsified
APP_VALIDATION_PLAN.md # That app as a standing harness
PRE_1_0_AUDIT.md # How complete each component is
B0C_SCREEN_READER_PASS.md # The one accessibility task a person has
│ to run
Clone, bootstrap, and run one of the two demo apps:
git clone https://github.com/ylahav/plinth_ui.git
cd plinth_ui
# Melos manages the three packages as a single workspace (one-time install)
dart pub global activate melos
# Links the packages to each other and fetches dependencies
melos bootstrap
# The showcase app — every component, one section at a time
cd example && flutter run
# ...or the Widgetbook gallery — each component's variants as separate use cases
cd ../widgetbook && flutter runA component change touches more than its widget file. Every component commit in this repo follows the same round, and skipping a step is what leaves the docs drifting from the code:
- The widget in
packages/plinth_components/lib/src/widgets/ - Its
exportinpackages/plinth_components/lib/plinth_components.dart - Widget tests in
packages/plinth_components/test/ - Widgetbook use cases in
widgetbook/lib/main.dart, including a knob-driven Playground - The prop reference in
docs/COMPONENTS.md(and remove the entry from its Coming soon list if you just built one) CHANGELOG.mdand a version bump inpubspec.yaml
Then verify exactly what CI verifies, from the repo root:
melos run format # fails if anything needs reformatting
melos run analyze
melos run testmelos run format-fix applies the formatting rather than just checking
it. One expected local failure: the PlinthButton golden test fails on
Windows and macOS by design, because the reference images are rendered
on Linux to match CI. Diffs under ~1% there are platform rendering
noise, not regressions — read docs/TESTING.md §7 before chasing one,
and don't regenerate goldens locally (there's a
regenerate-goldens.yml workflow that does it on Linux).
The example app demonstrates most components — run it to see them
live, or browse docs/COMPONENTS.md for the complete reference without
running anything. A persistent sidebar (wide screens) or drawer menu
(narrow screens) selects one section at a time, and "Show code"
on each section reveals the exact source behind that demo, with a copy
button.
One at a time, rather than the single long page this used to be. With all 115 sections mounted, reaching one control meant Tab-ing past 115 sidebar entries and then every control above it — hundreds of stops, on the app that exists to invite exactly that inspection. Selecting a section now also moves focus to the top of it, so the next Tab steps into the component. "All components" at the top of the sidebar restores the whole tour for anyone who wants to scroll it.
Those snippets live as hand-maintained
string literals in example/lib/src/demo_code.dart (and
src/showcase/examples_code.dart for the composed examples) — they
are not extracted at build time, so nothing enforces that they
match the widgets they mirror. Update the snippet alongside any demo
you edit; a stale one still compiles and still renders a working code
panel, which is what makes the drift easy to miss.
web/ is checked in for both example/ and widgetbook/, which is
what lets the Pages workflow build them. The desktop folders
(windows/, macos/, linux/) are not — Flutter generates those from
a template tied to your exact SDK version, so create them locally if
you want a desktop build:
cd example
flutter create --platforms=windows,macos,linux .That adds the platform folders without touching lib/main.dart or
pubspec.yaml. After that:
# Web
flutter run -d chrome
# Windows (on a Windows machine)
flutter run -d windows
# macOS (on a macOS machine)
flutter run -d macos
# Linux (on a Linux machine)
flutter run -d linuxDesktop targets only build on their matching OS — you can't build a
Windows .exe from macOS, for instance. Web builds anywhere. To
produce a shareable web build (e.g. for GitHub Pages):
flutter build web --release
# output lands in example/build/web/ — serve that directory staticallyThe Widgetbook app organizes the same components into browsable categories (Buttons & Actions, Forms, Navigation, Feedback, Overlays, Data Display, Surfaces, Layout & Typography) with each variant as its own use case in the sidebar.
-
PlinthTheme(plinth_core) — aThemeExtensionholding color ramps (curve-based 10-shade generator with non-linear lightness stops + shade-dependent saturation), spacing, radius, and font-size scales, plus surface/text/border tokens for the neutral chrome the ramps don't cover. -
Light and dark themes — register
PlinthTheme.darkThemealongsidedefaultThemeand the whole library follows. The ramps are shared between them (a blue button is the same blue either way); only the chrome inverts. See docs/COMPONENTS.md § Theme tokens. -
115 components across Primitives, Forms, Feedback, Data Display, Navigation, Surfaces, and Overlays — full list with props in docs/COMPONENTS.md, kept current every round rather than duplicated here. A few architectural patterns worth knowing up front:
- Several components deliberately wrap Flutter's own widgets
rather than reimplementing them —
PlinthSlider(Slider),PlinthTooltip(Tooltip),PlinthTable(Table, notDataTable) — because drag handling, accessibility, and layout correctness aren't worth re-deriving from scratch. - Overlays (
PlinthModal,PlinthDrawer,PlinthPopover,PlinthMenu) share aPlinthDisclosureController(plinth_hooks) for open/close state. Modal/Drawer additionally share an internalPlinthOverlayHost; Popover/Menu use their own show/hide mechanics since they aren't route-based. - Controlled-component pairs split visual indicator from content
ownership —
PlinthTabs/PlinthTabView,PlinthStepper(visual only),PlinthSegmentedControlall leave state ownership to the caller rather than managing it internally.
- Several components deliberately wrap Flutter's own widgets
rather than reimplementing them —
-
Widgetbook gallery (
widgetbook/) — manual (non-codegen) registration of every component's key states as browsable use cases, with a knob-driven Playground for 107 of the 115 components alongside the static variant grids (the two answer different questions: a playground explores combinations, a grid compares options side by side). A smoke test builds all 225 use cases in CI, so a page that compiles but throws on render is caught before anyone opens it.web/is checked in —cd widgetbook && flutter run -d chrome. -
Tests — pure-logic tests for
PlinthTheme's shade generator andPlinthDisclosureController, a golden (visual regression) test suite forPlinthButton(variants, a color override, disabled state, all sizes), and widget behavior tests across 65 test files. Every public component has at least one test — the only untested class isPlinthOverlayHost, which is internal and exercised indirectly throughPlinthModalHost/PlinthDrawerHost.Coverage is behavioral rather than visual, so it pins down what a component does, not how it looks: border-colour precedence on
PlinthTextInput(error beats focus), overlay teardown across repeated open/close cycles, controlled components reporting the value they would become rather than changing it themselves, and the fallback paths that only appear when something fails —PlinthAvatar's missing-image chain,PlinthImage's broken-URL icon. Notable coverage:PlinthPagination's ellipsis-collapse logic at large page counts,PlinthAccordion's single/multiple-open modes,PlinthCopyButton's clipboard write + timer-based revert,PlinthMenu/PlinthNotification's overlay-dismissal behavior. Seedocs/TESTING.mdfor the full breakdown and how to run them. Golden coverage spansPlinthButton,PlinthTextInput(focus and error borders),PlinthAlert(colour tinting), and the components that place things by arithmetic rather than by layout —PlinthRollingNumber, the two arc progress widgets,PlinthAngleSlider,PlinthOverflowListand the colour sliders. 43 images. That last group exists because a behaviour test can only confirm the number that went in:PlinthRollingNumbershipped rendering 58,210 as nearly 68,210 while 494 tests passed. The reference images are Linux-rendered to match CI, so they can only pass on Linux.dart_test.yamltags them and excludes that tag on Windows and macOS, which is why a localflutter testis green: they run in CI, where their result means something, and are skipped where it wouldn't. Seedocs/TESTING.md§7. -
CI is green —
.github/workflows/ci.ymlpasses end-to-end (bootstrap, format, analyze, test) on GitHub Actions as of this writing. Getting there surfaced two real, non-obvious issues worth knowing about if you add more golden tests or touch formatting: golden images are platform-sensitive (seedocs/TESTING.md§7 — regenerate on Linux via.github/workflows/regenerate-goldens.yml, don't loosen tolerances), anddart formatneeds to actually be run locally at least once (melos run format-fix) since code written without a live SDK won't match its output exactly.
Before locking in any version number, every component's public
constructor was audited against the others for naming/type drift. One
real inconsistency was found and fixed: PlinthMark and PlinthCode
used text for their single positional content field while
PlinthBadge/PlinthKbd/PlinthAnchor (the same "wraps one short
string" pattern) used label — both renamed to label for consistency.
Since the field is positional-only, this didn't require updating any
call sites (PlinthMark('...') is unaffected by the internal field
name). PlinthText.data was deliberately left as-is — it intentionally
mirrors Flutter's own built-in Text.data, not an inconsistency.
Several other apparent differences were checked and confirmed intentional rather than bugs:
onTap(PlinthNavLink,PlinthAnchor,PlinthColorSwatch) vs.onPressed(PlinthButton,PlinthActionIcon) mirrors Flutter's own split —ListTile/InkWell-style tappable tiles useonTap, button-styled widgets useonPressed.PlinthAlert/PlinthNotificationuse a non-nullablecolorwith a'blue'default, unlike every other component's nullablecolorfalling back to the theme's primary color — deliberate, since a feedback banner reading in your brand's primary color regardless of intent (info vs. error vs. success) would be a worse default than a conventional blue.PlinthBadge/PlinthTooltipdefault toPlinthSize.smrather than the.mdevery other component defaults to — matches how badges and tooltips actually look in practice; defaulting them to.mdwould make them oversized by default.- Five form-field components (
TextInput,Textarea,PasswordInput,NumberInput,Select) have an explicitenabled: boolalongside a nullableonChanged, while every other interactive component infers disabled state purely from a null callback. Justified: for a text field, "noonChangedprovided" and "field is disabled" are genuinely different states (a read-only-but-still-selectable field vs. a grayed-out, non-focusable one) — for a button, they're the same state.
-
Extend golden test coverage beyond— done, twice.PlinthButtonPlinthTextInputandPlinthAlertfirst, then the components that compute their own offsets and angles, which is where the two real visual bugs found so far both lived. 43 images. The remaining candidates are lower value:PlinthBadge's variants andPlinthLoader's three types are conditional but not computed, so a behaviour test already reaches most of what could break. -
Consider hue-drift at the extremes of the color-shade ramp— tried and rejected. One signed drift can't suit every hue: rotating darks the same way sends red toward magenta and yellow toward orange (fine) but blue toward cyan (worse). Doing it properly needs per-hue drift tables rather than a formula. Contrast wasn't the blocker — the WCAG suite passed throughout. See_generateShades. -
Extend the composed-blocks showcase — 110 examples against Mantine UI's ~123. Page Sections is complete, no subcategory is empty since
PlinthCarouselclosed Carousels, and none is more than two behind except Inputs. Nothing is blocked on a missing component. See docs/SHOWCASE.md. -
Bring the example app's component tour up to date— done. All 112 Mantine-parity components have a section, andexample/test/section_coverage_test.dartnow holds the section list against the snippet map so the two can't drift apart silently.The three Flutter-specific additions —
PlinthLtr,PlinthFocusTrap,PlinthTapTarget— deliberately have no tour section. Each is behaviour rather than appearance (a pinned text direction, a focus boundary, a minimum hit area), and a section showing one would be a screenshot of nothing.PlinthLtrhas a Widgetbook use case, where a side-by-side comparison actually shows something. -
Make a local— done. The golden tests carryflutter testquiet again@Tags(['golden'])anddart_test.yamlexcludes that tag on Windows and macOS only, so they still run in CI. Note this also means you can't force them on locally with--tags golden; the OS exclusion wins. That's deliberate — they cannot pass off Linux — but it's worth knowing before you go looking for the flag. -
Fill in pub.dev's discoverability fields — the package scores 160/160 on pana but ships no
topics:and noscreenshots:in its pubspec, which are the two things that make it findable by someone who isn't already looking for it.