Skip to content
All posts

What a component gallery can't tell you

A 25-screen banking app on FormaUI, rebranded until it doesn't look like FormaUI. 991 lines of theme over 13,325 lines of app, the four defaults the theme couldn't reach, and the contrast rule a new palette broke.

10 min read
  • Case study
  • Theming
  • Compose

A component gallery proves that a component renders. It cannot tell you what happens when forty of them have to carry a product — when the same balance appears on four screens, when a designer hands you a brand that isn't yours, when one token has to be right in light mode, dark mode, and on the one screen that ignores both.

So I built the product. FormaFinTechUiKit is a consumer banking app in Jetpack Compose: twenty-five destinations, a working money model, every button wired to something. It runs on the current release from Maven Central — the published artifact, not a local branch — and it deliberately does not look like FormaUI.

That last part is the test. An opinionated library is easy to like when you like its opinions; the question is what happens when you don't. I maintain FormaUI and I built the app, which makes this a self-assessment — so everything below is a number you can check out and re-derive, and the section that cost me 101 call sites is not buried.

The brand is 7% of the app

The visual language is modeled on the public brand of Wise, as a design study — Forma is a fictional product, not affiliated with or endorsed by Wise. Lime green, Inter, 24dp corners, a near-black ink. FormaUI ships coral and cream, Public Sans, and 8dp corners. Nothing survives that comparison by accident.

The whole rebrand is ui/theme/ — eight files, 991 lines, against 13,325 lines of application code. Colour is 316 of those lines and typography 187, which is to say the brand is mostly a palette and a type scale, exactly as it should be.

And the installation is three parameters:

FormaTheme(
    colorScheme = FormaColorScheme(light = BrandLightColorScheme, dark = BrandDarkColorScheme),
    typography = BrandTypography,
    shapes = BrandShapes,
    darkTheme = darkTheme,
    dynamicColor = false,
) { /* twenty-five screens */ }

There is no fourth step. No fork, no components-brand/ module, no copied source. In 13,325 lines I never once pasted a FormaUI component into the app to change its insides — every deviation is a parameter or a composite built on top.

Two things about that block are worth pulling out, because they're the ones I'd have doubted from the outside.

dynamicColor = false is a supported answer. Material You is one of FormaUI's headline features and it's one flag — which also means it's one flag off. This brand rests on a single accent green; letting the wallpaper recolour it would defeat the product. A library that made dynamic colour mandatory would have been unusable here.

Material 3 is still underneath. FormaShapes feeds M3's five shape slots, so the raw androidx.compose.material3 widgets in this app — and there are plenty, Text most of all — pick up the brand's corners and type scale without wiring. That matters more than it sounds: adopting FormaUI didn't create two visual systems that have to be kept in sync. It created one, with two entry points.

Four defaults the theme didn't reach

Now the part that cost me something.

FormaUI's *Defaults are composable getters that read the theme, so most brand tokens flow into components automatically. Four didn't:

FormaUI defaultresolves tobrand wants
FormaButtonDefaults.shapeshapes.md — 12dp24dp
FormaCardDefaults.shapeshapes.lg — 16dp24dp
FormaButtonDefaults.contentPaddinglg/xs — 24/8dp24/12dp
FormaCardDefaults.contentPaddingmd — 16dp24dp

The shapes are a slot collision, not a bug. FormaButton and FormaTextField both read shapes.md, and this brand wants 24dp buttons with 12dp inputs. Skewing the scale to fix buttons breaks text fields. There is no assignment of eight corner tiers that satisfies both, so the scale stays honest to the design doc and the call sites carry the correction.

The padding is a real gap. In the current release FormaTheme takes no spacing parameter and LocalFormaSpacing is internal — you can construct a custom FormaSpacing (the constructor is public) but you cannot install one. FormaUI's grid runs 4/8/12/16/24/32/48/96; this brand's runs 2/4/8/12/16/24/32/48, one notch finer throughout. So the app provides its own composition local and every screen reads FinTechTheme.spacing instead of FormaTheme.spacing, which is the kind of split that survives only because a comment makes it survive.

The cost, counted rather than estimated: a BrandDefaults object of corrected getters, referenced 101 times across 36 files.

That's the honest number. It is also not the disaster it looks like — every one of those call sites is a named constant passed to a public parameter, discoverable by autocomplete and greppable in one command, which is what "full override capability" buys you when a default is wrong. Compare the alternative: a library with no shape parameter gives you a fork.

The spacing half is fixed. FormaTheme takes a spacing: FormaSpacing parameter on the development branch and ships in the next release — I've written up how that gap opened and what it cost to close late. When the app moves to it, BrandSpacing becomes a fourth argument in the block above and roughly half those call sites delete themselves.

The contrast rule that a new palette broke

One correction in BrandDefaults isn't about geometry at all, and it's the most interesting thing the app found.

Material 3 draws a text button's label in colorScheme.primary. For most palettes that's a saturated accent and reads fine. This brand's primary is Wise green #9fe870, which on the white canvas computes to 1.47:1 — against WCAG's 4.5:1 floor for body text. The green is legible only as a background behind ink, which is exactly how the filled CTA uses it.

