Skip to content
ChatfuelSDK
ReferenceUI

Tokens

The semantic token vocabulary in tokens.css — colour roles, sizes, shapes and motion — how dark mode is mapped, and what the design-system passes refuse.

Tokens are semantic — a role, never a measurement — so a rebrand is one edit per role rather than a hunt through call sites, and a component that only ever names roles themes itself.

The colour roles

GroupTokensWhat they are for
Surfaces--color-surface, -raised, -sunken, -hover, -overlay, -inverse, --color-translucent, -strongThe page, then cards and chrome above it, inset rows below it, the panel a popover draws, and the dark body a tooltip uses. The two translucent fills are alpha rather than a grey step, so one value works over any surface in the ramp.
Borders--color-border, -strong, -subtleThe default hairline, the heavier rule a table column or a sticky header needs, and the quiet one inside a card.
Text--color-text, -muted, -faint, -inverseBody, secondary, placeholder and timestamp, and text on surface-inverse.
Accent and status--color-accent, -hover, -soft, -fg; --color-danger, -success, -warning, -info, each with a -softAccent means "selected" everywhere in the system. Each status tone carries a soft companion for the fill behind it.
Interaction--color-focus, -scrim, -row-hover, -row-selected, -row-selected-hover, -skeleton, -skeleton-highlight, -control-knobThe focus outline, the dialog scrim, table row states, the loading shimmer, and the switch thumb that stays light on both tracks.
Pipeline--color-pipeline-1-6An ordered cool-to-warm ramp, six values because a sales pipeline has six stages. Terminal stages keep success and danger instead.
Canvas--color-canvas, -grid, --color-edge, -hover, -selected, -ghost, --color-handle, -active, --color-selection-fill, -stroke, --color-guideA canvas is the one surface explicitly behind everything, and the only place a 1px line must stay readable at 10% zoom and quiet at 250%. The guide is deliberately not the accent: two accent lines meeting at a selected node is one line too many to read.
Chat--color-bubble-in, -in-fg, --color-bubble-out, -out-fgThe two bubble sides, each with its own foreground.
Run--color-run-active, -active-soft, --color-run-skipped, -skipped-softA tool call's step card. running is not info — four blue alerts stacked in a thread is a page of warnings about nothing — and skipped is not text-faint, because "this did not run" is a state rather than a de-emphasis.
Event palette--color-event-1-8, each with -soft and -fgEight nominal tones for calendar events: a solid for a bar or a dot, a soft for a block fill, a foreground for text on it. Spread round the wheel rather than stepped along it, and indigo is absent because indigo means "selected".
Calendar--color-now, --color-off-hours, --color-busy, -soft, --color-blocked, --color-available, -softThe now-line is a landmark, not a warning, so it is not danger: a rebrand that cools the danger red must be able to leave the clock hand alone.
Channels--color-channel-instagram, -whatsapp, -facebook, -tiktok, -widget, each with -softOne pair per messaging platform — glyph and the tinted circle behind it. Brand-adjacent, not brand-exact: every solid clears 3:1 on its own soft in both themes. Widget is the product's own channel, so it is the accent.

Sizes, shapes and motion

GroupTokensNotes
Radius--radius-control, -card, -chip, -bubble, -island, -pillRoles, not a t-shirt scale. 6px reads as a control at field height where 8px reads as a pill.
Elevation--shadow-raised, -overlay, -modal, -drag, -island, --shadow-card-inset, --shadow-secondary-buttonTints go through plain :root variables, so the dark theme re-tints all of them in three lines. A card is a surface step plus a 1px inset ring, not a lifted plane.
Motion--transition-duration-instant (80ms), -fast (120ms), -base (180ms), -slow (260ms), --transition-delay-tooltip (400ms), --ease-standard, -entrance, -exit, -spring, and the --animate-* setEntrances decelerate, exits accelerate. The --animate-* values inline their easing because the keyframe extractor scans them literally.
Z-index--z-index-sticky (10), -island (15), -rail (20), -dropdown (30), -drag (40), -overlay (50), -popover (60), -toast (70), -tooltip (80)One ordered ladder so no component invents a number. The namespace is --z-index-*, not --z-*; that is what makes Tailwind emit z-popover.
Chrome heights--height-topbar, -field, -field-sm, -row-compact, -row-cozy, -row-comfortable, -touch--height-touch is 44px, the WCAG 2.5.8 AAA target, opt-in through the touch-target utility — never by retuning --height-field under (pointer: coarse).
Time grid--height-hour-compact, -cozy, -comfortable, --height-event-minPixels, and mirrored in lib/time/timeGrid.ts: the event geometry is arithmetic on the same numbers the CSS draws with, and a test parses this file to prove they agree. rem would break that.
Band thresholds--container-compact (600px), --container-wide (900px), --container-inline (1280px)The same three numbers as the Band boundaries in lib/interaction/layout.ts, asserted by a test that parses this file. They generate both max-w-* and the @compact: / @wide: / @inline: container variants. Pixels, because container queries in rem resolve against the root font size while a ResizeObserver reports pixels.
Content measures--container-drawer, -auth, -auth-wide, -form, -composer, -prose, -appMax-widths for content columns, in the same namespace.
Layout widths--width-rail, -rail-expanded, -nav-panel, -sidenav, -list, -inspector, -panel, -column, -column-rail, -time-gutter, -time-columnSemantic roles again: --width-list and --width-inspector being equal today is a coincidence, not a synonym. w-* reads this namespace; max-w-* does not, so a max-width has to be written max-w-[var(--width-…)].
Spacing--spacing-gutter, -tight, -loosep-gutter compiles to the variable, so a band scope re-assigns the variable and every p-gutter and gap-gutter already written below it becomes fluid.
Viewport breakpoints--breakpoint-sm-2xlTailwind's stock values, declared explicitly. They drive shell chrome only — a module can be 700px wide inside a 2560px viewport, so the viewport answers the wrong question.
Type--font-sans, --font-mono, --font-display, --font-weight-*, and the roles --text-title, -heading, -body, -label, -meta, -micro, -nanoSize and line height are baked; weight is not, so text-sm font-semibold still means what it says. The upper register (--text-title-4--text-display) is for auth screens and empty states. Tailwind's text-xs / text-sm / text-base are aliased onto the same ramp so two scales cannot run side by side.
Utilitiesfocus-ring, skeleton, touch-targetfocus-ring uses outline, not a ring box-shadow: data-dense screens are full of overflow-hidden containers that clip a shadow, and an outline is never clipped.

