Skip to content

Theming & the design system ​

Civitai's UI components come two ways: as framework-agnostic HTML styled by data-* attributes (author the markup yourself), and as self-styling <civitai-*> custom elements (behaviour and ARIA wiring supplied). Either works in plain HTML, Svelte, Vue, Solid or vanilla JS; in React, @civitai/components-react gives you the elements as typed bindings.

The two sets differ — pick by what you need. The elements include modal, menu, switch and table, which have no data-civitai-ui contract; radio is a sheet component with no <civitai-radio> element (the element route uses one <civitai-radio-group> taking its options as a data property). The sheet has no tabs, but its segmented-control has a role="tablist" mode. Derive each set rather than trusting this list: the sheet's is the Components reference, the elements' is the tagNames in the package's custom-elements.json.

Not the same as @civitai/blocks-react

This design system is the presentational layer. It is independent of, and composable with, the block runtime SDK (@civitai/blocks-react, its hooks and /ui pack), which handles the host bridge, viewer, token and slots.

The three layers ​

The system is three packages, each a layer you can adopt independently:

LayerPackageWhat it isYou use it when…
1. Tokens@civitai/theme--civitai-* design tokens generated from Civitai's Mantine v7 theme — as a CSS-variables stylesheet, JS token objects, and a DTCG JSON file.you want Civitai's colors/spacing/typography as raw values.
2. Components (CSS + elements)@civitai/componentsTwo independent ways to consume it: attribute-driven, framework-agnostic CSS styled via data-civitai-ui="<name>" + data-variant/data-size (wrapped in @layer civitai.components), and the self-styling <civitai-*> custom elements, which carry the behaviour and ARIA wiring in shadow DOM.you want themed components in any framework (or none) — the CSS if you author the markup, the elements if you want the behaviour too.
3. React bindings@civitai/components-react@lit/react wrappers around the <civitai-*> custom elements, with props and events typed from the element classes.your block is React and you want typed props.

@civitai/components builds on @civitai/theme's tokens. @civitai/components-react binds the <civitai-*> elements, which style themselves in shadow DOM — since 0.9.0 it does NOT render layer 2's markup. To use the elements without React, import '@civitai/components/register' if you bundle, or load the self-registering elements.js from the package root via a CDN <script type="module"> if you do not (a bare specifier does not resolve in a browser). Neither defines every element — see the elements guide.

Pin each package's version separately in CDN URLs

The packages version independently — this page uses @civitai/theme@0.5.2, @civitai/components@0.10.1 and @civitai/components-react@0.9.2 — so copy each URL as written. A version a package never published 404s, which shows only as an unstyled page. An older pin returns a healthy 200 with old CSS: a stale @civitai/components leaves newer components bare (tooltip, toast, image, slider) or partially styled (select, checkbox, radio, segmented-control); a stale @civitai/theme drops newer tokens, so backgrounds and accents vanish. If markup that follows the current Components reference looks off, check your pins first.

@civitai/theme and @civitai/components each ship a package-root styles.css, served by jsDelivr and unpkg at <host>/@civitai/<pkg>@<version>/styles.css. @civitai/components-react ships no stylesheet (that URL 404s): each element carries its own CSS and injects the @civitai/theme tokens on first mount. The Plain HTML quickstart below is a complete, copy-paste page.

Themed components in generic HTML ​

You don't need React. Load two stylesheets — the tokens and the component CSS — then write HTML with the data-civitai-ui attributes.

This gives you the look only — no behaviour, no encapsulation. A button's loading state (aria-busy + disabled + the loader span) and a text-input's label/description/error ARIA relationships are markup you author to the contract. The <civitai-*> elements wire these for you.

html
<!-- 1. Load the design tokens + the component CSS (order-independent).
     Pin each package at its own version — they do not share one.
     Swap unpkg.com for cdn.jsdelivr.net/npm if you prefer jsDelivr. -->
<link rel="stylesheet" href="https://unpkg.com/@civitai/theme@0.5.2/styles.css" />
<link rel="stylesheet" href="https://unpkg.com/@civitai/components@0.10.1/styles.css" />

<!-- 2. Write markup with the data-attributes — styled identically to React. -->
<button data-civitai-ui="button" data-variant="filled" data-size="md">Generate</button>

<div data-civitai-ui="text-input">
  <label data-civitai-ui-label for="prompt">Prompt</label>
  <input data-civitai-ui-control id="prompt" placeholder="a cat astronaut" />
</div>

If you bundle your own JS, you can inject both stylesheets from the package instead of the CDN <link>s — injectStyles() adds the tokens and the component CSS once, idempotently:

html
<script type="module">
  import { injectStyles } from '@civitai/components';
  injectStyles();
</script>

