Elements gallery
@civitai/components ships the Civitai design system as <civitai-*> custom elements. They style themselves in shadow DOM, inject the @civitai/theme tokens on first use, and carry their own keyboard and ARIA behaviour, so they work the same in plain HTML, React, Vue, Svelte or Blazor.
Every demo below is live: the preview is rendered from the code shown under it, and it follows the site's light/dark switch.
Elements or markup?
This page covers the elements. The Component showcase covers the other way to consume the package: hand-written data-civitai-ui markup over a stylesheet. Pick the elements unless you need zero JavaScript.
Setup
No build step — one script tag. site-elements.js registers every element except the two that act as the viewer (<civitai-sign-in-button>, <civitai-workflow-button>), which need their own define import — see Acting as the viewer:
<script type="module" src="https://cdn.jsdelivr.net/npm/@civitai/components/site-elements.js"></script>
<civitai-button variant="filled">Generate</civitai-button>site-elements.js is the generic kit plus the Civitai vocabulary (rating badges, tags, reactions, media cards). elements.js is the generic kit alone. Building a Civitai App? Using the elements in a block has the table of which import defines which elements, and the token limits that apply inside a block.
With a bundler:
npm install @civitai/componentsimport '@civitai/components/register-site'; // all but the two viewer elements, or:
import '@civitai/components/civitai-button/define'; // one element at a timeReact — typed bindings with props and events from the element classes:
import { CivitaiButton, CivitaiCard, CivitaiStack, CivitaiTextInput } from '@civitai/components-react';
export function PromptCard({ onGenerate }: { onGenerate: () => void }) {
return (
<CivitaiCard withBorder padding="md">
<CivitaiStack gap="md">
<CivitaiTextInput label="Prompt" description="What to generate" />
<CivitaiButton variant="filled" onClick={onGenerate}>
Generate
</CivitaiButton>
</CivitaiStack>
</CivitaiCard>
);
}How the elements behave
| Attributes and properties | Every attribute mirrors a property, kebab-cased: full-width ⇄ fullWidth. |
| Lists of options | select, segmented-control, radio-group, tabs and breadcrumb take their items as a data property, since an attribute cannot carry an array. |
| Events | change is re-dispatched from the host. Richer events (vote, react, select, open) carry a detail. |
| Forms | Field elements are form-associated: name, FormData, required, form.reset(), type="submit" and Enter-to-submit all work. Setting error also makes the field invalid. |
| Theme | Set data-theme="light" or "dark" on any ancestor. With neither, the OS preference wins. |
| Styling | Override any --civitai-* token, or reach inside with ::part(...). |
The full contract — every tag, attribute, property, event, slot and part — is the package's generated custom-elements.json.
Buttons
variant: filled · light · outline · subtle. size: sm · md · lg. <civitai-button-group> joins its buttons into one control.
<civitai-button><civitai-button-group>light<civitai-group>
<civitai-button variant="filled">Filled</civitai-button>
<civitai-button variant="light">Light</civitai-button>
<civitai-button variant="outline">Outline</civitai-button>
<civitai-button variant="subtle">Subtle</civitai-button>
</civitai-group>
<civitai-group>
<civitai-button size="sm">Small</civitai-button>
<civitai-button loading>Loading</civitai-button>
<civitai-button disabled>Disabled</civitai-button>
<civitai-button>Next<span slot="right">→</span></civitai-button>
</civitai-group>
<civitai-button-group aria-label="Alignment">
<civitai-button variant="outline">Left</civitai-button>
<civitai-button variant="outline">Center</civitai-button>
<civitai-button variant="outline">Right</civitai-button>
</civitai-button-group>Text and layout
<civitai-text> separates the outline from the look: as picks the heading level, size (xs … 5xl) and weight pick the design. <civitai-stack> lays out vertically and <civitai-group> horizontally, both with gap (sm · md · lg). <civitai-card> takes padding and with-border.
<civitai-text><civitai-stack><civitai-group><civitai-card>light<civitai-card with-border padding="md">
<civitai-stack gap="sm">
<civitai-text as="h3" size="2xl" weight="bold">Generate an image</civitai-text>
<civitai-text>Body copy at the default size.</civitai-text>
<civitai-group gap="sm">
<civitai-text size="sm" weight="medium">sm medium</civitai-text>
<civitai-text size="xs">xs</civitai-text>
</civitai-group>
</civitai-stack>
</civitai-card>Status and feedback
<civitai-badge><civitai-loader><civitai-alert><civitai-progress>light<civitai-group>
<civitai-badge>default</civitai-badge>
<civitai-badge variant="light" color="success">ready</civitai-badge>
<civitai-badge variant="outline" color="warning">queued</civitai-badge>
<civitai-badge color="error">failed</civitai-badge>
<civitai-loader size="sm" label="Working"></civitai-loader>
</civitai-group>
<civitai-alert heading="Heads up">The default info intent.</civitai-alert>
<civitai-alert color="error" heading="Generation failed" closable>Not enough Buzz.</civitai-alert>
<civitai-progress label="Generating" value="64" show-value></civitai-progress>
<civitai-progress label="Preparing" indeterminate color="warning"></civitai-progress>Text fields
label, description, placeholder, required, disabled, readonly and error work on every field. <civitai-input-group> joins fields, buttons and data-affix text into one row.
<civitai-text-input><civitai-number-input><civitai-textarea><civitai-input-group>light<civitai-text-input label="Prompt" description="What to draw" placeholder="a cat"></civitai-text-input>
<civitai-group style="align-items: flex-start">
<civitai-number-input label="Seed" value="12345" min="0"></civitai-number-input>
<civitai-number-input label="Steps" value="999" max="150" error="Max is 150"></civitai-number-input>
</civitai-group>
<civitai-textarea label="Negative prompt" rows="3"></civitai-textarea>
<civitai-input-group>
<civitai-number-input aria-label="Tip" value="100" min="1"></civitai-number-input>
<span data-affix>Buzz</span>
<civitai-button>Tip</civitai-button>
</civitai-input-group>Choices
select, segmented-control and radio-group take their options through the data property. Each option is { value, label, disabled? }.
<civitai-select><civitai-segmented-control><civitai-radio-group><civitai-checkbox><civitai-switch><civitai-slider>light<civitai-select label="Model" placeholder="Pick a model"></civitai-select>
<civitai-segmented-control aria-label="View"></civitai-segmented-control>
<civitai-radio-group label="Speed" orientation="horizontal"></civitai-radio-group>
<civitai-group>
<civitai-checkbox label="Upscale" checked></civitai-checkbox>
<civitai-switch label="Public"></civitai-switch>
</civitai-group>
<civitai-slider label="CFG scale" min="1" max="20" step="0.5" value="7" show-value></civitai-slider>document.querySelector('civitai-select').data = [
{ value: 'flux', label: 'Flux.1 [dev]' },
{ value: 'sdxl', label: 'SDXL' },
{ value: 'sd15', label: 'SD 1.5 (retired)', disabled: true },
];
document.querySelector('civitai-segmented-control').data = [
{ value: 'grid', label: 'Grid' },
{ value: 'list', label: 'List' },
{ value: 'feed', label: 'Feed' },
];
document.querySelector('civitai-radio-group').data = [
{ value: 'fast', label: 'Fast' },
{ value: 'quality', label: 'Quality' },
];Disclosure
<civitai-tabs><civitai-collapse><civitai-tooltip>light<civitai-tabs aria-label="Result view">
<civitai-tab-panel value="images"><civitai-card padding="md">Images panel</civitai-card></civitai-tab-panel>
<civitai-tab-panel value="videos"><civitai-card padding="md">Videos panel</civitai-card></civitai-tab-panel>
</civitai-tabs>
<civitai-collapse heading="Advanced">
<civitai-checkbox label="Restore faces"></civitai-checkbox>
</civitai-collapse>
<civitai-group>
<civitai-tooltip label="Spends Buzz from your balance">
<civitai-button variant="outline">Hover or focus me</civitai-button>
</civitai-tooltip>
</civitai-group>document.querySelector('civitai-tabs').data = [
{ value: 'images', label: 'Images' },
{ value: 'videos', label: 'Videos' },
];Overlays
<civitai-modal> traps focus and closes on Escape or an overlay click. <civitai-confirm-dialog> resolves await dialog.ask() to true or false. <civitai-toast-region> shows toasts from show({ message, heading?, color?, duration? }).
<civitai-modal><civitai-confirm-dialog><civitai-toast-region><civitai-toast>light<civitai-group>
<civitai-button class="open-modal">Open modal</civitai-button>
<civitai-button class="ask" variant="outline">Delete…</civitai-button>
<civitai-button class="toast" variant="light">Toast</civitai-button>
</civitai-group>
<civitai-modal heading="Confirm generation">
This will spend Buzz. Focus cannot leave the dialog.
</civitai-modal>
<civitai-confirm-dialog heading="Delete model?" message="This cannot be undone."
confirm-label="Delete" destructive></civitai-confirm-dialog>
<civitai-toast-region></civitai-toast-region>const modal = document.querySelector('civitai-modal');
const confirm = document.querySelector('civitai-confirm-dialog');
const toasts = document.querySelector('civitai-toast-region');
document.querySelector('.open-modal').addEventListener('click', () => (modal.open = true));
document.querySelector('.ask').addEventListener('click', async () => {
const deleted = await confirm.ask();
toasts.show({ message: deleted ? 'Deleted.' : 'Kept.', color: deleted ? 'error' : 'info' });
});
document.querySelector('.toast').addEventListener('click', () => {
toasts.show({ heading: 'Saved', message: 'Your changes are live.', color: 'success' });
});Menu
Put any button in the trigger slot. Choosing an item fires select with detail.value.
<civitai-menu><civitai-menu-item><civitai-menu-label>light<civitai-group>
<civitai-menu label="Image actions">
<civitai-button slot="trigger" variant="outline" size="sm">Actions</civitai-button>
<civitai-menu-item>Save to collection</civitai-menu-item>
<civitai-menu-item>View post</civitai-menu-item>
<civitai-menu-label>Moderator</civitai-menu-label>
<civitai-menu-item disabled>Rescan</civitai-menu-item>
<civitai-menu-item destructive>Delete</civitai-menu-item>
</civitai-menu>
</civitai-group>Navigation
<civitai-nav-list current="…"> marks the item whose href matches and opens every group above it. An item with children is a collapsible group. <civitai-pagination> fires change, then read page.
<civitai-breadcrumb><civitai-pagination><civitai-nav-list><civitai-nav-item>light<civitai-breadcrumb></civitai-breadcrumb>
<civitai-pagination total="20" page="7"></civitai-pagination>
<civitai-nav-list label="Sections" current="/jobs/replay" style="max-width: 240px">
<civitai-nav-item href="/" label="Summary"></civitai-nav-item>
<civitai-nav-item label="Jobs">
<civitai-nav-item href="/jobs" label="Active"></civitai-nav-item>
<civitai-nav-item href="/jobs/replay" label="Replay"></civitai-nav-item>
</civitai-nav-item>
<civitai-nav-item href="/workers" label="Workers"></civitai-nav-item>
</civitai-nav-list>document.querySelector('civitai-breadcrumb').data = [
{ label: 'Home', href: '/' },
{ label: 'Models', href: '/models' },
{ label: 'Flux.1 [dev]' },
];Table
<civitai-table> styles a <table> you write yourself, including one a data grid generates. Flags: striped, hoverable, with-border, dense, sticky-header. Mark number cells with data-numeric.
<civitai-table>light<civitai-table striped hoverable with-border>
<table>
<thead>
<tr><th>Job</th><th>Model</th><th>Status</th><th data-numeric>Cost</th></tr>
</thead>
<tbody>
<tr><td>wf_8f21</td><td>Flux.1 [dev]</td><td><civitai-badge variant="light" color="success">succeeded</civitai-badge></td><td data-numeric>12</td></tr>
<tr><td>wf_8f22</td><td>SDXL</td><td><civitai-badge variant="light" color="warning">queued</civitai-badge></td><td data-numeric>4</td></tr>
<tr><td>wf_8f23</td><td>SDXL</td><td><civitai-badge variant="light" color="error">failed</civitai-badge></td><td data-numeric>0</td></tr>
</tbody>
</table>
</civitai-table>Media
<civitai-image>, <civitai-video> and <civitai-audio> share the states a generated file goes through: pending while it is being made, blocked when it is withheld from this viewer, and fallback text when it fails to load. Size them from outside.
<civitai-image><civitai-video><civitai-audio>light<civitai-group gap="lg" style="align-items: flex-start">
<civitai-image openable alt="A gradient" style="width: 140px; aspect-ratio: 1"
src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3Cdefs%3E%3ClinearGradient id='g' x2='1' y2='1'%3E%3Cstop stop-color='%23228be6'/%3E%3Cstop offset='1' stop-color='%23326D5C'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='1' height='1' fill='url(%23g)'/%3E%3C/svg%3E"></civitai-image>
<civitai-image pending style="width: 140px; aspect-ratio: 1"></civitai-image>
<civitai-image blocked style="width: 140px; aspect-ratio: 1"><span slot="blocked">Hidden: mature content</span></civitai-image>
<civitai-video src="/does-not-exist.mp4" fallback="This clip is gone" style="width: 140px; aspect-ratio: 1"></civitai-video>
</civitai-group>
<civitai-audio src="/does-not-exist.mp3" alt="A jingle" fallback="This track is gone" style="max-width: 320px"></civitai-audio>Civitai vocabulary
In site-elements.js / register-site only: the pieces that make an app look like civitai.com. <civitai-tag> fires vote, <civitai-reaction> fires react, and <civitai-media-card> lays them out over an image in named slots (media, top-start, top-end, bottom).
<civitai-rating-badge><civitai-avatar><civitai-tag><civitai-reaction><civitai-action-button><civitai-media-card>light<civitai-group>
<civitai-rating-badge rating="pg"></civitai-rating-badge>
<civitai-rating-badge rating="pg13"></civitai-rating-badge>
<civitai-rating-badge rating="r"></civitai-rating-badge>
<civitai-rating-badge rating="x"></civitai-rating-badge>
<civitai-avatar name="Jane Q Doe"></civitai-avatar>
<civitai-avatar size="lg" name="Sinity" frame="linear-gradient(135deg, #f0a, #0af)"></civitai-avatar>
</civitai-group>
<civitai-group>
<civitai-tag name="wolf" confidence="0.97"></civitai-tag>
<civitai-tag name="full moon" vote="1" score="128" show-score></civitai-tag>
<civitai-tag name="anime" readonly></civitai-tag>
</civitai-group>
<civitai-media-card href="#" label="Open image" style="width: 220px">
<img slot="media" alt="" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='280'%3E%3Cdefs%3E%3ClinearGradient id='g' x2='1' y2='1'%3E%3Cstop stop-color='%23b98b4a'/%3E%3Cstop offset='1' stop-color='%231b1f2a'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='200' height='280' fill='url(%23g)'/%3E%3C/svg%3E" />
<civitai-rating-badge slot="top-start" rating="pg"></civitai-rating-badge>
<civitai-action-button slot="top-end" label="Remix">
<span slot="icon" aria-hidden="true">✦</span>
</civitai-action-button>
<civitai-reaction slot="bottom" emoji="👍" label="Like" count="13100"></civitai-reaction>
<civitai-reaction slot="bottom" emoji="❤️" label="Heart" count="812" reacted></civitai-reaction>
</civitai-media-card>Acting as the viewer
Two elements do something rather than show something, through @civitai/sdk. They are not in site-elements.js or register-site, so the SDK stays out of apps that do not need it.
<civitai-sign-in-button>signs the viewer in. Inside civitai.com it asks the host; an app of its own hands itcreateSignIn()'s result assignIn. It hides once the viewer is signed in.<civitai-workflow-button>prices itstemplateas soon as it has one (Bake for 185 Buzz), submits it on click, and shows progress until it finishes. A second press offers to cancel. It firespriced,submitted,progress,finished,canceledanderror. Pass it a signed-inappfrom@civitai/sdk; left out, it callsinitialize()itself. It runs the workflow throughapp.orchestration, so inside a block, on the default token, the orchestrator refuses it — see Using the elements in a block.
The demo below gives both a stand-in for the SDK, so pressing them spends nothing and signs no one in.
<civitai-workflow-button><civitai-sign-in-button>light<civitai-group>
<civitai-workflow-button label="Bake"></civitai-workflow-button>
<civitai-sign-in-button variant="subtle">Sign in with Civitai</civitai-sign-in-button>
</civitai-group>
<civitai-text size="sm" class="log">Press Bake, then press it again to cancel.</civitai-text>const log = document.querySelector('.log');
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Stand-ins for initialize() and createSignIn() from @civitai/sdk.
let canceled = false;
const app = {
requestGrants: async () => true,
getToken: async () => 'demo',
orchestration: {
estimateWorkflow: async () => ({ cost: { total: 185 } }),
submitWorkflow: async () => ((canceled = false), { id: 'wf_demo' }),
cancelWorkflow: async () => void (canceled = true),
async *watchWorkflow() {
for (const status of ['unassigned', 'scheduled']) {
await wait(800);
yield { status, steps: [] };
}
for (let rate = 0.05; rate <= 1; rate += 0.05) {
await wait(300);
if (canceled) return yield { status: 'canceled', steps: [] };
yield { status: 'processing', steps: [{ status: 'processing', estimatedProgressRate: rate }] };
}
yield { status: 'succeeded', steps: [] };
},
},
};
const signIn = { signedIn: false, signIn: async () => (log.textContent = 'sign-in: would leave for Civitai') };
const button = document.querySelector('civitai-workflow-button');
button.app = app;
button.template = { steps: [{ $type: 'textToImage', input: { prompt: 'a red bike' } }] };
for (const type of ['priced', 'submitted', 'finished', 'canceled', 'error']) {
button.addEventListener(type, () => (log.textContent = `workflow-button: ${type}`));
}
document.querySelector('civitai-sign-in-button').signIn = signIn;In an app of your own, import both by path and pass the SDK's own objects — here a client from createSignIn(), since initialize() with no arguments waits for a civitai.com host:
import type { CivitaiWorkflowButton } from '@civitai/components/civitai-workflow-button';
import '@civitai/components/civitai-workflow-button/define';
import { createSignIn, initialize } from '@civitai/sdk';
const auth = await createSignIn({ clientId, scopes: ['ai:write:budgeted'] });
const button = document.querySelector<CivitaiWorkflowButton>('civitai-workflow-button')!;
button.app = await initialize(auth);
button.template = {
steps: [{
$type: 'textToImage',
input: { model: 'urn:air:sdxl:checkpoint:civitai:101055@128078', prompt: 'a red bike', cfgScale: 7, seed: 42 },
}],
};Contributing
The source lives in civitai-app-starters/packages/civitai-components. To change an element, its playground runs it from source with hot reload:
pnpm --filter @civitai/components dev