A shadcn registry of 97 items, authored in React Native and compiled to plain DOM components. One palette, one component vocabulary, two platforms — and no react-native-web in the web half.
Every item, drawn on one page per platform: scroll it instead of opening a story at a time.
Both are stories in the Storybook, under Gallery,
where each item also has stories of its own with its props and its tests.
Add one line to your components.json. The item names are the same on both sides —
card is card — and the URL is what decides which half you get.
// a DOM app
"registries": { "@cubeui": "https://cubicecho.github.io/cubeui/r/{name}.json" }
// an Expo app
"registries": { "@cubeui": "https://cubicecho.github.io/cubeui/r/native/{name}.json" }
npx shadcn@latest add @cubeui/tokens @cubeui/button
@cubeui/tokens is the one every consumer installs: it is the palette, emitted from
a single source as oklch() for the DOM and as hex for React Native, whose style
engine cannot parse oklch() at runtime.
compilerOptions.paths in the root
tsconfig.json too. npm create vite writes them into
tsconfig.app.json; the shadcn CLI reads only the root file, finds no
@/ alias, and resolves it as a relative path — so the install writes a literal
@/ directory at your project root and reports success.
@cubeui/form is now the
Form component, installed at components/ui/form.tsx; the old bundle of
form items is @cubeui/form-set. The primitives now install over shadcn's own. The
migration
notes list every change.
83 on both platforms · 14 web-only · 0 native-only. Anything not marked both has no half on the other platform.
| Item | Platforms | What it is |
|---|---|---|
@cubeui/action-button |
both | An icon button with a required accessible name (`label`), and a tooltip that survives being disabled: `disabled` is `aria-disabled`, so the `hint` saying why is still reachable. `onClick` on the web, `onPress` on device, where the tooltip opens on a long press. |
@cubeui/alert |
both | A callout: a tinted, bordered box with an icon, a title, a line under it and an optional action. `default`, `info`, `warning` or `destructive`; only `destructive` is announced as an `alert`, the rest as a polite `status`. |
@cubeui/alert-dialog |
both | shadcn's modal that interrupts to ask, with the same part names on both halves: radix's `AlertDialog` on the web, the native `Dialog` as an `alertdialog` with no close button on device. |
@cubeui/app-form |
web | The TanStack Form binding: useAppForm, plus one-element fields — InputField, TextareaField, SelectField, CheckboxField, SwitchField — that take the form and a checked field name instead of a render prop, and SubmitButton. |
@cubeui/badge |
both | A pill naming what something is or what state it is in — and, with no label, the dot that says the same thing in a tighter row. `onRemove` adds a trailing ✕ for a tag the user can take off. |
@cubeui/button |
both | A Pressable-backed button whose variants split into container and text halves, because native inherits no text colour. |
@cubeui/calendar |
both | A month of single-date selection: react-day-picker on web, a date-fns grid on device. |
@cubeui/card |
both | A card with typed text slots, an optional accent bar, and a whole-card press target that stays reachable by keyboard. |
@cubeui/card-layout |
both | A card with its slots already placed: icon, title, description, header action, body, and a footer split between prose and buttons. |
@cubeui/centered-layout |
both | A page that is one card, centred both ways: a sign-in, a token gate, a check-your-email screen. Full height, padded off the edges, a `max-w-sm` `CardLayout` taking every card slot; on device a scroll view that keeps the form above the keyboard. |
@cubeui/checkbox |
both | Radix on web, with its whole prop surface; a Pressable announced through role and aria-checked on device. Controlled or uncontrolled on both. |
@cubeui/code |
both | Monospaced text: Code, the inline span, and CodeBlock, a bordered block of preformatted text with an action slot for a CopyButton, a wrap prop to wrap long lines instead of scrolling sideways, and a maxHeight that scrolls. |
@cubeui/color |
both | Derives an accent and a desaturated tint from a user-chosen hex colour. |
@cubeui/color-bar |
both | The left-edge accent stripe a card wears, readable in both themes and labelled for anyone who cannot see it. |
@cubeui/color-dot |
both | A small swatch standing in for a colour-coded thing in a list. |
@cubeui/color-field |
web | A colour picker as one line, over a field the compiler checked holds a string. |
@cubeui/color-picker |
both | Swatches plus a hex field, replacing `<input type="color">` — which has no native counterpart. |
@cubeui/color-picker-field |
both | `ColorPicker` bound to a hex-string form field, its swatch row named by the field's label. `ColorField`, under an item name that does not collide with the web-only `color-field`. |
@cubeui/command |
both | A searchable list of choices: shadcn's Command parts over cmdk on web (arrow keys, Enter, fuzzy ranking); on device a search Input over pressable rows, filtered on every typed word, chosen by press. The same part names and the props MultiSelect uses on both. |
@cubeui/confirm |
both | One confirmation prompt for both platforms, as a promise: `if (await confirm({…})) remove()`. |
@cubeui/confirm-button |
both | A destructive button that asks first, with a required description of what is lost: an `ActionButton` that opens `ConfirmDialog`, with `requireText` to ask for the name before a big delete. |
@cubeui/confirm-dialog |
both | A destructive-action confirmation dialog: Cancel plus one destructive button. |
@cubeui/control |
web | Every cubeui control in one install. |
@cubeui/copy-button |
both | An icon button that copies `value` to the clipboard and turns into a tick for a moment, named `Copy` (or `label`) and then `Copied`. |
@cubeui/date-field |
web | A date and a date range as one line each, over fields the compiler checked hold a Date and a DateRange. |
@cubeui/date-picker |
both | A date and optionally a time behind a popover, plus a range picker that closes when the range is complete. Clearing is a real button, not one nested in another. |
@cubeui/date-time-field |
both | `DateTimeInput` bound to a `Date | null` form field, named by its label and its date: "Due September 15th, 2026". Its own item for the weight of the calendar. |
@cubeui/date-time-input |
both | A date popover and a time field that commit one Date back — or, with `clearable mode="date"`, an optional date only. |
@cubeui/description-list |
both | Read-only facts: `DescriptionList` over `PropertyRow`s, each a label, a value, an optional hint under the value and an optional action beside it. A `<dl>` of `<dt>`/`<dd>` groups on the web, `role="list"` on device; `layout="inline"` puts labels beside values and wraps to stacked when narrow. |
@cubeui/detail-header |
both | The header row of a detail view: back, colour dot, title, badge, subtitle, actions. |
@cubeui/detail-page |
both | The page shell plus a loading / not-found guard that narrows the entity for its `contentSlot`. |
@cubeui/dialog |
both | A modal. Radix on web with its focus trap and scroll lock; a transparent native Modal with a dimmed backdrop and a centred card. |
@cubeui/dialog-layout |
both | A dialog whose body scrolls under a header that does not, and which asks before closing on unsaved work. Composes the header/content/footer chassis rather than owning a second copy. |
@cubeui/disclosure |
both | A titled part of a screen whose body shows and hides: "Show completed (3)", "Raw output". The header is one button with a turning chevron and `aria-expanded`, an optional `actionSlot` sits beside it rather than inside it, and `contentSlot` is mounted only while open. Uncontrolled with `defaultOpen`, or controlled with `open`/`onOpenChange`. |
@cubeui/disclosure-row |
both | A list row that opens: the whole heading is the button, the actions sit outside it, and the body is Item's footer. Built on the same Item every other row in the list is, with `Disclosure`'s button: `aria-expanded`, a turning chevron, and `contentSlot` mounted only while open. Controlled with `open`/`onOpenChange`. |
@cubeui/download-button |
both | An icon button that saves `source` as a file named `filename`, disabled with a spinner while a fetched `source` is pending, and `downloadBlob()`, the function it calls. The browser's own download on the web; on device the share sheet or a folder the person picks, chosen by `destination` or, by default, from a menu on the button. |
@cubeui/empty |
both | shadcn's `Empty` parts on both halves, drawn as `EmptyState` is: the muted icon bubble, the title, the line under it and what to do about it. `EmptyState` is built on them. |
@cubeui/field |
both | Label, description and error chrome around a control. The unbound half of the form set. |
@cubeui/field-row |
web | Fields side by side on one row, wrapping when they no longer fit rather than squeezing to a width the values no longer read at. |
@cubeui/file-picker |
both | A drop zone, or a button, over a hidden file input on web. The native half is a placeholder, not an implementation. |
@cubeui/form |
both | Fields bound to TanStack Form: label, control and error wired once, for both platforms. `useAppForm`, the text, select, checkbox and switch fields, `SubmitButton`, and `bindToForm` for the rest. |
@cubeui/form-dialog |
both | Dialog chrome for a form, with a footer that leaves the submit control to the caller. |
@cubeui/form-element |
both | A real `<form>` on web so Enter submits; a `View` on native, where nothing raises a submit event. |
@cubeui/form-field |
web | One field: a label wired to its control, a description that sits inline or behind a help icon, an error the control is described by, and a skeleton the right height for what is loading. Presentational, so the TanStack binding sits on it rather than beside it. |
@cubeui/form-set |
web | Every cubeui form component in one install, TanStack binding included. Named `form-set` rather than `form`: that name is the React Native `Form` component in this registry, and the shadcn CLI resolves a cross-item import by basename, so the two cannot share it. |
@cubeui/format |
both | How a count, a size in bytes, a duration and a date are written for a reader, and how a row of them is joined: formatCount, formatBytes, formatDuration, formatDate, formatAgo and joinStats, on Intl with no dependency. |
@cubeui/header-content-footer |
both | The page chassis: chrome that stays, a body that scrolls, floors that let it. Ships the sticky preset alongside the base. |
@cubeui/icons |
both | The icon set, per-icon imports on native and a tree-shaken barrel on web, with the context that gives native icons a colour to inherit. |
@cubeui/input |
both | Single-line text entry, with an optional icon at its start (`leadingSlot`) or an icon-sized control at its end (`trailingSlot`). Keeps a real DOM input on web so time, number and min/max survive; a TextInput on device. |
@cubeui/item |
both | shadcn's list-row parts — `Item`, `ItemMedia`, `ItemContent`, `ItemTitle`, `ItemDescription`, `ItemActions`, `ItemHeader`, `ItemFooter`, `ItemGroup`, `ItemSeparator` — on both halves, with shadcn's names, props and metrics. For a row `ListItem` does not fit: a header or footer line, a badge beside the title. |
@cubeui/label |
both | A caption for a control. Radix on web so clicking it focuses the control; plain text on native, where that association does not exist. |
@cubeui/layout |
web | Every cubeui layout shell in one install. |
@cubeui/list-item |
both | One row of a list: `leadingSlot` (an avatar, a checkbox), a truncating `title` over a `description`, `meta` and `actionSlot` at the far end. With `onPress` the middle is one button and `leadingSlot` and `actionSlot` stay separate controls beside it, never nested in it. |
@cubeui/markdown |
web | A Markdown string rendered from the theme's tokens and the registry's own primitives: a fenced block is a CodeBlock, a table the Table, a rule a Separator, a task item a Checkbox. GitHub-flavoured, raw HTML never rendered, URLs filtered. Optional wikilinks, and a resolveLink for links between an app's documents with the pending and broken looks built in. |
@cubeui/markdown-editor |
web | A Markdown source and its rendering: a textarea beside a Markdown preview, and a toggle between edit, split and preview that the caller may hold or leave to the component. |
@cubeui/menu |
both | A popover of actions: radix DropdownMenu on web (arrow keys, typeahead, focus back on the trigger); the popover's centred sheet with menu and menuitem roles on device. A row takes an icon, a label, a trailing node and a destructive colour, and the menu closes when one is chosen. Checkbox rows toggle and keep the menu open; radio rows in a group choose one and close it. |
@cubeui/multi-select |
both | A combobox that selects more than one thing, over Popover and Command: searchable on every word, with an optional create row and coloured chips. On the web radix and cmdk make it portalled and keyboard-operable; on device it opens the native sheet, and each chip is also removable with its ✕. |
@cubeui/multi-select-field |
web | The multi-select as one line of a form, over a field the compiler checked holds a list of strings. |
@cubeui/option-select |
both | A select taking its choices as options rather than seven primitives to assemble, with groups, separators and notes, and the id and aria props landing on the trigger. Radix's listbox on the web, the Select sheet on device; with searchable, a search box above the list in a popover. |
@cubeui/page |
both | The page shell, its header, the card grid and the empty state — the four pieces every route repeats. |
@cubeui/page-header |
both | The title block a page wears in a header slot: an optional trail, an icon and title at a chosen heading level, a description, the page's buttons at the far end, and a search or filter row beneath. Carries the inset the page chassis leaves it. |
@cubeui/page-layout |
both | A whole page: a PageHeader pinned above a body that scrolls under it, in a named column. |
@cubeui/password-field |
web | A password as one line, with a reveal button none of the nine hand-written password inputs has. |
@cubeui/password-input |
both | A password box with a reveal button that is named, changes its name, and does not submit the form: Input with type=password (secureTextEntry on device) and a show/hide button in its trailing slot. |
@cubeui/popover |
both | Radix anchored to its trigger on web; a centred native sheet on device, because anchoring is a pointer-era affordance. |
@cubeui/primitive |
web | The shadcn primitives cubeui publishes, so a change made here reaches every project. |
@cubeui/progress |
both | A horizontal bar showing how much of something is done: `role="progressbar"` with its value announced, `value` from 0 to `max` (100 by default) as shadcn's, clamped. `label` names it; `valueLabel` reads the value in words. |
@cubeui/query-state |
both | The three rungs a list of rows climbs before it is a list of rows — failed, pending, empty — with the error card and the row placeholders exported on their own. |
@cubeui/radio-group |
both | An exclusive choice, one source for both platforms: `row` (circle, label, description), `card` (a tile with an icon) or `segmented` (a full-width framed row, icon-only segments allowed), with one tab stop and arrow keys that move and select handled inside. |
@cubeui/radio-group-field |
both | A set of exclusive choices bound to a TanStack form field, the options on the page and each able to carry a line saying what it means. The group is named by its title through aria-labelledby. |
@cubeui/readable-text-color |
both | Picks black or white ink for text sitting on a colour the user chose, by WCAG contrast. |
@cubeui/route-error |
both | What shows when the tree below a route has thrown, with nothing in it that could have been the cause. The whole error boundary in one element: an alert with a title, a friendly line, an optional copyable raw message (`details`) and extra buttons beside Try again (`actionsSlot`). |
@cubeui/search-input |
both | A search box: Input with a Search icon inside at its start, type=search (a searchbox), named by `label` (default Search), and a named clear button while it holds text. |
@cubeui/section |
both | A heading over a group of fields or rows: title, description, an action at the far end, an optional rule and an optional card surface. The title is a heading of rank `level` (2 by default), so a long settings page is navigable. |
@cubeui/section-heading |
both | The small muted heading above a section of content, with an optional heading `level`. |
@cubeui/segmented |
both | A segmented control: the pill, with an optional icon before its label; the named row it sits in, which can hold the current value and drop the labels to icons under a width; and the two class helpers a router link needs instead. |
@cubeui/select |
both | A radix listbox under its trigger on web; the options in a Modal sheet on device, which is what every native platform does. |
@cubeui/separator |
both | shadcn's one-pixel rule, `orientation` and `decorative` as shadcn's: hidden from assistive tech by default, a `role="separator"` with `decorative={false}`. |
@cubeui/setting-row |
both | One row of a settings page: a `title` and `description` at the start, the control that changes it in `actionSlot` at the end, dropping under the text when the row is too narrow for both. `actionSlot` may be a function handed the title's id, so a switch or select is named by the title through `aria-labelledby`. |
@cubeui/sidebar |
both | An app's sidebar: a header, a body that scrolls and a footer on the sidebar palette at a fixed width, with an optional titled list of rows and the nav row itself — a link with an icon, a truncating label, a count and aria-current on the active page. Ships BarNavItem, the same place as an icon-only link for an app bar: a required name that is also its tooltip, the count as a badge on the icon and a status as a dot. A controlled collapsed state folds the sidebar to a rail of icons, with SidebarCollapseButton and useSidebar for what the app puts in it. |
@cubeui/skeleton |
both | shadcn's loading placeholder: a rounded `bg-accent` block the caller sizes, pulsing on both halves (`animate-pulse` on the web, the same opacity curve in `Animated` on device). |
@cubeui/spinner |
both | A loading indicator: the LoaderCircle glyph turning, one `role="status"` named by `label`, sized and coloured by `className`. |
@cubeui/split-layout |
both | Two panes side by side on a named width scale, with the floors that keep one wide child from pushing the other off the screen. Ships SidebarLayout, the preset for the common case where one pane is the screen and the other is beside it — and, with sidebarHideBelow, the app bar (brand, nav, status, action) that stands in for a navigation rail on a narrow screen. |
@cubeui/stat-tile |
both | One number on a card: a `label`, a large tabular `value`, an optional `hint` under it and an `iconSlot` before the label. Given `onPress` the tile is a button, and with `selected` a toggle (`aria-pressed`) for the filter tile over a list. `loading` holds the figure's place. |
@cubeui/switch |
both | Radix on web; a Pressable track with a moving thumb on device, rather than RN's Switch, whose raw colour props would not follow the theme. |
@cubeui/switch-field |
both | A switch and its caption on one row, with the whole row in the hit target on both platforms. |
@cubeui/table |
web | shadcn's data table, re-published from here so the table every admin page draws is edited in one place: row and column headers scoped, and a container a keyboard can scroll when the columns do not fit. |
@cubeui/tabs |
both | Radix on web; a row of Pressables over a context on device. Controlled (value/onValueChange) or uncontrolled (defaultValue) on both. A trigger can carry an icon beside its label, in the tab's active or inactive colour, and a dot or a count at its far end as `trailingSlot`. A list with more tabs than fit scrolls sideways and keeps the selected tab in view. |
@cubeui/textarea |
both | Multi-line text entry. Keeps a real DOM textarea on web; a multiline TextInput on device. Takes a submit key, a ref, and maxRows to grow with its text. |
@cubeui/theme-picker |
both | Light, Dark and System as radio tiles, or with variant="compact" as a full-width row of icon-only radios for a sidebar or header, and useThemePreference(), which stores the choice and applies it: a class on <html> on the web, Appearance.setColorScheme on device, where the app passes its own storage. With palettes={PALETTE_PREFERENCES} it adds a palette choice beside the theme (default, or the dark-only monokai), stored by usePalettePreference() and applied as data-palette on <html> on the web and by PaletteProvider on device. THEME_PRE_PAINT_SCRIPT is the inline <script> that paints the stored theme and palette before React mounts. |
@cubeui/toast |
both | Transient messages for mutations fired outside a form — the failures that otherwise reach only the console. |
@cubeui/toggle-chip |
both | A small on/off pill — a filter chip, a day toggle, a swatch — with its colour split across container and label. |
@cubeui/tokens |
both | The palette as a Tailwind stylesheet, emitted from the same source as the native one so the two cannot drift. |
@cubeui/tooltip |
both | Radix on hover and focus on web; long-press on device, which is the platform's own way to ask what a control does. |
@cubeui/top-bar-layout |
both | An app shell with a bar across the top and no sidebar: brand at the start, navigation after it that scrolls sideways when narrow, actions at the end, and the page below — a banner and a main landmark, the bar sticky on the web. |
@cubeui/unsaved-changes-guard |
both | useUnsavedChangesGuard() and UnsavedChangesDialog: one question for the three ways out of a page with unsaved edits. It takes the app's router blocker as it comes (TanStack Router's or React Router's, with no router imported), guards the page's own Close button through leave(), and holds a beforeunload listener on the web while there are changes. |
@cubeui/utils |
both | The class merger every component calls, the one hover helper that has to be gated on platform, and the `webAs` declaration the RN→web compiler reads. |
Every item with a story is at cubicecho.github.io/cubeui/storybook/, rendered in a real browser — the same stories the test run drives, with the a11y addon set to fail rather than warn.
A consuming app can put them in its own sidebar with one entry, so someone reading
Domain/UploadPanel can click through to the Button it is built from:
// .storybook/main.ts
refs: { cubeui: { title: "cubeui", url: "https://cubicecho.github.io/cubeui/storybook" } }
Button still pass
contrast after our index.css override" is only answered by a story compiled in
your app — which is what the -stories items are.
Installing @cubeui/<name>-stories puts that component's stories beside it in
your tree, importing it as @/components/ui/<name>, so they render under your
stylesheet and run as tests under your addon-vitest. They need Storybook 9 or later on
@storybook/react-vite and declare no npm dependencies. Available for:
badge, button, card, section-heading, segmented, sidebar, theme-picker, toggle-chip.
npx shadcn add @cubeui/button-stories