Theming
KinetixUI uses a semantic CSS variable contract. Components reference semantic names only; the mapping to real colours lives in one place.
The contract#
| Variable | Light | Figma source |
|---|---|---|
--background | #ffffff | surfaceContainerLowest |
--foreground | #050c11 | onSurface |
--primary | #1d4ed8 | action blue (replaces Figma Primay navy) |
--primary-foreground | #f0f7ff | On Primary |
--secondary | #c7cfc7 | secondaryContainer (deepened for surface contrast) |
--muted / --muted-foreground | #f6f6f6 / #6d6d6d | surfaceContainer / onSurfaceVariant |
--accent | #f0f7ff | LightBlue |
--destructive | #ec5047 | error |
--border / --input | #92b2c8 | outline |
--ring | #1d4ed8 | focus outline (tracks --primary) |
--radius | 8px | Button |
--card, --popover, --ring, the neutral ramp, and the entire dark theme are
synthesized — Figma does not define them yet. Every synthesized value is
listed in the repo's TOKENS.md.
Both themes#
Light and dark are two independent palettes — not one value set flipped. Most
light roles are aliased from Figma; --primary / --ring are an action blue
(#1d4ed8 light, #60a5fa dark, each picked to clear WCAG AA on its own
surface); the rest of dark is synthesized.
light
dark
Retheme#
Override the semantic variables after importing the contract. Primitives stay put; only the mapping changes:
@import "@kinetixui/tokens/css";
:root {
--primary: var(--green-500);
--primary-foreground: var(--green-50);
--radius: 12px;
}Role tokens: brand, action, link, focus#
primary is doing several jobs in a shadcn-style contract: the fill of a button, the colour of a link,
the focus ring, and — in a lot of brands — the brand colour itself. Those diverge as soon as your brand
colour isn't a good button colour (KinetixUI's own navy wasn't, which is why primary is azure). Four
role tokens sit on top of it so you can split them:
| Token | Meaning | Default |
|---|---|---|
--action / --action-foreground | the interactive fill: buttons, checkboxes, selected states, spinners | var(--primary) / var(--primary-foreground) |
--link | Link button, inline links | var(--primary) |
--focus | focus indicator | var(--ring) |
--brand / --brand-foreground | identity: logos, headers, brand surfaces — not a control colour | the navy blue.500 |
Because action, link and focus default to primary / ring, a theme that only sets
--primary (everything above, and everything the CLI's theme command writes) keeps working unchanged.
Override a role on its own to split it off:
:root {
--primary: var(--green-500); /* everything follows… */
--action: var(--azure-700); /* …except buttons and controls, which stay azure */
--brand: var(--green-700);
}Components read the role tokens (bg-action, text-link), not primary, so overriding --primary
still recolours them via the default and overriding --action recolours only the interactive layer.
Contrast is on you when you split them: kinetixui theme build reports action / action-foreground
and brand / brand-foreground when you set them.
Hover and pressed. On the web the Button derives its states from action (bg-action/90,
bg-action/85), so they follow whatever you set. The contract also carries explicit
--action-hover / --action-pressed values — the same two colours, precomputed over the page
background — for the SwiftUI, Compose and Flutter ports, which have no alpha-derivation to lean on.
pnpm check:contrast asserts the explicit values equal the derivation and that text on them clears AA.
A theme generated in Create derives the same two states the same way, with one constraint
on top: the label has to stay readable on all three fills. action-foreground is chosen against
action, action-hover and action-pressed together rather than the resting colour alone, and where
no foreground can carry the full 90% / 85% move, both states scale back in step until it can. Some
brand colours therefore get a slightly subtler hover than the shipped blue does — the alternative was a
button label at 3.4:1 while pressed, which is what this used to do.
Native. The SwiftUI, Compose and Flutter components read the same role tokens: KinetixColors has
action, actionForeground, actionHover, actionPressed, link, focus, brand and
brandForeground alongside primary / ring, and the components use the role fields (the Button's
Link variant reads link; focus rings read focus). The primary Button uses actionHover on pointer hover and actionPressed while pressed; other components keep the platform's own interaction feedback. primary remains as the source they default to.
Upgrading: components that use bg-action / text-link need the matching
@kinetixui/ui/tailwind.config preset (it defines the new colours) — update the package when you
re-add components with the CLI.
Primitive ramps#
Seven ramps (azure, blue, green, taupe, cream, amber, red) plus a
synthesized neutral, each 0–1000. Browse them on the Colors
page.
Building a theme visually#
Create generates a theme from a handful of choices rather than a list
of hex values. Pick a theme colour and it derives the brand, action, link and
focus roles from it, along with the hover and pressed states and a foreground
that clears AA — in OKLCH, so the same rule produces the same perceptual step on
every hue. A separate neutral control sets the personality of the greys, and
radius and surface presets write the --radius-* and --shadow-* variables
documented above.
Its Export panel then generates that design for one of four targets: web CSS,
SwiftUI, Jetpack Compose or Flutter. The web CSS is an override block containing
only what differs from the shipped theme, with a :root and a .dark section —
one configuration describes both appearances. The three native targets are a
KinetixColors pair each, light and dark. Manual values set in the Advanced panel
beat everything generated, and a contrast failure you introduce there is reported
rather than quietly corrected.
Each target also shows the kinetixui preset … command that writes the same file,
because the CLI and the workspace run the same exporter.
Radius and surface reach SwiftUI and stop there.
KinetixRadii and KinetixElevations are part of its theme, its
components read them, and preset swiftui writes them — so a design's corners and
shadows arrive intact, minus shadow spread, which SwiftUI has no equivalent for.
Compose and Flutter still read generated constants rather than a runtime theme, so
there is nowhere for either to land on those two. Colours and the chart palette
travel everywhere.
kinetixui theme build is a separate, older path — a CSV of literal colours
compiled to CSS — and is web-only; see the CLI page for its scope.
Presets#
A configuration can be saved as a preset: a short code beginning KX1_ that
describes the design and nothing else. It carries the theme colour, the neutral,
the radius, the surface treatment, the chart palette and any manual overrides —
not the generated colours, not the CSS, and not which appearance or demo scene
you happened to be looking at. Those are regenerated from the preset by the same
engine that produced them the first time.
Share copies a /create?preset=KX1_… link that reopens the workspace with
that design. Copy preset copies the bare code, which is what
kinetixui preset decode reads and
kinetixui preset css resolves into this same stylesheet without a
browser — and preset swiftui, preset compose and preset flutter
resolve into native theme files.
The workspace's Export panel is the same four targets in the browser: one design, one resolved theme, and the exporter for whichever target is selected. A preset stays a different artifact from any of them — a preset is the design, generated code is one rendering of it.
The preview is always web. Selecting SwiftUI, Jetpack Compose or Flutter changes what Export generates, not what is drawn: those targets carry colours, and radius and surface apply on the web only, because no native package has a runtime token for them.
The version is in the prefix, so a code made today stays readable, and a future format is refused with an explanation rather than misread. A preset can only name tokens from the contract above — it is structured configuration, never arbitrary CSS.
Presets reach web CSS and all three native colour contracts.
preset swiftui, preset compose and
preset flutter resolve a preset into a KinetixColors pair for
SwiftUI, Compose and Flutter.
Colours only in all three: a design's radius and surface treatment apply on the
web and have no runtime token to land on natively. Flutter carries the whole
semantic contract; Compose is missing fields for input, ring and
tertiary-foreground, and its generated file says so.