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>
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
Icon sizing is coupled to button size.
IconButtonhardcodes_icon={{ fontSize: "1.2em" }}— theemunit means it scales relative to the button's font size, which is dictated bysize. Want asmbutton with a slightly larger icon? You're overriding internals. The coupling is baked into the component's source, not exposed as configuration.Polymorphism shifts ARIA responsibility to you. v3 replaced the
asprop withasChildfor rendering as a different element. The good news:chakra.buttonno longer injects a conflictingrole="button". The bad news: if you useasChildto render an<a>, you're entirely on your own for role semantics,hrefhandling, and keyboard behavior. The component doesn't help — it just gets out of the way and hopes you know what you're doing.Group still mutates children via
cloneElement. TheGroupcomponent (which replacedButtonGroup) usesReact.cloneElementto injectdata-group-item,data-first,data-last,data-between, plus CSS variables--group-countand--group-indexinto every child. AnIconButtoninside aGroupbehaves 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.Loading silently prevents form submission. The source literally reads
disabled={loading || rest.disabled}. Atype="submit"button withloading={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.Three components for one concept.
Button,IconButton, andCloseButtonare separate exports with overlapping but inconsistent APIs.IconButtonextendsButtonPropswith zero additional types — its entire implementation ispx="0"and an icon font-size override. A CSS class could do that. Meanwhile,IconButtonrequires you to passaria-labelbutCloseButtonhas one baked in. Why the inconsistency? BecauseCloseButtonwas 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. Nocolor, nopadding, noborder-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);
}
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)