ArchitectureDesign system6 min read

Why I extracted a design system from a production dashboard

Sharing components across frameworks fails quietly. Counting classes, naming jobs instead of colours, and writing contracts is what actually travelled.

By Abhishek Fouzdar

The dashboard had a button. It was written eight different ways. Same intent every time, a different padding, weight, shadow or transition each time, depending on who wrote the view and which month they wrote it in. Nobody had decided anything. The codebase had just accumulated.

That is the normal state of a Laravel app that has been in production for a couple of years and has shipped features faster than it has shipped conventions. The usual fix is "let's build a component library". I tried that first, and it is the wrong first move. This post is about why, and what I did instead: the extraction that became the Shipbytes design system.

Measure before you decide

Before touching anything I counted. A script walked every Blade view and tallied Tailwind classes by family and shade. The totals were more useful than any amount of design opinion:

  • zinc was used 4,888 times, the single most-used colour family. gray was also in use, as a second neutral ramp with a different hue temperature.
  • zinc-500 alone appeared 850 times. That is the colour of every label, timestamp and subtitle in the app, chosen by habit rather than by rule.
  • blue appeared 409 times and indigo 438 times, both as the link and emphasis colour. Nobody had picked one.
  • Success states used emerald, green and lime. Three families for one meaning.
  • Nine colour families were doing five jobs, and every family spanned nine to eleven shades because nothing enforced which shade meant what.

None of that is visible from inside the app. Each view looks fine on its own. The counts are what turn "the UI feels inconsistent" into a list of concrete decisions, and they are the evidence I kept citing in the specs later. A rule that says "use zinc, not gray" is taste. A rule that says "zinc won 4,888 to a few hundred, so gray goes" is a decision someone can check.

Why sharing components does not survive contact

The plan was to share components across the Blade app, a React front end that was coming, and whatever came after. So I looked at how portable the existing components actually were.

The markup was bound to Livewire in roughly 1,100 places. The cosmetic ones would have been fine. The structural ones were not: $wire.$watch drove autosave, a morph.updated hook recalculated layout after every render, there were 33 wire:ignore islands protecting third-party widgets, and loading state existed only as wire:loading. There was no isLoading variable anywhere to port. The loading state was the directive.

Any behaviour layer has to be rewritten per framework regardless. What genuinely travels is a shorter list:

Layer How well it travels
Tokens Perfectly. One CSS file every framework consumes.
Recipes, the class strings per variant, size and state Almost perfectly.
Specs: anatomy, variants, states, the accessibility contract As documentation.
Implementations They do not. Each one is a thin leaf on top of the three above.

So the system is token-first, not component-first. A Blade project installs one Composer package and gets 41 components. A React or Vue project will get its own thin implementations later, sharing the same tokens.css, so the two buttons are the same button. That shared file is the product. The components are how one framework consumes it.

Tokens name jobs, not colours

The semantic layer is where the counts turned into rules. Every token names what it is for:

--ds-semantic-surface: var(--ds-color-white);         /* what content sits on */
--ds-semantic-surface-sunken: var(--ds-color-zinc-100);  /* the ground behind cards */
--ds-semantic-fg: var(--ds-color-zinc-950);           /* headings, 748 uses */
--ds-semantic-fg-body: var(--ds-color-zinc-700);      /* running copy, 309 uses */
--ds-semantic-fg-muted: var(--ds-color-zinc-500);     /* labels and meta, 850 uses */
--ds-semantic-accent: var(--ds-color-blue-600);       /* settles blue vs indigo */
--ds-semantic-border: oklch(14.1% 0.005 285.823 / 0.1); /* zinc-950 at 10% */

The raw palette still exists, but nothing outside the semantic file is allowed to reference it. Reaching for zinc-500 directly is exactly how the app ended up with two neutral ramps. Reaching for fg-muted instead means a rebrand touches one file and dark mode is a second set of values for the same names, not a second set of markup.

Three things fell out of this that I would not have found by eye:

Timestamps were failing contrast. The app used zinc-400 for them, which sits at 2.63:1 on white. That shade is fine for an icon that repeats the label next to it, since the information is also in words. A timestamp is not redundant. Nobody else on the row says "3 days ago". They moved to fg-muted at 4.83:1. That is a real accessibility fix, delivered as a token change.