FOUC — CDN CSS loads after first paint

A CDN <link> (or an async injectStyles()) resolves after first paint, so a preloaded or server-rendered page can flash unthemed content. If you SSR or preload, self-host the two stylesheets, inline the critical CSS into <head>, or call injectStyles() synchronously before your block renders. For a client-rendered block mounted after load, the CDN <link> is fine.

Every component's exact markup — required elements, data-* attributes, and ARIA/role wiring — is in the Components reference, generated from MARKUP.md, the source of truth for hand-written markup. It is not what the React bindings render: since @civitai/components-react@0.9.0 those bind the custom elements, which style themselves in shadow DOM.

Plain HTML quickstart ​

Copy this into an index.html and open it: a themed page with a working light/dark toggle — no build step, no framework, no install.

Paint the page background yourself

The tokens don't style <body>. Without the body { background/color } rule below, the page around the components is unthemed (e.g. white in dark mode).

html
<!doctype html>
<html lang="en" data-theme="light">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>Civitai design system — plain HTML</title>

    <!-- Pinned CDN URLs, each package at its own version (unpkg or jsDelivr). -->
    <link
      rel="stylesheet"
      href="https://unpkg.com/@civitai/theme@0.5.2/styles.css"
    />
    <link
      rel="stylesheet"
      href="https://unpkg.com/@civitai/components@0.10.1/styles.css"
    />

    <style>
      /* The tokens don't paint the page — do it from the body/text tokens. */
      body {
        background: var(--civitai-color-body);
        color: var(--civitai-color-text);
        font-family: system-ui, sans-serif;
        margin: 0;
        padding: 2rem;
      }
    </style>
  </head>
  <body>
    <div data-civitai-ui="stack" data-gap="md">
      <div data-civitai-ui="group" data-gap="sm">
        <button data-civitai-ui="button" data-variant="filled">Generate</button>
        <button data-civitai-ui="button" data-variant="outline">Cancel</button>
        <span data-civitai-ui="badge" data-variant="light">beta</span>
        <!-- Toggle button — flips data-theme on <html> (see the script below). -->
        <button id="theme-toggle" data-civitai-ui="button" data-variant="subtle">
          Toggle theme
        </button>
      </div>

      <!-- data-with-border makes the card visible in LIGHT mode (see note below). -->
      <div data-civitai-ui="card" data-with-border="true" data-padding="md">
        <div data-civitai-ui="stack" data-gap="sm">
          <div data-civitai-ui="text-input">
            <label data-civitai-ui-label for="prompt">Prompt</label>
            <input data-civitai-ui-control id="prompt" placeholder="a cat astronaut" />
          </div>
          <div data-civitai-ui="alert" data-color="info" role="alert">
            <div data-civitai-ui-alert-body>
              <div data-civitai-ui-alert-title>Heads up</div>
              Edit the prompt, then hit Generate.
            </div>
          </div>
        </div>
      </div>
    </div>

    <script>
      // 3-line theme toggle: read the current theme off <html>, flip it, write it back.
      const root = document.documentElement;
      document.getElementById('theme-toggle').addEventListener('click', () => {
        root.dataset.theme = root.dataset.theme === 'dark' ? 'light' : 'dark';
      });
    </script>
  </body>
</html>

Light-mode cards need a border

In the light theme --civitai-color-body, --civitai-color-surface, and--civitai-color-surface-2 are all #fefefe, so a borderless card is invisible on a token-painted page. Add data-with-border="true" (as above), or your own border or box-shadow. This mirrors Civitai's own Mantine light palette, so the tokens will not be re-tinted.

Themed components in React ​

@civitai/components-react gives a React block the elements as typed bindings, with no <link>s to add. Handlers receive the DOM event (onChange={(e) => e.target.value}), and server rendering is best-effort — where server output matters, write the <civitai-*> tag directly in JSX, since attributes survive SSR and properties do not.

tsx
import { CivitaiButton, CivitaiStack, CivitaiTextInput } from '@civitai/components-react';

export function GenerateForm() {
  return (
    <CivitaiStack gap="md">
      <CivitaiTextInput label="Prompt" placeholder="a cat astronaut" />
      <CivitaiButton variant="filled" size="md" loading={false}>
        Generate
      </CivitaiButton>
    </CivitaiStack>
  );
}

A binding prop is assigned as a PROPERTY on every render (@lit/react skips dirty checking), so a hard-coded value="…" or checked discards what the viewer typed the next time the enclosing component renders — which is why the example sets no starting value. Starting values and form reset differ between React 18 and 19; read the element's value docstring (@civitai/components/dist/elements/field-base.d.ts) and test against the React major you ship.

