DEV Community

Toucan
Toucan

Posted on

Your design system has a coupling problem

Pick a popular component library, find the Button component.

This one thing does three jobs (structure, interactivity, skin/aesthetic).

Structure — the semantic HTML and accessibility contract. What element is it? What ARIA role does it have? What does it declare to assistive technology?

Interaction — the behavioural logic. Focus management, disclosure state, keyboard navigation, scroll locking (i.e modal open).

Aesthetics — the visual treatment. Colors, spacing, typography, border radius, elevation. Everything that defines the look of a digital interface....the brand.

These "3 pillars" are subject to constant change in any product...no exclusions. We need to anticipate these changes/fluctuations. Examples: The color palette is updated (aesthetics). New form fields are required which rightly demands accessibility & inclusion (structural). Requirement for a keyboard shortcut is introduced (interaction).

Binding Structure, Interaction, Aesthetics is a breeding ground for terrible UI/UX. Think buttons...we have icons, pagination, forms, linking to 3rd party sites...all buttons, all entangled. Whats the ARIA, whats the color, what's the icon size. Entangled...And that's buttons, which are an intrinsic part of forms. So now we're dealing with inputs and checkboxes and textareas and the list goes forever on.

The evidence

// Simple button
<Button colorPalette="blue">Save</Button>

// Icon button — separate component, zero padding preset,
// but literally just extends ButtonProps with no new types.
<IconButton aria-label="Search">
  <SearchIcon />
</IconButton>

// Button with icon — just compose as children now,
// but YOU manage the icon sizing and spacing.
<Button colorPalette="teal">
  <EmailIcon /> Email
</Button>

// Loading state — still baked into Button itself.
// Sets data-loading, disables the button, swaps children for a Loader.
<Button loading loadingText="Submitting">Submit</Button>

// Custom spinner? Pass a whole component via prop.
<Button loading spinner={<BeatLoader />}>Submit</Button>

// Spinner placement? Another prop.
<Button loading spinnerPlacement="end" loadingText="Saving...">Save</Button>

// Link that looks like a button — polymorphic `as` prop.
<Button as="a" href="https://example.com" target="_blank">
  External Link
</Button>

// Router link that looks like a button — different `as`.
<Button as={ReactRouterLink} to="/dashboard">Dashboard</Button>

// Button group — parent still controls children's layout,
// border-radius, and attached styling.
<Group attached>
  <Button variant="outline">Save</Button>
  <IconButton variant="outline" aria-label="Add">
    <AddIcon />
  </IconButton>
</Group>

// Close button — a THIRD component, with a default
// aria-label baked in. Different from IconButton why, exactly?
<CloseButton />

// Form submit — now the same component also needs to
// know about HTML form semantics.
<Button type="submit" form="checkout-form" loading={isSubmitting}>
  Pay Now
</Button>
Enter fullscreen mode Exit fullscreen mode

Prop mapping blow up

Concern Props / API
Loading loading, loadingText, spinner, spinnerPlacement
Polymorphism as (a, RouterLink, etc.)
Variants variant, colorPalette, size
State disabled, data-loading (set internally)
Form type, form, formAction
Group context Group component controls attached layout, border-radius
Accessibility aria-label required on IconButton, baked into CloseButton, inferred nowhere
Separate components Button, IconButton, CloseButton — three exports for one concept

Where the entanglement bites

  1. Icon sizing is coupled to button size. IconButton hardcodes _icon={{ fontSize: "1.2em" }} — the em unit means it scales relative to the button's font size, which is dictated by size. Want a sm button with a slightly larger icon? You're overriding internals. The coupling is baked into the component's source, not exposed as configuration.

  2. Polymorphism shifts ARIA responsibility to you. v3 replaced the as prop with asChild for rendering as a different element. The good news: chakra.button no longer injects a conflicting role="button". The bad news: if you use asChild to render an <a>, you're entirely on your own for role semantics, href handling, and keyboard behavior. The component doesn't help — it just gets out of the way and hopes you know what you're doing.

  3. Group still mutates children via cloneElement. The Group component (which replaced ButtonGroup) uses React.cloneElement to inject data-group-item, data-first, data-last, data-between, plus CSS variables --group-count and --group-index into every child. An IconButton inside a Group behaves differently than one outside it — border-radius gets stripped, negative margins get applied — and none of that is visible in your JSX. Classic action-at-a-distance.

  4. Loading silently prevents form submission. The source literally reads disabled={loading || rest.disabled}. A type="submit" button with loading={true} becomes a disabled button — it will not submit the form. The loading and form concerns collide, and there's no warning. You have to just know.

  5. Three components for one concept. Button, IconButton, and CloseButton are separate exports with overlapping but inconsistent APIs. IconButton extends ButtonProps with zero additional types — its entire implementation is px="0" and an icon font-size override. A CSS class could do that. Meanwhile, IconButton requires you to pass aria-label but CloseButton has one baked in. Why the inconsistency? Because CloseButton was designed for dialogs, not general use — the coupling to a specific context leaks into the API.

