Components
@civitai/components is a framework-agnostic pack of 20 presentational components. The styling is driven entirely by data-* attributes, so any HTML that follows the contract picks up the design system without a framework. This sheet is one of two independent ways to consume it: the other is the <civitai-*> custom elements, self-styling in shadow DOM, which are what @civitai/components-react binds for React — so the bindings are not a second renderer of this contract, and since @civitai/components-react@0.9.0 there is no longer a React layer that renders it. This page is generated from that contract — the canonical MARKUP.md that ships inside the @civitai/components package — so it never drifts from the source of truth.
New to the design system?
Start with the Theming & design system guide for the 3-layer model (tokens → framework-agnostic CSS → React bindings) and the plain-HTML and React setup. This page is the per-component attribute reference.
Component summary
| Component | data-civitai-ui | Enumerable attributes |
|---|---|---|
| Text | text | data-size: xs · sm · md (default) · lg · xl · 2xl · 3xldata-weight: normal (default) · medium · semibold · bold |
| Button | button | data-variant: filled (default) · light · outline · subtledata-size: sm · md (default) · lg |
| TextInput | text-input | — |
| Textarea | textarea | — |
| NumberInput | number-input | — |
| Select | select | — |
| Checkbox | checkbox | — |
| Radio | radio | — |
| RadioGroup | radio-group | — |
| Card | card | data-padding: sm · md · lg |
| Stack | stack | data-gap: sm · md · lg |
| Group | group | data-gap: sm · md · lg |
| Alert | alert | data-color: info · success · warning · error |
| Loader | loader | data-size: sm · md (default) · lg |
| Badge | badge | data-variant: filled (default) · light · outlinedata-size: sm · md (default) · lg |
| Slider | slider | — |
| SegmentedControl / Tabs | segmented-control | data-size: sm · md (default) |
| Toast | toast-region | data-color: info · success · warning · error |
| Tooltip | tooltip | — |
| Image | image | data-status: loadingdata-fit: cover (default) |
The exact element, required attributes, and ARIA/role wiring for each component are reproduced verbatim from the canonical contract below.
Setup
Load the tokens and the component CSS (order-independent, but load both):
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@civitai/theme/styles.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@civitai/components/styles.css" />These URLs are deliberately unversioned — they track each package's latest dist-tag, so the CSS you load always matches the contract documented below. A pinned URL is the failure mode this document has already shipped twice: jsDelivr serves every published version forever, so a stale pin returns 200 with an old stylesheet and every attribute documented since renders as an unstyled bare element — no console error, no failed request. To pin anyway (reproducible builds), append the version you read from each package's own npm page — …/npm/@civitai/theme@<theme-version>/styles.css — and re-check it when you upgrade. The two packages version independently: never copy one version across both links, because a version a package never published is a hard 404, and a 404'd stylesheet renders unstyled with no error either.
Or, from JS: import { injectStyles } from '@civitai/components'; injectStyles(); (injects both tokens and component CSS, idempotently).
Theming
Set data-theme="light" or data-theme="dark" on any ancestor (typically <html> or the block root). All tokens re-resolve from that scope. Default (no attribute) is the light palette.
Cascade / overriding
Every shipped rule lives in @layer civitai.components. Your own unlayered CSS always beats it — no !important, no specificity war. Override a token locally by redeclaring the custom property (e.g. style="--civitai-color-primary: #a259ff").
Components
Legend: bold = required attribute/element for correct styling + a11y.
Text — data-civitai-ui="text"
Headings, paragraphs and inline copy. This is the one component that prescribes no element of its own — you write the tag the meaning calls for and this styles it:
- Element:
<h1>–<h6>for a heading,<p>for a paragraph,<span>for inline text inside a sentence. A heading MUST be a real heading element: that is what puts it in the document outline and in a screen reader's heading list, and a styled<div>(or<span>) is not a substitute. data-size:xs·sm·md(default) ·lg·xl·2xl·3xl·4xl·5xldata-weight:normal(default) ·medium·semibold·bold- Colour is a utility, not an attribute here —
ci-mutedfor secondary copy,ci-text-info/-success/-warning/-errorfor the intent set,ci-text-defaultfor the body colour.colorinherits, so these reach<civitai-text>'s shadow content too. See Not in this component below.
Size and heading level are independent, on purpose. data-size never changes what the element means, and the element never changes the size — so an <h2> can be the small print of a card (data-size="xs") and a <p> can be the lede (data-size="xl"). Pick the level from the page's structure and the size from its design.
The scale — one scale, in two halves. xs–lg is the UI ramp: sm/md/lg are the same three sizes Button uses, so a size name means one size across the pack. xl–5xl is the heading ramp, and every value from lg up is one the ci-fs-* utilities already ship — so the pack has one type scale under two spellings, not two that disagree:
data-size | font-size | line-height | same value as |
|---|---|---|---|
xs | 12px | 1.5 | — |
sm | 13px | 1.5 | — |
md (default) | 14px | 1.5 | — |
lg | 16px | 1.5 | ci-fs-6 |
xl | 20px | 1.25 | ci-fs-5 |
2xl | 24px | 1.25 | ci-fs-4 |
3xl | 28px | 1.25 | ci-fs-3 |
4xl | 32px | 1.25 | ci-fs-2 |
5xl | 40px | 1.25 | ci-fs-1 |
Two caveats worth knowing, both deliberate. The names do not encode the ci-fs-N number, and the two sequences run in opposite directions (5xl is ci-fs-1) — the right-hand column above is the mapping, and it is the price of keeping one naming convention across the whole ramp instead of switching to fs-N halfway up. The unit differs: this ramp is px (Button's unit), ci-fs-* is rem. They are equal at the default 16px root and diverge if a consumer changes it; mixing units inside one ramp would make it non-monotonic there, which is worse. Nothing above ci-fs-1 (40px) is invented — that is the top of both ladders.
Margins are reset to 0. The browser's default heading/paragraph margins are em-relative, so they would move with every data-size; vertical rhythm in this pack belongs to stack / group. Space your text by wrapping it in one of those, not by relying on a UA margin.
Not in this component, deliberately — each already has an implementation one layer down, and the same predicate decides all three:
colour →
ci-muted(secondary copy),ci-text-info/-success/-warning/-error(the intent set Alert / Badge / Toast share),ci-text-default(the body colour).colorinherits, so a utility on this element — or on any ancestor — reaches<civitai-text>'s shadow content as well; its inner element iscolor: inherit. Text setscolor: inherit, not the text token, on both tracks, and that is what makes the ancestor half of the sentence above true: a specified value beats an inherited one at any specificity, so a token on the element itself would cancel every ancestor utility.⚠️ THE TRADE, and it applies to pages that DO set a colour — not only to pages that set none. Text does not paint
--civitai-color-texton its own, so it renders in whatever colour it inherits: wherever an ancestorcolorand the token disagree, Text follows the ancestor. An ancestorcoloris the common case, not the exception. Measured on both tracks at this commit, in the shape a block in this repo actually has: a[data-theme="dark"]root carryingcolor: #e6e6e6, which is whatcivitai-block-starterand all six apps understarters/examples/set. Text computesrgb(230, 230, 230)— the root's colour — against a dark token ofrgb(193, 194, 197). Restoring the removed declaration on that same fixture puts both tracks back atrgb(193, 194, 197)while the plain<p>beside them staysrgb(230, 230, 230); that pair is the trade, in the exact colours a block here ships. Dark is where it reads, the token being a soft grey next to a near-white block colour. Light theme behaves the same way: withcolor: rgb(24, 24, 27)on<body>Text computesrgb(24, 24, 27)where it computed the tokenrgb(34, 34, 34)before.Every in-repo consumer would be in that population once it renders Text — none does today — by two different routes: four starters set the colour on
<body>with Tailwind (text-zinc-900 dark:text-zinc-100—starters/next-app/src/app/globals.css,starters/react-pwa/index.html,starters/svelte-pwa/index.html,starters/sveltekit-app/src/app.html), and seven set it on a[data-theme]root as#1a1a1a/#e6e6e6(starters/civitai-block-starter/src/index.cssplus the sixstarters/examples/*/src/index.css). The package's owndemo/andplayground/are the exception that proves the rule: both setbody { color: var(--civitai-color-text) }, so they still show the token — by inheriting it, not because Text names it. With no colour anywhere on the page Text lands on the UA defaultrgb(0, 0, 0), since@civitai/themeships tokens only and sets nocolor.Ask for the token explicitly with
ci-text-default, the same utility route as every other value — 🔴 but that class lives inutilities.css, which is a separate stylesheet this package does not inject.injectStyles()ships the tokens andstyles.cssand nothing else, and@civitai/blocks-react'sinjectBlocksStyles()— reached on mount by 20 of the 21 component modules in that package's/ui,SettingsFormbeing the one exception and deliberately unstyled — adds only its own interactive CSS on top. So on either of those pathsci-text-default,ci-mutedand everyci-text-*is an unknown class that silently does nothing. Measured:<p data-civitai-ui="text" class="ci-text-default">under an ancestorcolor: rgb(24, 24, 27), withinjectStyles()alone, computesrgb(24, 24, 27)— the class had no effect. Link or import@civitai/components/utilities.cssalongsidestyles.cssif you colour, align or truncate text;demo/index.htmllinks all three for this reason.alignment →
ci-text-start/ci-text-center/ci-text-end.text-aligninherits, same as above.truncation →
ci-truncate. (This one does not reach shadow content —overflowdoes not inherit — so truncation on the element track is a real follow-up rather than an oversight.)
A data-color, data-align or data-truncate here would be a second copy of a predicate that already exists. Adding any of them later is additive; taking one away would not be.
<h2 data-civitai-ui="text" data-size="4xl" data-weight="bold">Generate an image</h2>
<p data-civitai-ui="text">Pick a model, then press Generate.</p>
<span data-civitai-ui="text" data-size="xs" class="ci-muted">Costs Buzz</span>Button — data-civitai-ui="button"
- Element:
<button>(or<a role="button">for links). data-variant:filled(default) ·light·outline·subtledata-size:sm·md(default) ·lgdata-full-width="true"— stretch to container width.- Loading: set
aria-busy="true"anddisabled; place a<span data-civitai-ui="loader" data-size="sm" aria-hidden="true"></span>as the first child. - Icon slots:
<span data-civitai-ui-section="left|right">…</span>. - A11y: native
<button>gives role/focus/keyboard for free. Icon-only buttons MUST have anaria-label.
<button data-civitai-ui="button" data-variant="filled" data-size="md">Generate</button>TextInput — data-civitai-ui="text-input"
- Wrapper
<div data-civitai-ui="text-input">containing, in order:<label data-civitai-ui-label for="ID">(+ optional<span data-civitai-ui-required aria-hidden="true">*</span>)- optional
<span id="ID-desc" data-civitai-ui-description> <input data-civitai-ui-control id="ID">- optional
<span id="ID-err" data-civitai-ui-error role="alert">
- Wire a11y: input
aria-describedby="ID-desc ID-err", and when invalidaria-invalid="true"+data-invalid="true"on the wrapper.
<div data-civitai-ui="text-input">
<label data-civitai-ui-label for="name">Name</label>
<input data-civitai-ui-control id="name" />
</div>Textarea — data-civitai-ui="textarea"
Identical to TextInput but the control is <textarea data-civitai-ui-control> (resizable vertically).
NumberInput — data-civitai-ui="number-input"
Identical to TextInput; the control is <input type="number" data-civitai-ui-control>.
Select — data-civitai-ui="select"
Identical field chrome to TextInput; the control is a native <select data-civitai-ui-control> (native disclosure caret retained). Wire a11y exactly like TextInput (label for, aria-describedby, aria-invalid + data-invalid when invalid). This is the framework-agnostic NATIVE select — not the interactive JS Select from @civitai/blocks-react.
<div data-civitai-ui="select">
<label data-civitai-ui-label for="model">Model</label>
<select data-civitai-ui-control id="model">
<option value="sdxl">SDXL</option>
<option value="flux">Flux</option>
</select>
</div>Checkbox — data-civitai-ui="checkbox"
A themed native checkbox: the box and its label sit inline in a -choice row, with description/error below. accent-color carries the theme tint.
- Wrapper
<div data-civitai-ui="checkbox">containing, in order:<div data-civitai-ui-choice>wrapping:<input type="checkbox" id="ID">— the checkbox itself. The baretype="checkbox"inside the wrapper is what the CSS targets; do not putdata-civitai-ui-controlon it (that is the full-width field-input chrome for text/select controls).<label data-civitai-ui-label for="ID">(+ optional<span data-civitai-ui-required aria-hidden="true">*</span>)
- optional
<span id="ID-desc" data-civitai-ui-description> - optional
<span id="ID-err" data-civitai-ui-error role="alert">
- Wire a11y: input
aria-describedby="ID-desc ID-err", and when invalidaria-invalid="true"+data-invalid="true"on the wrapper. - Disabled / checked / indeterminate are the native input states.
<div data-civitai-ui="checkbox">
<div data-civitai-ui-choice>
<input type="checkbox" id="tos" />
<label data-civitai-ui-label for="tos">I agree</label>
</div>
</div>Radio — data-civitai-ui="radio"
Identical to Checkbox but the control is <input type="radio" id="ID">. Group several by giving them the same name. Wrap a set in a RadioGroup (below) for the role=radiogroup layout + group label.
RadioGroup — data-civitai-ui="radio-group"
role="radiogroup"on the wrapper.- Optional group label:
<span data-civitai-ui-label id="GID">referenced by the wrapper'saria-labelledby="GID"(+ optional description linked viaaria-describedby). - Options container:
<div data-civitai-ui-radio-options>holding thedata-civitai-ui="radio"items.data-orientation="horizontal"lays them out in a row (default is a vertical stack). - Optional group-level error (mirrors the field components): a
<span id="GID-err" data-civitai-ui-error role="alert">after the options container. When present, wire a11y on the wrapper:aria-invalid="true"+data-invalid="true", and join the error id into the wrapper'saria-describedby="GID-desc GID-err"(alongside the description id). With no error, emit none of these attributes (backward-compatible).
<div data-civitai-ui="radio-group" role="radiogroup" aria-labelledby="sampler-lbl">
<span data-civitai-ui-label id="sampler-lbl">Sampler</span>
<div data-civitai-ui-radio-options>
<div data-civitai-ui="radio"><div data-civitai-ui-choice>
<input type="radio" name="sampler" id="s-euler" />
<label data-civitai-ui-label for="s-euler">Euler</label>
</div></div>
<div data-civitai-ui="radio"><div data-civitai-ui-choice>
<input type="radio" name="sampler" id="s-ddim" />
<label data-civitai-ui-label for="s-ddim">DDIM</label>
</div></div>
</div>
</div>Card — data-civitai-ui="card"
- A card has a subtle default hairline in light mode (where
surface==body, an otherwise-borderless card would be invisible). Dark differentiatessurfacefrombody, so no default hairline is drawn there. data-with-border="true"— the stronger, fully-opaque explicit border.data-padding:sm·md·lg.- A11y: use a landmark/heading inside as appropriate; the card itself is a presentational container (
<div>/<section>/<article>).
Stack — data-civitai-ui="stack"
Vertical flex. data-gap: sm · md · lg (default ~12px). Presentational <div>.
Group — data-civitai-ui="group"
Horizontal flex, items center-aligned. data-gap: sm · md · lg.
Wraps by default — a row of several controls reflows onto more rows rather than overflowing a narrow slot. Children may also shrink below their content width, so one long unbroken label narrows instead of pushing the whole row past the container. (That shrink applies to a child with the default overflow: visible; a child that sets any other overflow already gets it from the flexbox spec.)
data-nowrap="true" keeps the row on one line. Use it only where a single line is load-bearing, and expect overflow at narrow widths.
Both are plain attributes, so they are set the same way whoever writes the markup. The <civitai-group> element exposes the same choice as a nowrap property (reflected to the nowrap attribute), rather than data-nowrap.
Alert — data-civitai-ui="alert"
role="alert"(orrole="status"for non-urgent).data-color:info(default intent) ·success·warning·error.- Structure: optional icon, then
<div data-civitai-ui-alert-body>with an optional<div data-civitai-ui-alert-title>and the message. Optional dismiss:<button data-civitai-ui-alert-close aria-label="Dismiss">×</button>.
<div data-civitai-ui="alert" data-color="success" role="alert">
<div data-civitai-ui-alert-body>
<div data-civitai-ui-alert-title>Saved</div>
Your changes are live.
</div>
</div>Loader — data-civitai-ui="loader"
data-size:sm·md(default) ·lg.- A11y: decorative inside a button →
aria-hidden="true". Standalone busy indicator → wrap/annotate withrole="status"+ an accessible label (e.g. visually-hidden "Loading").
Badge — data-civitai-ui="badge"
data-variant:filled(default) ·light·outline.data-size:sm·md(default) ·lg.data-color(optional):info·success·warning·error— the same intent set as Alert. Omit it for the default primary accent. Recolors thefilled/light/outlinevariants.- Presentational
<span>. If it conveys status, add anaria-label.
<span data-civitai-ui="badge" data-variant="light" data-color="success" data-size="md">ready</span>Slider — data-civitai-ui="slider"
A themed native <input type="range">. accent-color carries the theme tint, so keyboard (arrow keys, Home/End, Page Up/Down) + ARIA come from the native control.
- Wrapper
<div data-civitai-ui="slider">containing, in order:- optional header
<div data-civitai-ui-slider-header>wrapping the<label data-civitai-ui-label for="ID">and an optional current-value read-out<output data-civitai-ui-slider-value for="ID">. Without a value read-out, use a bare<label data-civitai-ui-label for="ID">instead of the header. - optional
<span id="ID-desc" data-civitai-ui-description> <input type="range" id="ID">— the slider itself. Like checkbox/radio it does not carrydata-civitai-ui-control(that is the bordered field-input chrome). Setmin/max/step/valuenatively.- optional
<span id="ID-err" data-civitai-ui-error role="alert">
- optional header
- Wire a11y: input
aria-describedby="ID-desc ID-err", and when invalidaria-invalid="true"+data-invalid="true"on the wrapper (tints the track to the error token). Disabled is the native input state. When you render a formatted value read-out (e.g.20%,Large), also setaria-valuetexton the input to that same string so screen readers announce it instead of the rawaria-valuenow. 🔴 Nothing does this for you on either track:<civitai-slider>'sshow-valuerenders a read-out of the RAW value and sets noaria-valuetext(the attribute appears nowhere in this package's element sources). The deleted React<Slider>DID set it, from itsvalueLabelprop; that prop has no element equivalent, so a formatted read-out now means settingaria-valuetextyourself.
<div data-civitai-ui="slider">
<div data-civitai-ui-slider-header>
<label data-civitai-ui-label for="steps">Steps</label>
<output data-civitai-ui-slider-value for="steps">20</output>
</div>
<input type="range" id="steps" min="0" max="100" value="20" />
</div>SegmentedControl / Tabs — data-civitai-ui="segmented-control"
A row of segment buttons with roving tabindex + arrow-key navigation, in one of two ARIA role modes. The CSS is presentational; hand-HTML authors MUST implement the keyboard behavior themselves. Prefer an element for interactive use, which supplies it: <civitai-segmented-control> for the panel-less value switch (radiogroup/radio), and <civitai-tabs> when segments actually switch panels. They are two elements rather than one with a mode, because a tab's aria-controls is an IDREF and an IDREF cannot reach a panel in the light DOM from inside a shadow root.
Common to both modes:
- Wrapper
<div data-civitai-ui="segmented-control">with an accessible name (aria-labeloraria-labelledby).data-size:sm·md(default) ·lg. - Each segment:
<button data-civitai-ui-segment>,disabledfor a disabled one. Exactly one is selected:tabindex="0"on the selected segment,tabindex="-1"on the rest (roving tabindex). - Keyboard (roving):
ArrowLeft/ArrowRight(+ArrowUp/ArrowDown) move to the previous/next enabled segment (wrapping),Home/Endjump to first/last; selection follows focus. Move focus to the newly-selected segment.
Mode toggle (default) — a panel-less value switch (Mantine-style):
- Wrapper
role="radiogroup"; each segmentrole="radio"witharia-checked="true|false". Noaria-controls. Use this when the control just picks a value (no panels).
<div data-civitai-ui="segmented-control" role="radiogroup" aria-label="Layout" data-size="md">
<button data-civitai-ui-segment role="radio" aria-checked="true" tabindex="0">Grid</button>
<button data-civitai-ui-segment role="radio" aria-checked="false" tabindex="-1">List</button>
</div>Mode tabs — a tab set that switches visible panels:
- Wrapper
role="tablist"; each segmentrole="tab"witharia-selected="true|false"+ optionalaria-controls="PANEL_ID"+idlinking its tab panel. - Tab panel:
<div data-civitai-ui-tabpanel role="tabpanel" id="PANEL_ID" aria-labelledby="TAB_ID" tabindex="0">,hiddenwhen its tab is not selected.
<div data-civitai-ui="segmented-control" role="tablist" aria-label="View" data-size="md">
<button data-civitai-ui-segment role="tab" id="t-grid" aria-selected="true"
aria-controls="p-grid" tabindex="0">Grid</button>
<button data-civitai-ui-segment role="tab" id="t-list" aria-selected="false"
aria-controls="p-list" tabindex="-1">List</button>
</div>
<div data-civitai-ui-tabpanel role="tabpanel" id="p-grid" aria-labelledby="t-grid" tabindex="0">…</div>
<div data-civitai-ui-tabpanel role="tabpanel" id="p-list" aria-labelledby="t-list" tabindex="0" hidden>…</div>Toast — data-civitai-ui="toast-region" + data-civitai-ui="toast"
An aria-live notification host (toast-region) plus the individual toast card. <civitai-toast-region> owns the queue and auto-dismiss timers — call its show(options) method, which enqueues a toast and returns its id. (Until @civitai/components-react@0.9.0 a React ToastProvider + useToast() pair did this; both were deleted with the hand-written layer.) Hand-HTML authors render into the region and add each toast so the live region announces it.
- Host:
<div data-civitai-ui="toast-region" role="region" aria-label="Notifications" aria-live="polite">(fixed bottom-right stack). Usearia-live="assertive"for urgent errors. - Toast:
<div data-civitai-ui="toast" role="status">(orrole="alert"for urgent).data-color:info·success·warning·error(colors the left accent; same intent set as Alert). Omit for the neutral accent.<div data-civitai-ui-toast-body>with an optional<div data-civitai-ui-toast-title>and the message.- optional dismiss:
<button data-civitai-ui-toast-close aria-label="Dismiss">×</button>.
<div data-civitai-ui="toast-region" role="region" aria-label="Notifications" aria-live="polite">
<div data-civitai-ui="toast" data-color="success" role="status">
<div data-civitai-ui-toast-body>
<div data-civitai-ui-toast-title>Saved</div>
Your changes are live.
</div>
<button data-civitai-ui-toast-close aria-label="Dismiss">×</button>
</div>
</div>Tooltip — data-civitai-ui="tooltip"
A hover/focus tooltip: a positioned role="tooltip" bubble revealed when the wrapper is hovered or contains focus. <civitai-tooltip> wires the trigger's aria-describedby to the bubble and Escape-to-dismiss for you.
- Wrapper
<span data-civitai-ui="tooltip">containing, in order:- the trigger element (button/link/etc.), with
aria-describedby="TIP_ID". <span data-civitai-ui-tooltip-bubble role="tooltip" id="TIP_ID">— the bubble. Revealed on:hover/:focus-within, or force-open withdata-open="true".data-dismissed="true"force-HIDES it — it overrides the hover/focus reveal, so Escape-to-dismiss works even while the pointer still hovers / focus is still within (<civitai-tooltip>sets/clears this).
- the trigger element (button/link/etc.), with
- A11y: the trigger must be focusable so keyboard users can reveal the tooltip; keep the tooltip text short (it is supplementary, not the accessible name).
<span data-civitai-ui="tooltip">
<button data-civitai-ui="button" aria-describedby="tip-seed">Seed</button>
<span data-civitai-ui-tooltip-bubble role="tooltip" id="tip-seed">Randomize the seed</span>
</span>Image — data-civitai-ui="image"
A media container with a token placeholder background (visible while loading), object-fit control, and a broken-image fallback. Hand-HTML authors set data-status themselves. (<civitai-image> tracks this from the native load/error events, but reflects it as status, not data-status — its CSS keys off :host([status='loading']). The two tracks spell this one differently; see the components README.)
- Wrapper
<div data-civitai-ui="image">(size it withwidth/height/aspect-ratioinline or via your own class).data-status:loading·loaded·error(omitted ⇒ the image shows).<img data-civitai-ui-image-img>—data-fit:cover(default) ·contain. Always providealt.- optional
<div data-civitai-ui-image-fallback>— shown (overlay) only whendata-status="error".
<div data-civitai-ui="image" data-status="loaded" style="aspect-ratio: 16 / 9">
<img data-civitai-ui-image-img src="/img.jpg" alt="Preview" />
<div data-civitai-ui-image-fallback aria-hidden="true">Image unavailable</div>
</div>Relationship to the elements and to React
@civitai/components-react binds the <civitai-*> custom elements, not this sheet: the elements style themselves in shadow DOM, so they do not consume the contract above and are not a second renderer of it. Consuming this document means writing the markup yourself, in whatever framework or none.
Until @civitai/components-react@0.9.0 that package also shipped a hand-written React layer which DID render this markup, and an html-vs-react-parity browser test asserted identical getComputedStyle() between the two arms. That layer was superseded by the elements and the test retired with it — there is no longer a second implementation to compare against. The contract here remains executable against the sheet itself: the @civitai/components suites assert the rules in components.css directly.