- Blog
- Building eighteen apps with one design system
Building eighteen apps with one design system
By Erik Bjerke7 min read
- engineering
- design-system
- accessibility
An ecosystem of eighteen applications built by a very small team only works if
almost none of the interface is written twice. Numori-UI is how we get there: a
Tailwind 4 and Vue 3 component library that every Numori interface, including this
website, is assembled from.
It did not start as a library. It started as components/ui/ inside Numori Notes,
and it stayed there for a year before we pulled it out.
Extracting it was mostly deleting things
The notes app had twenty-eight components in that folder. Roughly a third of them
had a prop that existed for exactly one call site. A Modal with a
hideCloseButtonOnMobileWhenScrolled boolean is not a design system, it is a
changelog.
The rule we settled on: a prop earns its place if two unrelated screens need it,
or if leaving it out forces a consumer to reimplement the component. Everything
else became a slot. Modal went from eleven props to six plus a slot, and the
call sites got shorter rather than longer.
Accessibility is the reason to extract, not a bonus
This was the genuinely useful outcome and it was not the one we set out for.
When keyboard handling lives in each screen, it is done well in the screens someone was thinking about it in and forgotten everywhere else. Our notes app had a modal that trapped focus and a settings dialog that did not, because they were written four months apart.
Moving that behaviour into the component makes it structural. Toggle renders a
real <button role="switch"> with aria-checked, so keyboard activation comes
free from the platform rather than from a @keydown.space handler somebody
remembered. Button takes ariaLabel, ariaChecked, role and tabindex as
first-class props, because the alternative — dropping to a raw <button> when you
need an ARIA attribute — is exactly how a design system gets bypassed.
The honest part: it is not finished. Modal does not yet trap focus or set
role="dialog" and aria-modal, and it does not close on Escape. Those are real
gaps, they are written down, and this website deliberately does not use UiModal
for anything that matters until they are closed.
Tree-shaking, because a marketing site should not ship a form library
The library ships no precompiled CSS and no barrel file that drags everything in.
Import UiKbd and you get 0.4 kB. Import UiButton and you get 16 kB, about 7 kB
of which is the icon path table.
That last number is why this site delegates icons to @nuxt/icon instead. The
library bundles the thirty-six MDI glyphs its own components need; a website needs
mdi:github and mdi:robot-off-outline and thirty others that no component uses.
So Numori-UI's Nuxt module takes an icons.component option and hands icon
rendering to whatever the host app already has:
numoriUi: {
prefix: 'Ui',
icons: { component: 'Icon' },
}
Design systems usually get this backwards and insist on owning the icon layer.
What it cost
Two weeks, and one genuinely annoying class of bug. Because components are consumed as raw single-file components rather than as a prebuilt bundle, Vite has to be told not to pre-bundle or externalise the package. That is three lines in a Vite plugin now, and it was an afternoon of confusion first.
Worth it. The next application starts with a working, accessible, translated interface on day one, and an accessibility fix lands in eighteen apps at once instead of in the one somebody filed a bug against.