Using the <civitai-*> elements in a block
@civitai/components ships Civitai's UI as custom elements — <civitai-button>, <civitai-text-input>, <civitai-modal> and the rest. They need no UI framework, which is why the default civitai app init template uses them with @civitai/sdk and nothing else.
This page covers only what is specific to a block: which import defines which elements, theming from the host, the elements whose credentials a block cannot supply, and the reaction and tag elements that look wired but are not. Every element — live, with its attributes, events and copy-paste markup — is in the component showcase.
Register the elements
A <civitai-*> tag does nothing until its element is defined, and an undefined tag is not an error: the browser renders it as an unknown inline element — no styles, no behaviour, nothing in the console. When an element "renders as plain text", check this first.
@civitai/components@0.10.1 declares 47 elements in its custom-elements.json. No single import defines all of them:
| Import (bundler) | CDN bundle (no build step) | Defines |
|---|---|---|
import '@civitai/components/register' | elements.js | the generic kit: 40 elements |
import '@civitai/components/register-site' | site-elements.js | the generic kit plus the civitai vocabulary — <civitai-avatar>, <civitai-media-card>, <civitai-reaction>, <civitai-rating-badge>, <civitai-tag>: 45 |
import '@civitai/components/civitai-sign-in-button/define', …/civitai-workflow-button/define | none | the two elements that act as the viewer; no bundle includes them |
register-site already imports register, so one of the two is enough. Of the CDN files, load elements.js or site-elements.js, not both: each carries its own copy of Lit. The two viewer elements are left out of every bundle because they import @civitai/sdk (an optional peer of @civitai/components).
To ship less, replace the bundle with one …/<tag>/define import per element you use — and the undefined-tag trap above then applies to every one you forget. <civitai-tab-panel> has no define of its own; civitai-tabs/define registers it.
import '@civitai/components/civitai-button/define';
import '@civitai/components/civitai-text-input/define';In a bundler, import the entry points, not the bundle files
The package's sideEffects allow-list names register, register-site and the per-element define modules. It does not name elements.js / site-elements.js, so a bundler may drop a bare import '@civitai/components/elements.js' as unused — with no warning, and every tag left undefined. Use the bundle files from a <script> tag; use register from code.
Theme them from the host
The elements read the --civitai-* tokens, which inherit into their shadow roots. Load the tokens once and drive the theme with one attribute on <html>:
import '@civitai/theme/styles.css';
import { initialize } from '@civitai/sdk';
const app = await initialize();
const syncTheme = () => (document.documentElement.dataset.theme = app.theme);
syncTheme();
app.onChange(syncTheme);The tokens default to dark and switch to light under [data-theme='light']. Inside a block the theme is the host's: take it from app.theme, never from the viewer's OS preference, and keep it in sync from app.onChange. Light and dark themes has the full rules.
Elements a block's token cannot drive
<civitai-workflow-button> prices, submits and follows a workflow through app.orchestration — the orchestrator, called directly. On a block's default token the orchestrator refuses it, and that path carries none of the controls a block's spend goes through. In a block, spend through /api/v1/blocks/workflows/* with app.workflows, as shown in Money calls.
<civitai-chat>, from the separate @civitai/components-chat package, is a Civitai assistant in one element. It has the same limit, for the same reason: it calls the orchestrator and Civitai's MCP servers directly with the token app.getToken() returns, not through the /api/v1/blocks/* routes. Inside a block, on the default token, its generations are refused. It fits an app of your own that signs the viewer in with createSignIn(); read Transport models before putting it in a block. Its README covers setup.
<civitai-reaction> records nothing
<civitai-reaction> looks like the reaction button on civitai.com. It does not work like one. It is presentational only: it calls no route, sends no bridge message, and needs no scope.
- Properties:
emoji,count,reacted(the pressed state),label(the accessible name, e.g.Like) anddisabled. You set all of them. The element cannot read an entity's counts or whether this viewer has already reacted. - On click it toggles itself optimistically: it flips
reacted, movescountby one (never below 0), then dispatches areactCustomEventwithdetail: { emoji, reacted, count }. The event bubbles and iscomposed, so it crosses shadow roots. In React,@civitai/components-react's<CivitaiReaction>exposes it asonReact. - Saving the change is your job. If your write fails, set
reactedandcountback yourself. The element does not do it for you.
import '@civitai/components/civitai-reaction/define';
import type { ReactionDetail } from '@civitai/components/civitai-reaction';
// Your own write, e.g. to app storage. It is not a Civitai reaction.
declare function saveMark(emoji: string, marked: boolean): Promise<void>;
const button = document.querySelector('civitai-reaction');
if (button) {
button.addEventListener('react', (event) => {
const { emoji, reacted } = (event as CustomEvent<ReactionDetail>).detail;
saveMark(emoji, reacted).catch(() => {
// The element has already flipped itself. Put it back.
button.reacted = !reacted;
button.count = Math.max(0, button.count + (reacted ? -1 : 1));
});
});
}Real Civitai reactions go through useReactions
To set a real reaction, wire the element to the useReactions hook. It reads and sets the viewer's reactions on images over the bridge, and needs the consent-gated social:react:self scope (in the Scopes reference). A reaction made this way is a Civitai reaction: it pays, ranks and notifies exactly as the site's own button does. Its limits are in the hook reference: images only, no Dislike, and a per-image reactTicket that only the REST image routes supply.
The hook's example reads event.detail.reaction. ReactionDetail carries that field from @civitai/components 0.13.0 on; on an older version it is undefined, so the element flips itself and nothing is written. Use 0.13.0 or later.
Other entity types (posts, models, articles) have no reaction support. The other social actions an app can take are tips (the social:tip:self scope) and collection follows. A follow needs no scope: <FollowButton> from @civitai/blocks-react/ui, or the useCollectionFollow hook beneath it, asks the host to follow, and the host confirms each follow with the viewer.
If you only want a reaction-style button that stays inside your app, you can keep app-local "marks" in your app's own storage, as the example above does. They are not Civitai reactions. They never reach civitai.com, their counts do not match the counts on the site, and the creator never sees them. Your app's copy must not call them Civitai reactions, and must not suggest the creator is notified.
<civitai-tag> votes go nowhere either
<civitai-tag> draws the up/down vote arrows from civitai.com's tag pills. Like <civitai-reaction>, it is presentational only: it calls no route, sends no bridge message, and needs no scope.
- Properties:
name,vote(1up,-1down,0none),score,show-score,confidence,ratingandreadonly(which removes the arrows). You set all of them. The element cannot read a tag's real score or how this viewer voted. - On click it sets
vote(pressing the side already chosen clears it to0), then dispatches avoteCustomEventwithdetail: { name, vote }. It does not changescore. The event bubbles and iscomposed. In React,@civitai/components-react's<CivitaiTag>exposes it asonVote. - Saving the vote, and moving
score, is your job. If your write fails, setvoteback yourself.
import '@civitai/components/civitai-tag/define';
import type { TagVoteDetail } from '@civitai/components/civitai-tag';
// Your own write, e.g. to app storage. It is not a vote on a Civitai tag.
declare function saveTagVote(name: string, vote: -1 | 0 | 1): Promise<void>;
const tag = document.querySelector('civitai-tag');
if (tag) {
let previous = tag.vote;
tag.addEventListener('vote', (event) => {
const { name, vote } = (event as CustomEvent<TagVoteDetail>).detail;
const before = previous;
previous = vote;
saveTagVote(name, vote).catch(() => {
// The element has already changed its vote. Put it back.
tag.vote = before;
previous = before;
});
});
}Apps cannot vote on Civitai tags. civitai.com records tag votes through its own signed-in session, and no block scope, hook, bridge message or /api/v1/blocks route exposes that to an app. A vote your app stores is an app-local mark, the same as the reaction marks above: it never reaches civitai.com and does not change the tag's score there.
Next
- The
@civitai/sdkclient — the other half of the default template. - Quickstart — scaffold an app built on both.
- Theming & the design system — tokens, the stylesheet half, and the React bindings (
@civitai/components-react) over these same elements.