There's no shortage of React UI kits on npm. Search for one right now, and you'll get hundreds of results, most with the same seven button variants and a Storybook someone abandoned halfway through. So when I open-sourced brightframe — pulled out of a real coworking site I built, LAN — I didn't really want to write the usual "here's our 70 components, look how many there are" post. Component count isn't interesting. Anyone can list props and screenshot a button in five colors.
What actually took time, and what I think is worth writing about, is the part that happens after the README makes a claim. "Tree-shakeable." "Server Components-safe." "Accessible." Those are three words I typed pretty confidently early on, and then, more recently, I sat down and tried to prove myself wrong on each one. This post is what that turned up.
"Tree-shakeable per component" — okay, but how much, actually?
Every component ships as its own entry point:
import "brightframe/tokens.css";
import "brightframe/Btn.css";
import { Btn } from "brightframe/Btn";
Saying "unused components add nothing to your bundle" costs nothing. I added size-limit to CI so the claim has to keep being true, not just have been true once when I wrote the sentence:
| Entry | Minified + brotli |
|---|---|
Whole kit (import { ... } from "brightframe", JS) |
40.13 kB |
Whole kit (brightframe/style.css) |
11.83 kB |
One component (brightframe/Btn, JS) |
641 B |
One component's styles (brightframe/Btn.css) |
890 B |
641 bytes vs. 40 kilobytes. That gap is the whole reason the per-component entry points exist, and now if a refactor accidentally makes Btn drag in half the kit, the build just fails instead of me finding out from a bundle-size complaint six months later.
"Server Components-safe" — this one had an actual bug in it
RSC has no hook dispatcher at all. A component needs "use client" if it does one of two things in its own source: calls a hook, or wires up a DOM event handler in its own JSX. I wrote a little script (scripts/check-use-client.mjs) that walks every component and checks both, and runs in CI.
First time I ran it, it found something I genuinely didn't expect. Every RHF* field wrapper — the ones for react-hook-form — already had the directive. The parallel Formik* family didn't have it on any of them, despite every single one calling useField() internally. Same author (me), same shape, same week probably, and one family was fine while the other would've thrown an unclear error the first time someone tried to render it inside a Server Component tree. I didn't catch this by reading files — I grepped the ~64 components that use hooks against the ~36 that already had the directive and diffed the two lists. Reading eight files carefully would not have found this; a dumb list comparison did.
There's a smaller one I actually like more, because it breaks a mental shortcut. Burger — the hamburger menu icon — holds no state of its own. open and setOpen come in as props. My gut said "no state, no hooks, should be server-safe." Wrong: its JSX still writes onClick={() => setOpen(!open)} directly, which is the second rule, not the first. "Doesn't use useState" and "server-safe" are not the same question, and it took a mechanical check to notice I'd been conflating them.
Also, embarrassingly, the script only looked at .tsx files for a while — until useCombobox.ts (more on that hook below) shipped as its own publicly importable module with no directive and no .tsx extension to be caught by the glob. Fixed the glob. This is the kind of gap you only find once you build something whose entire job is to catch you being wrong.
And one thing I didn't fix, on purpose: Progress and Skeleton spread {...rest} onto their host element from a loosely typed props object, which technically means a consumer could pass an onClick through and expect it to work. Both are already marked "use client". I left them that way instead of "correcting" them to match the strict rule, because the two failure modes aren't symmetric — an unnecessary directive costs you a small unused client boundary, a missing one silently breaks someone's click handler in production. When in doubt, I'd rather over-mark than under-mark. Wrote that down as an open, deliberately unresolved thing rather than pretending the mechanical rule settles it.
"Accessible" — turns out that's a much bigger claim than a per-component test can back up
Every component already has a jest-axe unit test. For a long time I took that as "the kit is accessible," full stop. Then I added a second layer that actually renders every real Storybook story in a live Chromium browser (@storybook/addon-vitest + @storybook/addon-a11y) and ran it for the first time this week.
29 real violations. Across 16 story files. Zero of them caught by the existing unit tests, because a unit test only ever renders one component in isolation — never two composed the way an actual page does.
Some of what that turned up:
-
Alert.module.csshad two separate rules touching.description— one settingopacity: 90%, one settingcolor. Neither line looks wrong on its own. Combined, against a tinted background, the description text on every single Alert variant dropped below the 4.5:1 contrast minimum. One merged rule fixed all four variants at once, and I felt a little dumb for not noticing two rules targeting the same class in the first place. - The accent orange token measured 1.94–2.01:1 in a couple of real compositions — both as text on white and as white text on an accent fill. It's meant purely as a decorative accent, never a button fill, so nobody had ever actually checked it against a background where the contrast mattered. It just... sat there, unchecked, for however long.
- Two Storybook stories — meant to be documentation, the thing people copy-paste from — showed passing a real
<Btn>intoDropdownMenu/Popover'striggerprop. Both components already wraptriggerin their own<button>for thearia-haspopupwiring. So the examples were literally teaching people to nest a button inside a button. Invalid HTML, real violation, and it was in the docs, which is worse than if it had just been a bug nobody saw. -
HorizontalScrollerhad no way to reach its own scroll track with a keyboard. A scrollable region with nothing focusable inside it just isn't tabbable — obvious in hindsight, invisible until a real audit tool says so. Fixed it withtabIndex={0}androle="region", and then immediately hit a second violation because two of them ended up on the same page with the same accessible name.
One thing I found and did not fix — BookingForm's demo puts GuestsCounter's label straight onto FormCard's strong purple-tinted background, dropping its contrast from a fine 4.84:1 to 3.75:1. That's not a token I can nudge my way out of; it's a real design decision (a separate on-tint label color, or restricting that background to darker content). I marked it todo in the a11y config rather than quietly ignore it or half-fix it with a hack. It's still broken. I just didn't pretend otherwise.
And one finding I looked at, decided was probably noise, and left alone: a 4.47:1 reading on a token whose flat CSS math comes out to 4.84:1. My best guess is the checker is sampling actual anti-aliased pixel edges at a specific bold/17px size rather than the raw color value — a rendering artifact, not a real bug. That token is used in roughly 80 components. I wasn't going to darken it everywhere to chase a 0.37 gap I'm not even sure is real. Could be wrong about that. Wrote the reasoning down so whoever looks next doesn't have to start from zero.
Freezing 73 moving targets
Nobody is going to click through 70-plus Storybook stories after every CSS change and eyeball whether something shifted. So every component now has a committed screenshot baseline per theme, diffed on every CI run through Vitest's browser-mode screenshot matcher.
Two things bit me setting this up that I didn't see coming.
Animations have to be frozen per test, not once at the top of the file. A component mid-animation never settles into a stable frame, so the test just hangs and times out instead of failing with a useful message — which took me a minute to figure out, because "timeout" doesn't obviously mean "your spinner is still spinning." The fix is injecting animation: none !important; transition: none !important in a beforeEach, because Vitest's browser mode gives each test its own document, so you can't set it once globally and expect it to stick.
Two components are just excluded outright — Loader, which has no idle state at all (it's always animating, there's nothing to freeze into), and MobileDatePicker, whose sheet uses position: fixed, which contributes nothing to a bare, unstyled test container's layout box the way it would in a real page. I didn't try to force a fix for two components in an afternoon. I named the limitation, left it in the docs, and moved on.
Pulling one component's brain out of its body
Most of brightframe is deliberately not headless — a <Card> that looks right without you writing a line of CSS is the actual pitch. But Combobox now also ships its interaction logic on its own, as useCombobox, with none of the styling:
const combobox = useCombobox({ options, value, onChange: setValue });
<div ref={combobox.containerRef}>
<input {...combobox.getInputProps()} />
{combobox.open && (
<ul {...combobox.getListProps()}>
{combobox.filteredOptions.map((option, index) => (
<li key={option.value} {...combobox.getOptionProps(option, index)}>
{option.label}
</li>
))}
</ul>
)}
</div>
The styled version is built on top of this hook now, not next to it, so there's exactly one copy of the open/filter/keyboard-nav logic instead of two that quietly drift apart over time. I started with Combobox and not, say, SelectField or DropdownMenu, because it has strictly more logic than SelectField needs (which means SelectField could adopt this same hook later without losing anything), while DropdownMenu's keyboard handling is a genuinely different shape — item-list navigation, not text filtering — so it'd be its own extraction, not a copy-paste of this one.
Closing the loop on where this all came from
brightframe was pulled out of lan-site's own internal component library. That's fine for me, but it's a one-way trip for anyone else stuck in the same spot with their own inline kit. So there's now codemods/migrate-legacy-kit/ — a small, config-driven codemod, dry-run by default:
bun codemods/migrate-legacy-kit/cli.ts ../your-app/src/components \
--config your.config.ts \
--report dry-run-report.md
Rules are either safe-rename (1:1, no behavior change, gets rewritten if you pass --write) or manual-review (flagged with a note, and there's no flag, no override, nothing that forces it through automatically). I tested that second guarantee specifically, because the moment a "just force it" escape hatch exists, someone eventually uses it on a rename that wasn't actually safe.
So what was the point of all this
None of this changes what brightframe fundamentally is. Still a token-driven theme system, still mostly presentational components plus a handful of genuinely interactive ones, still no router or i18n dependency baked in. What changed is that the README's more interesting sentences — the bundle size, the RSC safety, the accessibility — aren't just sentences anymore. They're backed by something that runs on every push and fails loudly the moment they stop being true.
If you're picking between UI kits — this one or someone else's — "how many components" is probably the wrong first question. I'd ask: if this claim turned out to be false tomorrow, would anything actually notice?
npm install brightframe
Top comments (1)
That progression from unit-level
jest-axechecks to composed Storybook stories is the strongest part of this write-up. Accessibility is rarely a stable property of a component in isolation; it emerges from composition, content, state, and the task a person is trying to complete.I would add a third layer after automated story checks: a small set of interaction contracts for the highest-risk primitives. For a dialog, menu, combobox, or horizontal scroller, the contract could record initial focus, keyboard path, accessible name in context, Escape behavior, focus restoration, and behavior at 200% and 400% zoom. Those checks would not prove conformance, but they would catch regressions that axe cannot infer.
The nested-button example also makes an important documentation point: examples are part of the product surface because consumers copy them. Have you considered treating every documented composition as a versioned test fixture, rather than only as a visual story?