Theming a Drop-In UI Kit Without Forcing a Fork
Every prebuilt UI component library eventually meets a design system it was not built for. At that point the integrating developer makes one of three choices: accept the defaults, override with increasingly specific CSS, or copy the source into their repository and stop taking updates.
The third outcome is the expensive one. It looks like success in the sprint it happens and becomes a maintenance liability for years, because the fork no longer receives fixes, accessibility corrections, or new platform behaviour.
I work on developer-facing UI Kits at CometChat, where the whole value proposition is that a team drops in a conversation interface instead of building one. That makes forking a product failure, not a customer mistake. It has forced me to think about customisation as an API with a compatibility contract, rather than as a styling convenience.
The layered model
Customisation requests are not one thing. They arrive at four different depths, and each needs its own mechanism.
| Depth | What the developer wants | Mechanism |
|---|---|---|
| Tokens | Brand colours, radius, type scale, spacing | CSS custom properties |
| Component styling | This one button looks wrong in our layout | Documented class or style hooks per component |
| Composition | Add a field, reorder the header, remove an action | Slots and render props |
| Behaviour | Different data source, different send pipeline | Headless core with the UI as an optional layer |
The failure mode is offering only the first and the last: a colour variable and ""you can build it yourself"". The middle two are where real integrations live.
Tokens are the contract you will never be able to change
CSS custom properties are the right substrate for the token layer because they cascade, they can be scoped per subtree, and they cost nothing at runtime. They also become a public API the moment you document them.
.my-app-chat {
--cc-color-primary: #2b6cb0;
--cc-color-surface: #ffffff;
--cc-radius-md: 10px;
--cc-font-family: ""Inter"", system-ui, sans-serif;
}
Two decisions matter more than the names:
Semantic, not literal. --cc-color-primary survives a redesign; --cc-color-blue-500 does not. Literal names force you to either lie or rename, and renaming is a breaking change.
Every internal value resolves through a token. The moment one component hardcodes a hex value, the theme has a hole in it, and the developer who finds that hole reaches for !important. That is the first step towards the fork.
Support dark mode by redefining tokens under both a prefers-color-scheme query and an explicit attribute selector, so a host application that has its own theme toggle can drive yours.
Slots are what stop the fork
Most ""we had to fork it"" stories are not about colour. They are about structure: the team needed an extra action in the message header, a custom empty state, a badge next to a name, their own attachment picker.
If your components accept slots for their meaningful regions, those requests are satisfied without touching your internals:
<MessageList
renderHeader={(conversation) => <OurHeader conversation={conversation} />}
renderMessage={(message, Default) =>
message.type === ""order"" ? <OrderCard message={message} /> : <Default />
}
renderEmptyState={() => <OurOnboardingPrompt />}
/>
Note the second argument in renderMessage. Passing the default renderer back into the override is a small thing that changes the economics: developers can special-case one message type without reimplementing the other fifteen. Overrides that require all-or-nothing replacement get used all-or-nothing.
Escape hatches, deliberately placed
You will not anticipate every requirement, so plan the exit. A headless layer - hooks or plain functions that expose state and actions without markup - lets a team keep your data handling and connection management while writing their own UI. That is a customer who still gets your bug fixes.
The rule I try to hold to: any escape hatch should degrade one layer at a time. Wanting custom markup for the composer should not mean giving up presence handling, and wanting a different send pipeline should not mean giving up the message list.
Versioning: the part that decides whether upgrades happen
A UI Kit has a wider public surface than a normal library, and much of it is not in the type definitions. Class names people style, token names they set, DOM structure they select against, slot signatures, default visual behaviour - all of it is API in practice.
So the policy has to be explicit:
- Documented tokens, slots, and class hooks follow semver. Renaming one is a major version.
- Internal class names are namespaced and documented as unstable, and the docs say what to use instead.
- Visual changes that alter layout dimensions get called out in release notes even when no code changed, because they can break a host page's surrounding layout.
- Deprecate in two steps: keep the old name working alongside the new one with a console warning, then remove it in the next major. A deprecation that ships and removes in the same release is just a break with extra words.
- Ship a migration note with a code example per breaking change. Upgrade friction is why people stay on old majors, and people on old majors file bugs you already fixed.
Accessibility is not a customisation option
One asymmetry is worth stating: theming should not be able to break keyboard navigation, focus visibility, roles, or contrast. Tokens that control colour should ship with documented contrast expectations, and focus indicators should not be removable through the theme layer alone.
If a developer has to choose between matching their brand and keeping the component accessible, the design of the theming layer is at fault, not the developer.
The test I apply
Before shipping a customisation feature, I ask: does this let someone satisfy the request without reading our source? If the answer is no, they will read the source, and once they have read it, copying it is a short step.
A UI Kit is measured by how many teams are still on a recent version a year later. Every customisation mechanism above exists to make that number higher.
Top comments (0)