So the text-button variant gets contentColor = FormaTheme.colorScheme.onSurface, and the destructive variant gets colorScheme.error, and both are theme roles rather than fixed colours so they stay correct in dark mode.

The lesson generalises past this app: an opinionated library's accessibility guarantees are tuned to its own palette. FormaUI clears contrast with coral on cream. Swap in a pale accent and some of those guarantees become claims about a palette you no longer use. The structural accessibility work carries over untouched — 48dp targets, semantics roles, chart summaries — but every contrast pair is yours again the moment you replace the colours. Nobody tells you this in a README, including mine.

What I didn't have to build

Twenty-nine of the forty components appear in the app. The eleven that don't — pickers this product doesn't need, a navigation rail with no tablet layout to put it in, tooltips on a touch-first UI — are absent because the app has no use for them, which is its own useful signal about the roster.

Three things saved disproportionate time:

Charts, with no dependency decision. The dashboard has a spending donut, a balance trend line and a monthly bar chart. In a normal Compose project that's an afternoon of evaluating MPAndroidChart against Vico against YCharts, then a week of making the winner match your brand. Here they're FormaDonutChart, FormaLineChart, FormaBarChart, already themed, already generating screen-reader summaries. They're presentation charts — no tooltips, no scrubbing — and for a banking dashboard that was exactly the right amount of chart.

Tabular figures, already in the type system. FormaTypography carries a numeric slot alongside the Material scale. The app fills it with fontFeatureSettings = "tnum" and reads it at twenty call sites, which is why a balance updating on screen doesn't shift its own layout as the digits change. It's a small thing that a finance app cannot skip and that no MaterialTheme has a place to put.

An animationSpec parameter I didn't expect to need. The app honours the system's reduced-motion setting through a LocalReducedMotion composition local. A NavHost-level switch can turn off screen transitions, but it cannot reach inside a chart — and FormaUI's entry sweep is 800ms, the longest motion in the app. Because FormaDonutChart accepts animationSpec and documents null as "render the final frame", honouring the setting is one conditional:

animationSpec = if (LocalReducedMotion.current) null else FormaChartDefaults.EntryAnimationSpec,

This is the pattern that mattered most across the whole build, and it isn't a feature — it's a habit. Every place FormaUI could have hardcoded a value, it took a parameter with a sensible default instead. That's what lets a consumer honour an accessibility setting the library itself knows nothing about.

What it correctly refused to give me

Thirty files of app-specific components: a payment card face, a PIN keypad, an amount field with a currency chip, a card wallet, a transaction row, a status pill.

The status pill is the clarifying case. It looks like something FormaChip should cover, and it isn't — this brand's semantic tones (positive, warning, negative, neutral) are colour pairs from the design doc, and one of the rules is that the CTA green must never double as a success indicator. So StatusPill is a small Row of Text reading brand tokens, built inside FinTechTheme, sitting next to FormaChip without competing with it.

That's the right division of labour. A component library gives you the vocabulary; the product writes the sentences. A library that shipped a FormaStatusPill general enough for every product's semantics would ship one useful to none of them — and the reason the app's own components don't look foreign next to FormaUI's is that they're built from the same tokens, not from the same source.

The scoreboard

Count
Screens25
App source13,325 lines Kotlin
Theme layer (the entire rebrand)991 lines, 8 files
FormaUI components used29 of 40
App-specific components built30 files
@Previews that render with no device122
Tests202
FormaUI components forked or copied0
Call sites correcting a FormaUI default101

Six things that generalise

  • A themeable library is one you can make unrecognisable. If a rebrand takes more than the theme constructor and a handful of parameters, what you have is a look, not a design system.
  • Count your override sites, and publish the number. 101 is a real cost and it's also a bounded one. "Fully customisable" without a number is marketing; with a number it's a decision you can make.
  • A default that can't be fixed at the theme level is a design bug in the token layer, not at the call site. Two components reading one shape slot and wanting different values means the slot is doing two jobs.
  • Accessibility guarantees don't survive a palette swap. Structure does — targets, semantics, generated descriptions. Contrast doesn't. Re-audit every pair the moment you replace the colours, including the ones the library got right.
  • null is a feature. A parameter that accepts "no animation" is what lets a consumer honour a setting the library never heard of. The same is true of every shape, colors and contentPadding on the surface.
  • The absence of a component is information. Eleven unused components and thirty hand-built ones is a truer map of what a product needs than any roster.

The thing I did not expect was how little of the interesting work was in the components. It was in the theme layer, where four token families have to agree with each other across two colour schemes and one screen that opts out of both — and where a library either hands you the parameter or hands you a fork.


FormaUI is an opinionated Material 3 component library for Jetpack Compose — 40 components with the design work already done, and a theme you can replace entirely. Try every component live in your browser, or read the token-by-token comparison with stock Material 3.

Back toAll posts