# 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.

Page: https://sdk.chatfuel.com/docs/guides/customize-the-design-system
Markdown: https://sdk.chatfuel.com/docs/guides/customize-the-design-system.md

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](/docs/reference/ui/tokens). This is what happens
when you change one.

<Steps>
  <Step>
    ### Change the token [#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.
  </Step>

  <Step>
    ### Do the dark side in the same edit [#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:

    | Block                                                             | When 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.
  </Step>

  <Step>
    ### Swap the faces [#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:

    ```css title="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 `h1`–`h6`
    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](/docs/guides/embed-into-an-existing-app).
  </Step>

  <Step>
    ### Retune motion in two places [#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.
  </Step>

  <Step>
    ### Check it [#check-it]

    ```bash
    npm run check
    npm run dev
    ```

    Open [localhost:5173](http://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.
  </Step>
</Steps>

<Callout type="warn">
  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.
</Callout>

## The two rules a component keeps [#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](/docs/concepts/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](/docs/reference/ui/patterns).