Dark mode tints should be computed, not hand-tuned. Light mode uses the fixed 100 and 700 steps for badge backgrounds and text. Dark mode uses 15% alpha of the base colour over the surface. One rule, instead of a second ramp that drifts every time someone adjusts a shade.

on-danger has to flip. danger lightens to red-400 in dark mode, and white text on red-400 is 2.87:1, a fail. The dark value is a near-black label. The token system made that a two-line change in one file rather than a search across every destructive button.

The border hairline is a known deviation at roughly 1.1:1, kept deliberately because the affordance of an input comes from its fill and shadow, and mitigated by a focus ring that passes 3:1 on every surface. Writing that down matters as much as fixing the timestamps. A documented exception stays a decision. An undocumented one becomes a bug report in six months.

Specs are the contract

A spec is not a style guide. The class strings live in the implementation and the values live in the tokens. The spec says what the component is made of, which variants and states exist, and what a correct implementation must guarantee. It is the thing that keeps a Blade button and a future React button the same component rather than two lookalikes.

The button spec is a good example of what the counting surfaced and what only careful thought surfaces. Two things from it:

Sizes md and lg are one component at two breakpoints, not two components. That is how you get a 44px touch target on phones without a "mobile button". Padding is calc(step - 1px) to absorb an always-present 1px border, which is why the numbers look odd, and also why primary and secondary line up pixel for pixel when they sit side by side.

The loading state carries the disabled attribute, because a second submit while the first is in flight is precisely the bug the state exists to prevent. But it must not pick up the disabled styling. Faded reads as "refused". A spinner at full contrast reads as "working". Those are opposite messages, and conflating them is the most common button bug I have seen in Laravel admin panels, usually because someone wired wire:loading.attr="disabled" and called it done.

There are 31 specs now, one per component plus one for colour. Each has a "Do not" section listing the mistakes that were already made once, so they are not made again.

Tests that keep it honest

Extracting a system is the easy half. Keeping it from decaying back into the thing it replaced needs three kinds of automated checks:

  1. Token tests. Every foreground and surface pair that the colour spec promises is measured for contrast in both themes. The timestamp rule above is enforced, not just documented.
  2. Render tests. Every component renders through a real Laravel view, and every class it emits is checked against the compiled CSS. This one exists because Tailwind v4 silently discards any class it does not see in a scanned source. A missing @source line produces 41 unstyled components and no error at all, because "unused" is not a failure. That was the single most common way a fresh install went wrong, so the test suite catches it.
  3. Behaviour tests. Every interactive component, modal, drawer, dropdown, combobox, date picker and the rest, is driven in a real browser. Open, close, keyboard navigation, focus trapping. Six components need Alpine and the focus trap is implemented directly rather than through a plugin, because Alpine ignores directives it has no handler for. A missing plugin would leave a modal that looks right and traps nothing.

What the install looks like now

The whole point was that the next project starts in minutes, not weeks. For a Laravel 12 app the install is one Composer package and three lines of CSS:

@import 'tailwindcss';

@import '../../vendor/shipbytes/blade-ui/dist/tokens.css';
@import '../../vendor/shipbytes/blade-ui/dist/theme.css';
@source '../../vendor/shipbytes/blade-ui/resources/views';

No npm step for a Blade project. The compiled CSS ships in the package. Dark mode is a class on the root element rather than the OS preference, so a toggle can override the system setting, and theme.css carries the [x-cloak] rule that Alpine never ships but every overlay depends on.

<x-ds::button>Save</x-ds::button>
<x-ds::button variant="secondary">Cancel</x-ds::button>
<x-ds::badge tone="success">Active</x-ds::badge>

What I would tell past me

Count first. The hour spent tallying classes paid for itself ten times over, because every later argument was settled by a number instead of by whoever felt more strongly.

Decide what travels before deciding what to build. Sharing components across frameworks is a goal that sounds obvious and fails quietly. Sharing tokens and contracts is boring and works.

Write the exceptions down. The border hairline, the fg-subtle rule, the one place a native select is still the right choice. A system is as much the list of things it deliberately does not do.

The design system is MIT licensed and on GitHub. The documentation, with a screenshot and a spec for every component, is at shipbytes.dev/docs/design-system. The React and Vue packages are not built yet. When they are, they will be thin, because the hard part already travels.

New posts by email

Get new posts by email. Nothing else, no digests, unsubscribe in one click.

Comments

Comments are GitHub Discussions in shipbytes/discussions. Sign in with GitHub to post.