Skip to content
ChatfuelSDK
Guides

Change how it looks

One file of tokens moves every component in both themes. The duplicate-block rule, the two places motion lives, the fonts that are the app's rather than the system's, and the edits that generate nothing.

No component in the design system carries a dark: variant, and none of them hardcodes a color. Every one is written against semantic utilities — bg-surface, text-text-muted, rounded-card, shadow-overlay, duration-fast — which means the whole of a rebrand, dark mode included, is an edit to one file. That file is src/vendor/ui/styles/tokens.css, it is yours, and there is no upstream package to fight about it.

The names themselves are on the token reference. This is what happens when you change one.

Change the token

The file has four sections in order: @theme static { … } for anything that should generate a utility, a plain :root for raw constants that should not, then the dark blocks, then the custom utilities.

Almost everything you want is in the first one — the surface ramp, the borders, the text tones, the accent and its hover, four status tones with a soft variant each, the interaction colors, a six-value pipeline ramp, the radii, the shadows, the type ramp, the durations and easings, a z-index ladder and the density heights. Change --color-accent and every button, every focus ring's neighbour, every selected row and every accent border follow at once.

Two spellings decide whether that happens at all.

@theme static, not plain @theme. Plain @theme lets Tailwind tree-shake variables no utility currently uses out of :root, and anything reading them back with getComputedStyle then sees nothing.

And the namespace has to be the one Tailwind expects, because the namespace is what decides which utility gets generated.

Do the dark side in the same edit

The dark palette is declared once, as --dark-* values in the plain :root block. Three blocks sit below it — two that re-map those values onto the semantic names, and one that exists for a single property — and they are separate on purpose:

BlockWhen it applies
@media (prefers-color-scheme: dark) { :root:not([data-theme]) }the system default, and only while nobody has chosen
[data-theme='dark']an explicit choice
[data-theme='light']also an explicit choice — it exists to set color-scheme

Both dark blocks have to declare the same set of properties. Add a color to one and not the other and the app themes correctly under a toggle and wrongly under the system setting, or the reverse. Upstream a validator pass compares the two property by property and names the one that is missing; in your copy that comparison is yours to make, so make the two edits together.

The third block earns its two lines: color-scheme is not cosmetic. Without it, native <select> popups, scrollbars and autofill stay light inside a dark UI.

The attribute selector is deliberately not :root-prefixed. That is what lets an embed host stamp data-theme on the wrapper element holding the Chatfuel tree instead of on <html>useTheme({ target, persist }) takes that element, and persist: false keeps it from fighting a theme preference the host already owns.

Swap the faces

tokens.css only names the families — --font-sans, --font-mono, --font-display. The faces themselves are npm packages the app imports, in its own CSS entry:

src/index.css
@import 'tailwindcss';

@import '@fontsource-variable/geist';
@import '@fontsource-variable/geist-mono';
@import '@fontsource-variable/manrope';

Self-hosted, not linked: nothing is fetched from a third-party origin at runtime, which is what lets a deployed app work behind an egress proxy. Swapping a face is a dependency swap plus one line in each of the two places.

Beside tokens.css sits base.css, and the split matters if you ever embed. tokens.css contributes theme variables and nothing else; base.css paints <body>, restyles every h1h6 on the page to the display face, and claims ::selection and every scrollbar. A standalone app imports both. Somebody else's app imports one — see putting the modules in an app you already have.

Retune motion in two places

--transition-duration-{instant,fast,base,slow} and --ease-{standard,entrance,exit,spring} drive every CSS transition. They are mirrored, as plain numbers and strings, in src/vendor/ui/lib/interaction/motion.ts, because the Web Animations API cannot read CSS custom properties and the drag paths use element.animate().

Nothing enforces the pair. Retune a curve in the CSS and not in the mirror and a dragged card and the transition underneath it ease differently — which is exactly what happened once, when a palette port moved --ease-standard and --ease-entrance and left the mirror on the old curves.

The same split runs through reduced motion. A media block collapses every duration token to 1ms, and WAAPI never sees it, so every element.animate() path consults prefersReducedMotion() instead. If you add an animated path of your own, it consults it too.

Check it

npm run check
npm run dev

Open localhost:5173. A changed --color-accent shows up on the primary button, the active rail item and the selected table row in the same load — if it moved in one place and not another, something in that second place is not reading the token.

Then switch the theme with the toggle in the top bar and switch your operating system between light and dark with the toggle set to follow the system. Those are the two dark blocks, and the second one is the one people forget: a token that looks right under the toggle and wrong under the system setting is a property present in one block and missing from the other.

A custom property in the wrong namespace generates no utility, and nothing anywhere says so. It is --z-index-sticky, not --z-sticky. It is --transition-duration-fast, not --duration-fast. --height-* backs h-* and --width-* backs w-*, so --height-field does not give you w-field. The variable is in the file, the class is in the markup, the build is green, and the rule was never written.

The two rules a component keeps

Editing a component is the other half of this, and src/vendor/ui/ is written to be edited — primitives, forms, floating surfaces, overlays, the table, the drag-and-drop layer and the chat set, all dependency-free React over those tokens. Two rules survive the edit.

No raw color literal. No hex, no rgb(), no hsl() in a component. A hardcoded color is a color that can never follow the theme, and it is the one thing that turns a tokens-only rebrand back into a hunt. The one place that genuinely needs a ramp — the avatar's per-name color — goes through oklch() over --avatar-* tokens so the dark theme flips four numbers instead of fourteen hex values.

focus-visible:focus-ring, never bare focus-ring. The utility sets outline unconditionally; it is a rule, not a variant. Written bare it paints a permanent ring on every instance of the element — which is what the deals board looked like once, every card outlined. It uses an outline rather than a box-shadow ring because data-dense screens are full of overflow-hidden scroll containers that clip a shadow.

Two more habits are worth keeping because their failure mode is silence. Never write a class flush against a template interpolation — Tailwind reads source as text, so `... shadow-island${x}` hands it the candidate shadow-island${, generates nothing, and the element quietly has no shadow. And keep module code free of viewport prefixes: a module can be 700px wide inside a 2560px window, so md: asks the window a question only the container can answer, and it is right by accident on a full-width desktop.

Upstream, pnpm validate refuses all of the above — the mismatched dark blocks, the color literal, the bare focus-ring, the glued class, the viewport prefix, a pixel size outside the type scale, and a component missing from the barrel that makes it reachable through ~ui. Your app carries none of those gates, because vendoring hands you the source and not the toolchain around it. They are still the rules the tree was written under, and a copy that keeps them stays a copy you can diff against ours.

The component set itself, state by state, is on UI patterns.

On this page