The props come from the element classes: CivitaiButton takes variant / size / loading / fullWidth / color / href; the field inputs take label / description / error / required and wire up htmlFor / aria-describedby / aria-invalid for you. react and react-dom^18 || ^19 are peer dependencies. See the Components reference for every component's props.

Light and dark themes ​

Set data-theme="light" or data-theme="dark" on any ancestor — typically <html> or your block root — and every token (and so every component) re-resolves from that scope. With no attribute the dark palette applies and the OS preference is ignored: since @civitai/theme@0.5.0 the dark values live directly on :root and the stylesheet declares no prefers-color-scheme block in either direction.

Only light and dark select a token block — any other value inherits the dark base. BLOCK_INIT's own theme is required, so payload.theme is safe to write directly; payload.context.theme (model-slot/page context) and the direct-load init fragment's theme? are optional, and an unguarded root.dataset.theme = ctx.theme writes "undefined" — silently dark.

If you stamp data-theme="light" (common before the default changed), keep it. A dark stamp is now redundant on its own — but inheritance takes the NEAREST [data-theme] ancestor, and a component reading document.documentElement (as BlockGate does) may rely on it.

html
<html data-theme="dark">
  <!-- every data-civitai-ui component below renders in the dark palette -->
</html>

In an embedded block, the host hands you the viewer's active theme over the BLOCK_INIT handshake — reflect it onto your root's data-theme so your block matches the surrounding Civitai UI (see Concepts → the bridge).

Overriding styles — the @layer model ​

Every rule @civitai/components ships lives in @layer civitai.components. Because unlayered CSS always beats layered CSS regardless of specificity, your own plain CSS overrides the component styles with no !important and no specificity war:

css
/* Your unlayered rule wins over @layer civitai.components — no !important needed. */
[data-civitai-ui='button'][data-variant='filled'] {
  border-radius: 999px;
}

This rule is double-edged

An existing app's unlayered global rules (button {}, input {}, a reset, utility classes) also win, so components render unthemed with no error. Adding the design system to an existing app? Read Theming an existing app first.

To retheme rather than restyle, redeclare a token locally — the components read --civitai-* custom properties, so overriding one cascades to every component in that scope:

html
<!-- Recolor just this subtree by overriding the primary-color token. -->
<div style="--civitai-color-primary: #a259ff">
  <button data-civitai-ui="button" data-variant="filled">Custom purple</button>
</div>

Use a token override to recolor or respace consistently, and an unlayered rule for a structural change to one component.

Scope a token override — never declare one at :root. The token sheet itself is unlayered, so an app's :root { --civitai-color-primary: … }ties with it, and the later stylesheet wins — with useBlocksStyles() injecting from a useEffect, that is usually the tokens, and your rebrand silently does nothing. Scope it as above (an ancestor's inline style, or a class on your block root). Details: Components reference.

Theming an existing app (retrofit / incremental adoption) ​

Adding the design system to an existing app with its own global CSS breaks silently. This section covers the collision, the fix, CSS resets and frameworks (Tailwind), a gentler token-only path, and self-hosting the CSS.

The @layer collision (why your buttons look unthemed) ​

Any unlayered rule beats any layered rule, regardless of specificity, so the global rules a real app already has —