How dark mode works

The dark palette is declared once in a plain :root block, and then mapped onto the semantic names twice: once under @media (prefers-color-scheme: dark) scoped to :root:not([data-theme]), and once under [data-theme='dark']. An explicit choice — either value — disables the media rule, and the attribute selector is deliberately not :root-prefixed so an embedding host can put data-theme on its own wrapper element instead of on <html>.

Duplicating the map is the point. light-dark() would end up nested inside the color-mix() Tailwind compiles opacity modifiers to: bg-accent/50 becomes a color-mix() over the variable, guarded by @supports, with a hex fallback baked from the light value at build time. The two blocks are kept honest by a validate pass that fails if either declares a property the other does not.

@theme compiles into @layer theme, and unlayered rules beat layered ones regardless of source order, so these overrides win wherever the app places the @import — no !important and no ordering fragility. color-scheme is set in both blocks and is not cosmetic: forms/Select is a real native <select>, and without it the dropdown, the scrollbars and autofill stay light.

No component carries a dark: variant and none hardcodes a colour, so theming — dark mode included — is a tokens-only edit to this one file. Reduced motion works the same way: the prefers-reduced-motion block retunes --transition-duration-* and the --animate-* set rather than resetting animations globally, which would also freeze the spinner.

Why @theme static

@theme static keeps every variable in :root even when no utility currently uses it. Without it Tailwind tree-shakes unused theme variables, and the design-system gallery — which reads token values back with getComputedStyle — renders them as a phantom em dash. That is also why Tailwind's own breakpoint values are re-declared here: their defaults live in a non-static @theme and get shaken out.

What the passes enforce

pnpm validate runs these; pass 11 is the design system's own.

RuleWhat fails
11(a) dark-block parityA property declared in one dark block and not the other. The two are duplicated on purpose, and this is what keeps the duplicate in lockstep.
11(b) no raw colour#rgb, rgb(, rgba(, hsl( or hsla( in any .ts/.tsx under packages/ui/src, outside icons/ where the match would be SVG path data. Avatar is the one component that needs a ramp, and it renders oklch() over --avatar-* tokens.
11(d) barrel completenessA component file missing from its directory's barrel, a directory barrel missing from src/index.ts, or a new directory classified as neither a component nor a non-component directory. internal/ is the opt-out.
11(e) focus-ringThe utility written bare. It sets outline unconditionally, so bare it draws all the time — on the deals board that was every card, permanently ringed. Write focus-visible:focus-ring.
11(g) no viewport prefixsm: / md: / lg: / xl: / 2xl: in apps/shell/src/modules. A module is sized by its container: use useBand when the answer changes what renders, a container variant when it only changes how it looks. Shell chrome is exempt, because the shell really is window-sized.
11(h) no text-[Npx]A pixel size outside the type scale, anywhere in the shell, the gallery or packages/ui.
11(i) glued classesA class written flush against a template interpolation. Tailwind reads source as text, so shadow-island${ matches no utility, the rule is never generated, and nothing reports it — a missing class is a missing rule, not an error.
11(j) zero-sized <svg>h-0, w-0 or size-0 on an <svg>. The specification disables rendering of an element whose width or height is zero, and overflow: visible never gets a say; it cost this repository every line on the canvas.

Both 11(g) and 11(h) are adjudicated by the TypeScript parser rather than a line regex, because the spelling each bans is also the spelling its own comment has to use — a comment is trivia and never a node, which is the property the rule rests on.

On this page