Theming
Every colour, surface and radius is a --cennge-* CSS variable. Change one in your stylesheet, for the whole page or for one element, and the components under it follow, with no rebuild.
How it works#
cennge-ui/styles.css declares the theme as custom properties: the light theme on :root, the dark one on .dark. A Tailwind @theme inline block then maps them to utilities, so bg-primary compiles to background-color: var(--cennge-primary). The utilities read the variable where the element is, at runtime: override it on :root and the whole app changes; override it on one element and only that part of the page does.
There is no JavaScript theme object and no provider to wrap. Light and dark are a class, the accent is an attribute, and the rest is the tokens below.
Surfaces#
Depth comes from layered surfaces more than from shadows. Each step is a little lighter in the dark theme, and a little whiter in the light one.
bg-surface-1bg-surface-2payload-cache
Deploy from main
bg-surface-3bg-surface-4import { Badge, Card, CardBody, CardHeader, ToggleGroup, ToggleGroupItem } from "cennge-ui";
function Token({ children }: { children: string }) {
return <code className="rounded-small bg-default-bg px-1.5 py-0.5 font-mono text-xs text-muted">{children}</code>;
}
function Layer({ name, token }: { name: string; token: string }) {
return (
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-muted">{name}</span>
<Token>{token}</Token>
</div>
);
}
export default function Surfaces() {
return (
<div className="flex w-full flex-col gap-3 rounded-large border border-border bg-surface-1 p-3 sm:p-4">
<Layer name="Page" token="bg-surface-1" />
<div className="flex flex-col gap-3 rounded-large bg-surface-2 p-3 sm:p-4">
<Layer name="Panel" token="bg-surface-2" />
<Card>
<CardHeader className="items-start justify-between">
<div className="min-w-0">
<p className="font-semibold">payload-cache</p>
<p className="text-sm text-muted">Deploy from main</p>
</div>
<Token>bg-surface-3</Token>
</CardHeader>
<CardBody className="flex-row flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3">
<ToggleGroup aria-label="Environment" size="sm" defaultValue="production">
<ToggleGroupItem value="production">Production</ToggleGroupItem>
<ToggleGroupItem value="preview">Preview</ToggleGroupItem>
</ToggleGroup>
<Badge color="success" variant="dot">
Ready
</Badge>
</div>
<span className="flex items-center gap-2 text-xs text-muted">
Selected segment <Token>bg-surface-4</Token>
</span>
</CardBody>
</Card>
</div>
</div>
);
}--cennge-backgroundbg-background- The page. The same colour as surface 1.
--cennge-surface-1bg-surface-1- The page, and what sits flush on it, such as the Sidebar.
--cennge-surface-2bg-surface-2- Panels laid into the page: tables, faded fields and snippets.
--cennge-surface-3bg-surface-3- Cards, menus, popovers, toasts and flat fields.
--cennge-surface-4bg-surface-4- What is raised on those: the default Button, Switch tracks, the chosen segment, tooltips.
--cennge-foregroundtext-foreground- Text.
--cennge-mutedtext-muted- Secondary text: descriptions, hints, placeholders.
--cennge-borderborder-border- Hairlines, and the borders of bordered and faded variants.
--cennge-ringoutline-ring- The keyboard focus ring: the page's primary colour unless you set it.
Colour roles#
Seven roles name what a colour means rather than what it is: default, primary, secondary, success, warning, danger and info. They are the values of the color prop, wherever a component has one. primary is the accent, default is neutral, and the other five are fixed: violet, green, amber, red and blue.
import { Fragment } from "react";
import type { CenngeColor } from "cennge-ui";
const roles: { name: CenngeColor; label: string }[] = [
{ name: "default", label: "Archived" },
{ name: "primary", label: "Featured" },
{ name: "secondary", label: "Beta" },
{ name: "success", label: "Passed" },
{ name: "warning", label: "Flaky" },
{ name: "danger", label: "Failed" },
{ name: "info", label: "Queued" },
];
// Whole class names, so Tailwind finds them in the source.
const solid: Record<CenngeColor, string> = {
default: "bg-default text-default-foreground",
primary: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
success: "bg-success text-success-foreground",
warning: "bg-warning text-warning-foreground",
danger: "bg-danger text-danger-foreground",
info: "bg-info text-info-foreground",
};
const tint: Record<CenngeColor, string> = {
default: "bg-default-bg",
primary: "bg-primary-bg",
secondary: "bg-secondary-bg",
success: "bg-success-bg",
warning: "bg-warning-bg",
danger: "bg-danger-bg",
info: "bg-info-bg",
};
const highlight: Record<CenngeColor, string> = {
default: "bg-default-highlight",
primary: "bg-primary-highlight",
secondary: "bg-secondary-highlight",
success: "bg-success-highlight",
warning: "bg-warning-highlight",
danger: "bg-danger-highlight",
info: "bg-info-highlight",
};
const text: Record<CenngeColor, string> = {
default: "text-default-text",
primary: "text-primary-text",
secondary: "text-secondary-text",
success: "text-success-text",
warning: "text-warning-text",
danger: "text-danger-text",
info: "text-info-text",
};
const chip = "inline-flex h-7 items-center rounded-small px-2.5 text-xs font-medium";
export default function ColourRoles() {
return (
<div className="grid w-full grid-cols-[auto_auto_auto_1fr] items-center gap-2 sm:gap-x-4">
{["Solid", "-bg", "-highlight", "-text"].map((heading) => (
<span key={heading} className="pb-1 font-mono text-xs text-muted">
{heading}
</span>
))}
{roles.map(({ name, label }) => (
<Fragment key={name}>
<span className={`${chip} ${solid[name]}`}>{name}</span>
<span className={`${chip} ${tint[name]} ${text[name]}`}>{label}</span>
<span className={`${chip} ${highlight[name]} ${text[name]}`}>{label}</span>
<span className={`text-sm font-medium ${text[name]}`}>{label}</span>
</Fragment>
))}
</div>
);
}Each role is a set of variables, here for primary:
--cennge-primary-50 … -950bg-primary-500- The scale, eleven steps from 50 to 950, each a utility of its own.
--cennge-primarybg-primary- The solid fill.
--cennge-primary-foregroundtext-primary-foreground- Text on the solid fill.
--cennge-primary-texttext-primary-text- The role as text: step 800 in the light theme, 300 in the dark one.
bg-primary-bg- A 14% tint of the fill, mixed by the utility: the fill of flat variants.
bg-primary-highlight- A 26% tint of the fill: their hover.
Every pair meets WCAG AA, 4.5:1: the foreground on the fill, and the text colour on every surface and on the role's own two tints. That is why the text step changes with the theme. default is the neutral role: its fill is surface 4 and its text is the foreground.
Accent presets#
data-accent swaps the accent for one of five presets: orange, the default, blue, green, violet and red. Put it on <html> for the whole page, or on any element for the part inside it. A preset sets only primary (its scale, fill, foreground and text) and the order of the chart palette; the other roles stay as they are. Each row below is a <div> with its own data-accent.
data-accent="orange"data-accent="blue"data-accent="green"data-accent="violet"data-accent="red"import { Badge, Button } from "cennge-ui";
const accents = ["orange", "blue", "green", "violet", "red"] as const;
// The chart palette in order: each preset sets its own.
const slots = [
"bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4",
"bg-chart-5", "bg-chart-6", "bg-chart-7", "bg-chart-8",
];
export default function AccentPresets() {
return (
<div className="flex w-full flex-col gap-2">
{accents.map((accent) => (
<div
key={accent}
data-accent={accent}
className="flex flex-wrap items-center gap-x-5 gap-y-3 rounded-medium bg-surface-3 px-4 py-3"
>
<code className="w-36 font-mono text-xs text-primary-text">data-accent="{accent}"</code>
<div className="flex items-center gap-2">
<Button size="sm" color="primary">
Deploy
</Button>
<Badge color="primary">Live</Badge>
</div>
<div className="ml-auto flex items-center gap-2.5">
<span className="font-mono text-xs text-muted">chart-1…8</span>
<span className="flex gap-1" aria-hidden="true">
{slots.map((slot) => (
<span key={slot} className={`size-3 rounded-full ${slot}`} />
))}
</span>
</div>
</div>
))}
</div>
);
}On orange and green the text on the fill is dark; on blue, violet and red it is white, so that every pair keeps its contrast. The site's accent switch sets data-accent on <html>, as an app would: every example on the page follows it except the rows above, which set their own.
<html lang="en" data-accent="blue">Your own colour#
To use a colour of your own, override the role's variables in your CSS, after the import. Give all eleven steps of the scale, the fill, the text on the fill, and the text colour for each theme:
:root {
--cennge-primary-50: #eef6ff; /* …through… */
--cennge-primary-950: #0b1f3a;
--cennge-primary: #1f6feb; /* solid fill */
--cennge-primary-foreground: #ffffff; /* text on the fill */
--cennge-primary-text: #1a5fd0; /* the colour as text, light theme */
}
.dark {
--cennge-primary-text: #6ea8ff; /* the colour as text, dark theme */
}Check the same pairs the built-in roles meet: 4.5:1 for the foreground on the fill, and for the text colour on every surface and on the tints. Any role can be recoloured this way, not only primary.
The chart palette does not follow such an override: its eight slots are set by the presets, so the first keeps the preset's colour, orange by default. Set --cennge-chart-1 in the same rule if your charts should open with your colour.
A preset of your own works like the built-in ones. Declare it under a data-accent value of your choosing; in the dark theme, the library already sets --cennge-primary-text to your 300 step on any element with data-accent:
[data-accent="teal"] {
--cennge-primary-50: #f0fdfa; /* …through… */
--cennge-primary-950: #042f2e;
--cennge-primary: #14b8a6;
--cennge-primary-foreground: #140c06;
--cennge-primary-text: #115e59;
}The chart palette does not know such a preset either: inside it, the slots keep the order of the page around it. Set --cennge-chart-1 to --cennge-chart-8 in the same rule to choose your own.
Chart palette#
Series in cennge-ui/charts take eight colours in order, --cennge-chart-1 to --cennge-chart-8, also as Tailwind's bg-chart-1 and the like for legends of your own. The first follows the accent preset. The order after it is chosen for each preset, so that neighbours stay far apart: under violet, orange comes second and blue fourth, because violet next to blue blurs for colour-blind readers. The dots at the end of each row above show it.
The eight are checked, not picked by eye: each keeps 3:1 on every surface, and neighbours stay distinguishable under simulated protanopia and deuteranopia, in both themes and for every preset. A ninth series is grey rather than a new colour. A dot on a line sits in a ring of the colour behind the chart; off a Card, set --cennge-chart-surface to that colour.
Radius, motion and type#
--cennge-radius-smallrounded-smallradius="sm"- 0.5rem.
--cennge-radius-mediumrounded-mediumradius="md"- 0.75rem: buttons and fields.
--cennge-radius-largerounded-largeradius="lg"- 1rem: cards.
--cennge-disabled-opacity- 0.5: how far disabled fields and buttons fade.
--cennge-font-sansfont-cennge- Inter, then the system font.
--cennge-ease-outease-tactile- The tactile ease-out:
cubic-bezier(0.23, 1, 0.32, 1). --cennge-duration-fast--cennge-duration- 120ms and 180ms.
--cennge-edge-light--cennge-edge-darkinset-shadow-tactile- The raised edge of solid buttons: a light line on top, a darker lip below.
--cennge-shadow-colorshadow-raisedshadow-floating- Cards at rest; what floats, such as menus, popovers and dialogs.
font-cennge is opt-in, so the library never replaces your own font-sans. The radius utilities have their own names, rounded-small to rounded-large, so they do not collide with Tailwind's rounded-sm to rounded-lg.
A variable set on an element reaches everything inside it. The second form below sets the three radii with Tailwind's arbitrary properties, and its field, its button and its own corners follow:
As shipped
Tighter corners
import { Button, Input } from "cennge-ui";
// The three radii, set on one element for everything inside it.
const tighter = [
"[--cennge-radius-small:0.125rem]",
"[--cennge-radius-medium:0.25rem]",
"[--cennge-radius-large:0.375rem]",
].join(" ");
function InviteForm({ title, className = "" }: { title: string; className?: string }) {
return (
<div className={`flex w-full max-w-72 flex-col gap-3 rounded-large border border-border p-4 ${className}`}>
<p className="text-sm font-semibold">{title}</p>
<Input label="Invite by email" type="email" defaultValue="dev@example.com" />
<Button color="primary">Send invite</Button>
</div>
);
}
export default function Radius() {
return (
<div className="flex w-full flex-wrap gap-4">
<InviteForm title="As shipped" />
<InviteForm title="Tighter corners" className={tighter} />
</div>
);
}Beyond tokens#
classNameon any component is merged with tailwind-merge, so your class wins over the variant's:<Button className="rounded-full">.- Components with inner parts, such as Input, Select, Dialog and Table, take classes for them through
classNames, one key per slot:<Input classNames={{ inputWrapper: "…" }} />. CenngeProvidersets the defaultsize,radiusandcolorfor every component below it. See Provider.- Light and dark are the
darkclass on<html>. See Dark mode.