Let's step away from system where changing a color means touching every component that references it. It seems like an obvious solution to me. Or just please, no more theme providers that wrap your entire app in a JavaScript object of visual decisions/demands. Flutter being the only excuser of that request - keep doing what you're doing.

Hello tokens

Tokens - named variables that store visual decisions. A color, a spacing value, a border radius. Instead of hardcoding #2563eb in a component, you reference --color-primary. Instead of 16px, you reference --space-md. The value lives in one place, the token. Everything else points to it.

My argument

Decouple. That's it.

Components own structure and interaction. Tokens own aesthetics.

Concretely:

  • A <Button> defines its <button> element, role, aria-pressed, keyboard handler. That's it. No color, no padding, no border-radius. None.
  • A token system defines every visual value — --color-primary: #2563eb, --space-md: 1rem, --radius-sm: 4px — as CSS custom properties. Primitives feed semantic names (--color-action-primary), semantic names feed component bindings (--button-bg).
  • One CSS file maps tokens to component hooks: .button { background: var(--button-bg); padding: var(--button-padding); border-radius: var(--button-radius); }.

Just look. If you have buttons that's it. It's dead simple. Let's remove complications.

.button {
  background: var(--button-bg);
  padding: var(--button-padding);
  border-radius: var(--button-radius);
}
Enter fullscreen mode Exit fullscreen mode

That's the entire wiring. Three layers (raw HTML, :root vars, CSS mapping), zero entanglement.

What this fixes

Theme change? Edit one token. --color-primary: #2563eb becomes --color-primary: #dc2626. Every alias that references it — --color-action-primary, --color-link, --color-focus-ring — resolves to the new value automatically. No component touched. No JS redeployed. One CSS variable, done. Oh...your buttons all use the primary color...update one token, done. Oh...you have a backend system that requires a tweak of the same brand, sure, same components, same patterns, different tokens, new look, done. All by changing 1 key/value pair.

So, why not CSS-in-JS?

CSS-in-JS solved real problems — specificity wars, naming collisions, dead code. But it welded visual treatment to the component tree. styled(Button) isn't styling a button, it's creating a new component. That's coupling.

We traded global CSS problems for component coupling. Same mess, different file.

Uhm, Tailwind?

Tailwind removed JavaScript coupling. Real progress. But bg-blue-500 hover:bg-blue-600 dark:bg-blue-400 px-4 py-2 rounded-lg is still aesthetics tangled with structure. Theming means find-and-replace across every component. Dark mode means dark: on every class. Brand refresh means touching every file.

Yes, you can consolidate with @apply.button { @apply bg-blue-500 hover:bg-blue-600 dark:bg-blue-400 px-4 py-2 rounded-lg }. But now you're just remapping utility classes to custom classes. You've added a layer of indirection without removing the coupling. The values are still hardcoded, the dark mode logic is still per-class, and a brand refresh still means updating every @apply block. You've moved the problem, not solved it. Tokens solve it at the source — one value changes, everything downstream follows.

Tailwind is utility-first. What I'm describing is token-first. The visual decisions don't live in markup at all. They live in one token layer that feeds CSS that feeds components. The component never knows what color it is.

The separation

Concern Owns Doesn't touch
Structure HTML elements, ARIA roles, semantic contracts Colors, spacing, JS behaviour
Interaction Focus, keyboard nav, disclosure, scroll lock Markup choice, visual treatment
Aesthetics Tokens → CSS custom properties → component hooks DOM structure, event handling

Three columns. No overlap. Change one, the others don't know and don't care.

This isn't theoretical. This is the way we should be building - stop tying things together that share different concerns. It's a gap that needs addressing. As such - I built this. And if individual tokens are too much (I agree) here's a helper.

Get coding - your best bird Toucan.

Top comments (0)