Reference
Components
30 components, identical in four frameworks. Wherever one needs a focus trap, keyboard navigation or managed ARIA, that behaviour is written once in a framework-free core and shared by all four, rather than reimplemented per adapter.
Install
pnpm add @crosskit-ui/react
# then, once, at your app entry
import "@crosskit-ui/styles";pnpm add @crosskit-ui/vue
# then, once, at your app entry
import "@crosskit-ui/styles";pnpm add @crosskit-ui/svelte
# then, once, at your app entry
import "@crosskit-ui/styles";pnpm add @crosskit-ui/angular
# angular.json
"styles": ["@crosskit-ui/styles", "src/styles.css"]One stylesheet, imported once. Consumers need no Tailwind of their own — it is an authoring tool here, not a runtime dependency.
How the styling works
Every component emits data-scope, data-part and data-state — never a class name. Your ownclass lands on the root untouched, and because everything shipped sits inside a CSS cascade layer, your unlayered rules win regardless of specificity.
Retheming is CSS custom properties: about 48 semantic --ck-*variables, with dark mode a single [data-theme="dark"] block.
Primitives
ButtonThe template every presentational component copies. Eight variants, three sizes, an optional icon and a loading state — all of it selected by data attributes, none of it by class names.Icon104 icons as path data, not components. Presentation attributes (fill, stroke-width, linecap) live in CSS, which is why the markup is byte-identical in all four frameworks.SpinnerA pure-CSS loading indicator. No JavaScript, no animation library.DividerA separator, horizontal or vertical, optionally with a label in the middle.BadgeA small status pill. Count, dot, or label.TagA labelled chip, optionally removable.CardA surface with padding and an optional border or shadow.AlertAn inline status message. The first composition in the library — its icon is an Icon, and proving the child's own data-part survived was the point.
Layout
ContainerA max-width wrapper that centres its content.RowA flex row with a 12-column grid inside. `spacing` is unbounded, so it ships as an inline custom property rather than a data attribute — see the CSP note below.ColA 12-column grid cell with per-breakpoint spans. `offset` works here — in v0 it emitted `ml-${n}/12`, which is not valid Tailwind, so it and all four `*Offset` props were silently no-ops.
Forms
InputA text field with label, helper text, error state and optional prefix/suffix slots.TextareaA multi-line field. Auto-resize is CSS (`field-sizing: content`, with a replica fallback) rather than a keystroke handler — so it also grows on paste, on a programmatic set, and for an initial value, none of which v0 handled.CheckboxA native checkbox with a styled control. `indeterminate` is a DOM property with no HTML attribute, which is why it needs an effect rather than a prop on the element.Radio & RadioGroupNative radios sharing a `name` already handle selection and arrow keys. RadioGroup adds the group semantics screen readers need, which v0 never provided.SwitchA single `<input role="switch">`. v0 fired two different event shapes — a synthesised `{target:{checked}}` from a wrapper div's onClick, and the real onChange from the inner input — and which one you got depended on where you clicked.SelectThe trigger is a real `<button role="combobox">`. v0 used `<input readOnly role="combobox">`, which is the ARIA pattern for a typeahead combobox and wrong for a plain select.UploadA file queue with a trigger, a list, per-file progress and retry — plus `Upload.Dragger`, the drop-target form. The queue arithmetic is framework-free and lives in `@crosskit-ui/core`, so the `accept` filter applies to a drop as well as to the file dialog; the attribute alone only filters the OS dialog.
Overlays
ModalThe overlay template every other one copies, built on the primitives in `@crosskit-ui/core` with no runtime dependency. Rendering is gated on presence, never on `open`, which is what keeps `data-state="closed"` on screen long enough for the exit animation to run.DrawerThe same behaviour as Modal — about fifteen lines differ. It is distinguished in CSS by `data-ck="drawer"` plus `data-placement`, since both share `data-scope="dialog"`.TooltipDeletes the largest file in v0 — three state variables, four refs, three effects, hand-rolled viewport clamping and a manually managed portal container. The trigger wraps your element rather than cloning props onto it.PopoverThe interactive half of Tooltip, and the reason the two are separate components rather than one with a flag. A tooltip DESCRIBES its trigger, so a screen reader flattens its contents into the trigger's description; a popover is a thing the trigger opens, so a button inside it stays a button. New in v2, React first.Menu / DropdownReplaces v0's Dropdown + Menu + MenuItem trio with one data-driven component. v0 took a whole element as its trigger, so the common case put a button inside a button; v1 removed the choice by generating the button itself, and React's v2 Dropdown takes the element back and never wraps it — so your own Button stays exactly one button.ToastThe toast queue is a plain store with no framework in it, so the toaster is a module-level singleton. The three lines below are identical in React, Vue, Svelte and Angular — no provider, no inject, no DI token in any of them. React builds its queue with createToastQueue(); the other three still use createToaster() until they move across.@zag-js/toast
Disclosure
TabsValue-keyed rather than index-keyed. `TabItem.id` is required now: v0 had it optional with a positional fallback while emitting `aria-labelledby={`tab-${index}`}` against triggers that carried no id, so the association never resolved.Accordion / CollapseThe chevron rotates off the machine's own `data-state` — nothing in JavaScript toggles a class.
Data display
AvatarAn image with an initials fallback. The load state is a `data-state` attribute, so the initials no longer flash before a cached image paints.ProgressA determinate or indeterminate bar. Indeterminate is `value={null}` rather than a separate flag, which matches the ARIA model.TableColumns are plain serialisable data — no render functions — so a column definition is identical in all four frameworks. Custom cell content is a per-framework slot keyed by column id, which is the one place a shared API genuinely cannot work: a `cell` returning React JSX cannot render in Vue.@tanstack/table-coreWatermarkA repeating mark drawn over its children, on a canvas raster tiled across the region. The overlay is created and repaired by `@crosskit-ui/core` rather than rendered by the framework, so deleting it in the inspector, hiding it from a stylesheet or stripping its attributes all bring it straight back. That resists accidental and casual removal — it is explicitly not a security boundary, since the mark lives in the page it is protecting. New in v2, React first.