cairn_ui 0.2.0
cairn_ui: ^0.2.0 copied to clipboard
A modern, accessible Flutter component library: 65 widgets on an engineered token system with OKLCH colors, light and dark themes, and golden tests.
Cairn UI #
A modern, accessible Flutter component library built on a design token system that is actually engineered: colours authored in OKLCH with a matched dark theme, a radius scale derived proportionally from a single number, a type scale that survives text scaling, and golden tests holding all of it in place.
65 components. No runtime dependencies beyond Flutter itself.
Documentation site: https://rlphjyson.github.io/cairn_site/ — every component live and interactive, plus composed blocks, charts and a typography style guide. It is a Flutter web app (source) built with this library: its navigation, tabs, command palette, code-block toasts and directory table are all Cairn widgets doing a real job.

More screenshots
Overlays, dark theme:

Tokens:

Feedback and data, dark theme:

Install #
dependencies:
cairn_ui: ^0.1.0
Quick start #
import 'package:cairn_ui/cairn_ui.dart';
import 'package:flutter/material.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
theme: CairnTheme.materialTheme(CairnTheme.light),
darkTheme: CairnTheme.materialTheme(CairnTheme.dark),
home: Scaffold(
body: Center(
child: CairnCard(
width: 360,
children: <Widget>[
const CairnCardHeader(
title: Text('Deploy your project'),
description: Text('Ship to production in one click.'),
),
CairnCardFooter(
mainAxisAlignment: MainAxisAlignment.end,
children: <Widget>[
CairnButton(
variant: CairnButtonVariant.outline,
onPressed: () {},
child: const Text('Cancel'),
),
CairnButton(onPressed: () {}, child: const Text('Deploy')),
],
),
],
),
),
),
);
}
}
Run the full catalogue:
cd example && flutter run
How Cairn is built #
Most component libraries are a pile of widgets that happen to look alike. Cairn is a token system with widgets on top, which is a different thing: the consistency is structural rather than maintained by hand. Three claims, each of them checkable rather than aspirational:
- No component hardcodes a number that should come from a token. Every padding, radius, duration and colour resolves through the token layer.
- Every place a CSS-shaped design idea meets Flutter's rendering model is resolved explicitly and documented where the two disagree — of which there are more than you would expect.
- Golden tests lock the result down, so the implementation cannot drift.
1. The token layer #
| File | Contents |
|---|---|
lib/src/tokens/oklch.dart |
oklch() → Color conversion |
lib/src/tokens/colors.dart |
All 19 semantic slots, light and dark, each annotated with the oklch() string it was authored as |
lib/src/tokens/spacing.dart |
A 0.25rem (4 logical pixel) base scale |
lib/src/tokens/radius.dart |
The proportional radius scale, and CairnRadiusScale for custom bases |
lib/src/tokens/typography.dart |
Font sizes, line-height ratios, weights, tracking |
lib/src/tokens/shadows.dart |
The shadow scale plus the CSS-blur conversion |
lib/src/tokens/motion.dart |
Durations and easing curves |
Each colour records its authored value next to the converted constant:
/// `primary` = `oklch(0.205 0 0)` — `#171717` (neutral-900).
static const Color lightPrimary = Color(0xFF171717);
Four decisions in there are worth calling out, because each of them is a place where the obvious choice is the worse one.
-
Colours are authored in OKLCH, not HSL or hex. OKLCH is perceptually uniform: an equal step in lightness reads as an equal step in brightness at every hue. That is what lets the light and dark themes be two readings of one ramp and stay balanced by construction rather than by eye. The conversion lives in the package, so the provenance of every colour is checkable —
test/tokens/oklch_test.dartre-derives every baked constant from itsoklch()string. -
The radius scale is multiplier-based. Every step is a fixed multiple of a single base:
0.6,0.8,1,1.4,1.8,2.2,2.6. The obvious alternative — pixel offsets from the base (base - 4,base + 4) — gives identical numbers at the 10px default and falls apart the moment the base is retuned: a compact 4px theme would produce a negative small radius.CairnRadius.scaled()stays proportional at any base, so changingCairnTheme.radiusrescales the whole library coherently. -
The achromatic ramp lands exactly on Tailwind's published
neutralpalette.oklch(0.145 0 0)is#0A0A0A(neutral-950),oklch(0.922 0 0)is#E5E5E5(neutral-200), and so on down the scale. That is both an independent check that the OKLab pipeline is correct — a subtly wrong matrix would drift by a few units and miss — and a convenience, since Cairn's greys then sit flush against a Tailwind-flavoured design.test/tokens/tokens_test.dartasserts it. -
Tokens are named for their role, never their appearance. A component asks for the foreground that belongs on a destructive surface, not for white. That is the whole reason overriding one slot re-themes everything that reads it, and it is why
destructiveForegroundexists as a slot at all rather than being a hardcoded white inside Button and Badge.
2. Where CSS intuitions and Flutter's rendering model disagree #
Cairn's tokens are authored in the vocabulary design systems actually use —
rem, oklch(), box-shadow, line heights in absolute units. Converting that
vocabulary into Flutter is where the interesting bugs live, because several of
these conversions are wrong if you carry the numbers across literally. Each is
implemented and commented in the source.
Blur radius means different things. CSS defines a shadow's blur as a
Gaussian with standard deviation half the stated radius. Flutter converts
BoxShadow.blurRadius to a sigma with radius * 0.57735 + 0.5. Putting a CSS
6px blur straight into blurRadius yields a sigma ~50% too wide.
CairnShadows.cssBlur() inverts Flutter's formula so the rendered sigma is the
one the design called for.
Outer shadows are clipped in CSS and not in Flutter. CSS never paints an
outer box-shadow through its element; Flutter paints a blurred filled copy of
the shape behind the box with no clip. On a transparent form control — which is
most of Cairn's — that turns the smallest shadow in the scale into a grey wash
across the field, and turns the hard 3px focus ring into a solid fill over the
entire control instead of an outline around it. CairnShadowed paints shadows
through a clip that removes the shape's interior.
A partial-strength token scales alpha, it does not set it. "The input
colour at 30%" means 30% of what it already is. For an opaque token that is
the same as setting alpha to 0.30 — but Cairn's dark input token is already
oklch(1 0 0 / 15%), so 30% of it is white at 4.5%, not 30%.
withOpacityModifier() multiplies. Getting this wrong is a six-times-too-strong
grey fill on every dark-mode form control.
Container(alignment:) expands to fill. A Flutter Container with a
non-null alignment grows to its bounded constraints. Used to centre a button's
label it silently stretches every button to its parent's width — the opposite of
what a button should do, which is size to its own content. Cairn centres via the
child's own mainAxisAlignment or a Center(widthFactor: 1) instead, and
test/components/sizing_test.dart guards it.
Line height is a ratio, not a length. A type scale pairs a font size with an
absolute line height (14px text on a 20px line). Flutter's TextStyle.height is
a multiple, so the conversion is 20 / 14. Storing the ratio is what keeps
intrinsic heights correct when the user scales text — the pixel value would
silently stop matching.
A line height of exactly 1 is not Flutter's default. Card, Dialog and Label titles want their line box to be precisely the font size, so a single-line heading sits optically centred against the controls beside it. Flutter's default comes from font metrics (~1.2), so it has to be set explicitly or titles sit low.
Some values are deliberately not round. The Switch track is 18.4 logical pixels, because a 16px thumb has to clear a 1px border on both sides with a hair of room and that is where the arithmetic lands. The Tabs track padding is 3px, not a spacing step, because that is what makes the active tab sit flush inside it. The Checkbox radius is a literal 4px rather than a scale step, because at 16px square a proportional radius rounds the box into a blob. Rounding any of these to a nicer number is exactly the drift this library exists to avoid.
3. Golden tests as the enforcement mechanism #
The token layer is what makes the first implementation correct. Golden tests are what keep it correct.
test/goldens/goldens_test.dart renders every component as a sheet — all
variants and states in one image — in both light and dark, and byte-compares it
against a committed reference PNG. A change to a padding value, a colour or a
radius fails CI with a visual diff.
Two things make this reliable rather than flaky:
Fonts are bundled, not borrowed from the OS. flutter test loads no real
font by default — text lays out with a placeholder where every glyph is an
identical box, which tells you nothing about typography. Cairn ships Geist
(SIL OFL 1.1) under test/fonts/ and registers it in
test/flutter_test_config.dart. Because the font comes from the repository,
text shapes identically everywhere.
Goldens are generated and verified on one platform. Even with identical
fonts, sub-pixel anti-aliasing can differ between operating systems. Rather
than weaken the comparison with a fuzzy threshold — which would let real
regressions through — Cairn enforces goldens on Linux, which is what CI
runs. On Windows and macOS the widgets are still built and pumped (so layout
errors and exceptions are caught), but the pixel comparison is skipped. Set
CAIRN_FORCE_GOLDENS=1 to compare anyway.
Regenerating goldens
Because references must come from the same platform that verifies them, do not
run --update-goldens locally on Windows or macOS. Instead:
- Go to Actions → Update goldens → Run workflow.
- Download the
goldensartifact and commit the files, or tick commit to have the workflow push them for you.
The workflow re-runs flutter test afterwards to prove the regenerated images
actually pass.
One more detail: golden capture pumps a fixed number of frames rather than
calling pumpAndSettle. Components with repeating animations — the Spinner,
the Skeleton pulse, the Input OTP caret, indeterminate Progress — never stop
scheduling frames, so pumpAndSettle times out. The harness also sets
MediaQueryData.disableAnimations, which those components honour, so they
render as one deterministic frame.
Theming #
CairnTheme is a ThemeExtension. That is a deliberate integration choice: an
inherited widget of Cairn's own would force host apps to nest two theme systems
and would break any Material widget already in use. Riding on
ThemeData.extensions instead means Cairn composes inside an ordinary
MaterialApp, Flutter animates theme transitions for free via
ThemeExtension.lerp, and Theme.of(context) stays the single lookup path.
MaterialApp(
theme: CairnTheme.materialTheme(CairnTheme.light),
darkTheme: CairnTheme.materialTheme(CairnTheme.dark),
);
materialTheme() also aligns Material's own defaults with the Cairn tokens —
scaffold background, ColorScheme, text selection colours, no ink splash
(Cairn has no ripple; its interactions are colour and shadow transitions only),
and Material 3's filled TextField default turned off, since Cairn inputs are
transparent with the border doing the work.
If you would rather wire it up yourself:
MaterialApp(
theme: ThemeData(
extensions: const <ThemeExtension<dynamic>>[CairnTheme.light],
),
);
Customise with copyWith. Changing radius rescales every component's corners
proportionally:
final theme = CairnTheme.light.copyWith(
primary: const Color(0xFF3B82F6),
radius: 4.0,
fontFamily: 'Inter',
);
Components resolve tokens through CairnTheme.of(context), which falls back to
CairnTheme.light rather than throwing — a widget dropped into an app with no
extension registered still renders correctly.
Accessibility #
A component library that looks right and behaves wrong is worse than no library at all, because it makes the wrong thing easy. Cairn treats keyboard and screen reader behaviour as part of each component's definition rather than as a later pass.
:focus-visible, not:focus. A focus ring is a keyboard affordance: clicking a button must not show one, tabbing to it must. Flutter'shasFocuscannot distinguish the two, soCairnInteractivecombines focus state withFocusManager.highlightModeand tracks whether the focus change came from a pointer press.- Space and Enter both activate, wired through
ActivateIntentso it composes with a host app's own shortcuts rather than intercepting raw keys. - Focus trapping and restore in Dialog, Alert Dialog, Sheet and Drawer.
These push a
PopupRoute, which gets Flutter's per-routeFocusScope— plus back-gesture dismissal, which a mobile user will try first. Anchored surfaces (Popover, Dropdown Menu) useOverlayPortalinstead so they stay out of the navigation stack. - Escape dismisses every overlay — except Alert Dialog, which exists precisely to demand an explicit choice.
- Roving focus in Radio Group and Tabs: one tab stop for the group, arrow keys to move within it, so a ten-option group does not cost ten tab presses.
- Slider exposes increase/decrease actions with correct
increasedValue/decreasedValueannouncements, and supports arrow keys plus Home/End. - Disabled means inert. A disabled control is genuinely removed from hit testing and focus traversal, not just painted grey — and so is its subtree.
- Reduced motion is honoured by Skeleton, Spinner and the Input OTP caret.
Components #
| Component | Notes |
|---|---|
| Accordion | Single and multiple modes, 200ms height animation |
| Alert | Default and destructive, optical icon alignment |
| Alert Dialog | Not dismissible by Escape or barrier, by design |
| Aspect Ratio | Thin wrapper over Flutter's own |
| Avatar / Avatar Group | Fallback persists on image error |
| Badge | 5 variants |
| Breadcrumb | Responsive gap, current-page semantics |
| Button | 6 variants x 8 sizes |
| Calendar | Fixed 7x6 grid so height never jumps |
| Card | Header / content / footer slots |
| Carousel | PageView-backed, with indicators |
| Checkbox | Tristate (indeterminate) supported |
| Collapsible | The primitive under Accordion |
| Combobox | Searchable select |
| Command | Command palette with keyword matching |
| Context Menu | Right-click and long-press, anchored to the pointer |
| Data Table | Sort, filter, paginate — built on Table |
| Date Picker | Calendar in a Popover |
| Dialog | Focus trapped, Escape to dismiss |
| Drawer | Bottom sheet with grab handle and drag-to-dismiss |
| Dropdown Menu | Labels, separators, shortcuts, destructive items |
| Empty | Dashed border (hand-painted; Flutter's Border can't) |
| Form Field | Layout only — Flutter already has Form validation |
| Hover Card | Open and close grace periods |
| Input | 36px tall, transparent, 3px focus ring |
| Input OTP | One hidden field behind painted slots, so paste works |
| Kbd / Kbd Group | Sans, not monospace |
| Label | Line height of exactly 1 |
| Menubar | Hover-to-switch once open |
| Menu primitives | Panel, item, checkbox item, label, separator |
| Navigation Menu | Arbitrary panel content |
| Pagination | Windowed range with ellipses |
| Popover | Flip and shift positioning |
| Progress | Determinate and indeterminate |
| Radio Group | Roving focus |
| Scroll Area | Configures Flutter's scrollbar rather than replacing it |
| Select | Menu matches trigger width |
| Separator | Decorative or semantic |
| Sheet | Four sides, asymmetric 500ms open / 300ms close |
| Skeleton | Two-second pulse, respects reduced motion |
| Slider | Keyboard driven, 4px halo |
| Spinner | Constant-speed rotation, respects reduced motion |
| Switch | The derived 18.4px track |
| Table | 8px cells, 40px header |
| Tabs | Filled and line variants |
| Textarea | Auto-grows with its content |
| Toast | Host outlives the route that fired it |
| Toggle / Toggle Group | Single and multiple, joined or spaced |
| Tooltip | Opens on hover and keyboard focus |
| Chat Bubble | Sent and received, 75% width cap, squared sender corner |
| Countdown | Tabular digits so a ticking value never jitters |
| Dock | Bottom navigation, 64px, whole item is the tap target |
| Diff | Before/after divider; drag or arrow keys |
| FAB | Optional speed dial with labelled mini actions |
| File Input | Draws the control; you supply the picker |
| Hero | Landing-page header with a 36rem measure |
| Indicator | Pins a badge to a corner without affecting layout |
| Link | Underline on hover and focus |
| List | Leading / title / subtitle / trailing rows |
| Mockups | Window, browser, code and phone frames |
| Navbar | Truly-centred middle region |
| Radial Progress | Ring analogue of Progress |
| Rating | Half stars, keyboard stepping, read-only mode |
| Stack | Overlapping layers, or an avatar pile |
| Stat / Stats | Headline figure with a divided group |
| Status | Tone dot with optional ping |
| Steps | Horizontal or vertical, tappable once complete |
| Swap | Two-face toggle: fade, rotate or flip |
| Timeline | Marker rail with a connecting hairline |
Extended components (since 0.2.0) — the 20 rows from Chat Bubble to
Timeline in the table above are ported from daisyUI's
catalogue, for the shapes shadcn/ui does not have (Stat, Steps, Timeline,
Rating, Dock and so on). Only the idea is borrowed: every one is built from
Cairn's tokens, so it themes, scales and goes dark with everything else.
daisyUI's own colour names are not used; where state needs a colour beyond
primary and destructive, CairnTone adds success, warning and info
as part of the token layer. Colours come from the same OKLCH pipeline and have
light and dark values.
What this library deliberately does not do #
It is a pure widget package, so a number of things commonly checked for in
application projects simply do not apply: there is no backend, no auth, no
analytics SDK, no network layer, no local database, no offline sync, no
platform channels and no native code. platforms: declares every target
Flutter supports because there is nothing platform-specific to break.
It also does not ship a form-validation engine (Flutter has Form and
FormField; competing with them would fight the framework), a theme-builder
UI, or icon assets — the handful of glyphs components need are drawn from
Lucide's geometry with a CustomPainter.
Roadmap #
- Flutter web and mobile templates on Cairn Site. The
documentation site will gain
ready-to-use starter templates built on this library — a Flutter web set
(landing page, dashboard, docs shell, auth screens) and a Flutter mobile
set (onboarding, tab-bar app shell with the Dock, settings, chat). Each will
be copy-and-run, themed entirely by
CairnTheme, with light and dark included. The Extended components (Navbar, Dock, Hero, Stats, Steps, Chat Bubble, List) exist largely to make those templates possible without one-off widgets. Not yet released; nothing in the package depends on it.
Testing #
flutter test # 172 tests
flutter analyze --fatal-infos --fatal-warnings
dart format --set-exit-if-changed .
Behaviour tests live in test/components/, goldens in test/goldens/, token
maths in test/tokens/.
Credit #
- Geist by Vercel — bundled under
test/fonts/for deterministic golden tests, SIL OFL 1.1. - Lucide — the icon geometry redrawn in
lib/src/internal/icons.dart. ISC licensed.
Cairn is not affiliated with or endorsed by either project. Full third-party attribution is in NOTICE.md.
Licence #
MIT — see LICENSE.