css
/* Typical existing-app CSS: a reset, element rules, utility classes — all UNLAYERED. */
button { background: #635bff; color: #fff; border-radius: 6px; }
input  { border: 1px solid #ccc; padding: 8px; }

— win over civitai's [data-civitai-ui='button'] / [data-civitai-ui='text-input'] styles, even though the civitai selectors are more specific. Nothing errors.

The tell is a partial theme: badges, cards, alerts and loaders are themed, but buttons and inputs keep your old styles.

The fix — put your legacy CSS in a lower layer (mind the parse order) ​

Move your existing CSS into a layer that sorts below civitai:

  1. Declare the layer order @layer app, civitai; (later layers win).
  2. Wrap your existing CSS in @layer app { … }.

Layer order is set at the first encounter of each name, so the @layer app, civitai; statement must appear before the civitai <link> tags — otherwise civitai registers first, app sorts after it, and your old styles win again.

html
<head>
  <!-- 1. Declare layer ORDER first — app sorts BELOW civitai.
          MUST come before the civitai <link>s (first-encounter ordering). -->
  <style>@layer app, civitai;</style>

  <!-- 2. Now load the civitai CSS. Its @layer civitai.components slots ABOVE app. -->
  <link rel="stylesheet" href="https://unpkg.com/@civitai/theme@0.5.2/styles.css" />
  <link rel="stylesheet" href="https://unpkg.com/@civitai/components@0.10.1/styles.css" />

  <!-- 3. Wrap your existing/global CSS in the lower `app` layer. -->
  <style>
    @layer app {
      button { background: #635bff; color: #fff; border-radius: 6px; }
      input  { border: 1px solid #ccc; padding: 8px; }
      /* …your reset, element rules, utility classes… */
    }
  </style>
</head>
<body>
  <!-- Now this renders in the civitai blue, not your #635bff. -->
  <button data-civitai-ui="button" data-variant="filled">Generate</button>
</body>

Any layer name works (legacy, base, your app's name), as long as it comes before civitai in a leading @layer …; list.

Coexisting with an existing CSS reset / framework (Tailwind, etc.) ​

A CSS reset or framework normalize doesn't just recolor a component — it strips it to nothing. Tailwind's Preflight ships this, unlayered:

css
/* Tailwind Preflight (excerpt) — UNLAYERED. Zeroes out every button. */
button {
  background-color: transparent;
  background-image: none;
  padding: 0;
  border: 0;
}

A correct data-civitai-ui="button" data-variant="filled" then renders as bare text — no fill, padding or border, and no error. Apply the layer recipe to your entire stylesheet, the framework reset included. Tailwind v4 makes that a one-liner, because each @import accepts a layer(…):

css
/* app.css — Tailwind v4. Preflight + utilities all land in @layer app. */
@layer app, civitai.components;   /* app sorts BELOW civitai.components */

@import "tailwindcss" layer(app); /* Preflight, utilities, everything → @layer app */

If you can't add layer(app) to the import (older Tailwind, or a pre-compiled tailwind.css you don't control), wrap the compiled output in @layer app { … } by hand instead — same effect, same ordering rule.

The first-encounter trap applies here too

The leading @layer app, civitai.components; statement must be the first place either layer name is seen — before the civitai <link>s and before Tailwind's @import. If Preflight registers a bare app layer first, the order flips and you are back to bare text.

The gentle option — consume tokens, keep your own markup ​

The lowest-friction path is to keep your own markup and consume only the --civitai-* design tokens in your CSS — colors, radius, spacing, fonts. With no layered component rules there is no @layer fight, and you need only the tokens stylesheet (@civitai/theme).

html
<!-- Just the tokens — no component CSS, no @layer collision. -->
<link rel="stylesheet" href="https://unpkg.com/@civitai/theme@0.5.2/styles.css" />

Then restyle your own component by swapping hard-coded values for tokens:

css
/* BEFORE — your custom button, hard-coded brand values. */
.my-btn {
  background: #635bff;
  color: #ffffff;
  border-radius: 6px;
  padding: 8px 16px;
}

/* AFTER — same element/class, now painted from civitai tokens.
   Re-themes with light/dark automatically; no data-civitai-ui, no layer. */
.my-btn {
  background: var(--civitai-color-primary);
  color: var(--civitai-color-primary-fg);
  border-radius: var(--civitai-radius);
  padding: 8px 16px;
}

It re-resolves in dark mode from the same data-theme scope. Adopt the data-civitai-ui components (with the @layer recipe) later, per component. Either way, light-mode surfaces are all #fefefe, so give a card or panel a border or shadow.

Self-hosting / offline — vendoring the CSS ​

For an offline or self-contained app — and as the FOUC fix — vendor the CSS. Copy exactly two files, one from each package:

Copy this file (from the published package)From packageIt is
styles.css (the package root)@civitai/themethe --civitai-* design tokens
styles.css (the package root)@civitai/componentsthe @layer civitai.components component CSS

Which styles.css? Watch out for the decoys

@civitai/theme ships styles.css (root) and dist/tokens.css; @civitai/components ships styles.css (root) and dist/components.css. A CDN serves the root file; the package's exports["./styles.css"] points at the dist/ one. They are byte-identical. Vendor the package-root styles.css from each, and don't cross the two packages' files.

html
<!-- Vendored, no CDN. Same two stylesheets, served from your own origin.
     Load theme BEFORE components (they read the tokens as custom properties,
     so they're technically order-independent — theme-first is the safe convention). -->
<link rel="stylesheet" href="/assets/civitai/theme.styles.css" />       <!-- @civitai/theme  → styles.css -->
<link rel="stylesheet" href="/assets/civitai/components.styles.css" />  <!-- @civitai/components → styles.css -->

Pin the packages at the version you vendored (@civitai/theme@0.5.2, @civitai/components@0.10.1) and re-copy both files when you bump. With a bundler, import '@civitai/theme/styles.css' and import '@civitai/components/styles.css' resolve through exports instead.

Where to go next ​

  • Components reference — every component, its data-civitai-ui name, enumerable attributes, and ARIA/role markup (generated from MARKUP.md, which owns the list).
  • Quickstart — scaffold and run a block.
  • Concepts — the block / host / slot / bridge model, including how the host passes the active theme to your block.

Civitai Developer Documentation