An unstyled, accessible UI component library for SolidJS — a faithful port of Base UI, built on Solid 2.0's fine-grained reactivity.
You bring the styles and the markup; quoin brings the behavior, accessibility, and positioning. Every component is a set of composable parts you assemble yourself, so you keep full control over the DOM.
[!WARNING] quoin is pre-release software (
0.1.x). The API is still settling and breaking changes should be expected. It targets thesolid-js@2.0.0-betaline and currently relies on a few patches to that beta (seepatches/); until those fixes land upstream, consuming apps may need to apply the same patches.
| 1 | pnpm install quoin-ui |
| 2 | # or |
| 3 | bun add quoin-ui |
quoin targets Solid 2.0 and expects the following peers:
| 1 | bun install solid-js@2.0.0-beta.14 @solidjs/web@2.0.0-beta.14 @solidjs/signals@2.0.0-beta.14 |
quoin relies on three small fixes to the Solid 2.0 beta — Portal mount
defaulting to document.body, suspense/error flushing in @solidjs/signals,
and a context untrack fix. These land upstream in a later beta; until then,
bun consumers must apply them to their own install (npm/pnpm/yarn users
need an equivalent such as patch-package).
The patch files ship inside the package. Copy them into your project and
register them in your package.json:
| 1 | mkdir -p patches |
| 2 | cp node_modules/quoin/patches/* patches/ |
| 1 | // package.json |
| 2 | { |
| 3 | "patchedDependencies": { |
| 4 | "solid-js@2.0.0-beta.14": "patches/solid-js@2.0.0-beta.14.patch", |
| 5 | "@solidjs/web@2.0.0-beta.14": "patches/@solidjs%2Fweb@2.0.0-beta.14.patch", |
| 6 | "@solidjs/signals@2.0.0-beta.14": "patches/@solidjs%2Fsignals@2.0.0-beta.14.patch" |
| 7 | } |
| 8 | } |
| 1 | bun install # applies the patches |
Without the patches quoin still installs and imports cleanly, but a few components (portals, suspense-driven state) won't behave correctly.
quoin exposes every component at two layers. Reach for whichever fits the job — they share the same underlying state machine, so you can mix them.
The Base UI / React-style API: a namespace of composable parts you nest to build the markup. Batteries included — portal, focus trap, scroll lock, positioning, and transitions are wired up for you. This is what you want most of the time.
| 1 | import { createSignal } from "solid-js"; |
| 2 | import { Dialog } from "quoin-ui/dialog"; |
| 3 | |
| 4 | function Example() { |
| 5 | const [open, setOpen] = createSignal(false); |
| 6 | |
| 7 | return ( |
| 8 | <Dialog.Root open={open()} onOpenChange={setOpen}> |
| 9 | <Dialog.Trigger>View notifications</Dialog.Trigger> |
| 10 | <Dialog.Portal> |
| 11 | <Dialog.Backdrop /> |
| 12 | <Dialog.Popup> |
| 13 | <Dialog.Title>Notifications</Dialog.Title> |
| 14 | <Dialog.Description> |
| 15 | You are all caught up. Good job! |
| 16 | </Dialog.Description> |
| 17 | <Dialog.Close>Close</Dialog.Close> |
| 18 | </Dialog.Popup> |
| 19 | </Dialog.Portal> |
| 20 | </Dialog.Root> |
| 21 | ); |
| 22 | } |
Every component family is also a subpath export (quoin/dialog, quoin/select, …) so you only ship what you import. Components are unstyled — bring your own CSS, CSS modules, or Tailwind. Every part forwards class, style, and the standard data attributes (data-open, data-disabled, …) for styling state.
The createX API: a Solid-idiomatic primitive that owns the state, modality, and dismissal logic but renders nothing. You provide the markup and read the state from any descendant with useX(). The compound <Dialog.Root> is itself a thin wrapper over createDialog — drop to this layer when you need full control of the rendered tree.
| 1 | import { createSignal, Show } from "solid-js"; |
| 2 | import { createDialog, useDialog } from "quoin-ui/dialog"; |
| 3 | |
| 4 | function Example() { |
| 5 | const [open, setOpen] = createSignal(false); |
| 6 | // Controlled — `setOpen`/`toggle` emit through `onOpenChange`; you own the signal. |
| 7 | const dialog = createDialog({ open, onOpenChange: setOpen }); |
| 8 | |
| 9 | return ( |
| 10 | <dialog.Provider> |
| 11 | <button onClick={() => dialog.toggle()}>View notifications</button> |
| 12 | <Show when={dialog.open()}> |
| 13 | <div role="dialog" aria-modal={dialog.modal() === true}> |
| 14 | <h2>Notifications</h2> |
| 15 | <p>You are all caught up. Good job!</p> |
| 16 | <CloseButton /> |
| 17 | </div> |
| 18 | </Show> |
| 19 | </dialog.Provider> |
| 20 | ); |
| 21 | } |
| 22 | |
| 23 | function CloseButton() { |
| 24 | const dialog = useDialog(); // reads the nearest <dialog.Provider> |
| 25 | return <button onClick={() => dialog.setOpen(false)}>Close</button>; |
| 26 | } |
Note: the primitive manages state and behavior only — DOM machinery like the portal, focus trap, and scroll lock lives in the compound parts. With
createDialogalone you render (and wire up) the surface yourself.
For state that lives outside your JSX — opened from an event handler or another component — the Dialog namespace also exposes an imperative Dialog.createHandle() you connect via <Dialog.Root handle={…}> and drive with handle.open(triggerId) / handle.close() / handle.openWithPayload({ … }).
| Overlays | Dialog | Alert Dialog | Popover |
| Tooltip | Preview Card | Drawer | |
| Menus | Menu | Submenu | Context Menu |
| Menubar | Navigation Menu | Toolbar | |
| Selection | Select | Combobox | Autocomplete |
| Form | Field | Fieldset | Form |
| Input | Number Field | OTP Field | |
| Controls | Button | Checkbox | Checkbox Group |
| Radio | Radio Group | Switch | |
| Toggle | Toggle Group | Slider | |
| Disclosure | Accordion | Collapsible | Tabs |
| Display | Avatar | Progress | Meter |
| Scroll Area | Separator | ||
| Providers | Direction Provider | CSP Provider |
Quoin heavily references Base UI's component model in idiomatic Solid:
Dialog.Root, Dialog.Trigger, Dialog.Popup, …) that you nest to build the markup you want.open/onOpenChange, value/onValueChange) — pass a signal and wire the callback.@floating-ui/dom.import/default) plus a JSX-preserved bundle (the solid condition) so Solid-aware bundlers can recompile for your target.This is an experimental Solid 2.0 port and is not affiliated with the Base UI team. It tracks Base UI's behavior and test suite where practical, with adjustments for Solid's reactivity model.
Built on the work of the Base UI team (from the creators of Radix, Floating UI, and Material UI) and the SolidJS project.
[!IMPORTANT] quoin depends on three small fixes to Solid 2.0 beta.14 that have not yet landed upstream. You must apply them in your own project — package-manager patch settings are an install-time mechanism that does not transfer from a published dependency to its consumers. Without them, parts of quoin will not work; most severely, every portalled component (dialog, popover, tooltip, menu, select, drawer, …) crashes in Chromium with
TypeError: parameter 1 is not of type 'Node'.
The three patches (against solid-js, @solidjs/web, and @solidjs/signals at 2.0.0-beta.14) fix:
@solidjs/web — Portal passes a Proxy to Node.contains. Chromium throws on every portalled surface; happy-dom/WebKit mask it, so it only shows in a real Chromium browser. (The critical one.)@solidjs/signals — action() rejection routing. A rejected action() recurses forever instead of settling; await action(...)() hangs.@solidjs/signals — async-memo rejection wake-up. A rejecting async memo never wakes its dependents; await resolve(...) hangs.solid-js — createContext provider tracks its value. The provider re-runs its whole subtree on unrelated upstream changes.quoin ships the patch files in patches/. Copy them into your project and register them with your package manager.
bun — copy patches/ to your project root and add to package.json:
| 1 | { |
| 2 | "patchedDependencies": { |
| 3 | "solid-js@2.0.0-beta.14": "patches/solid-js@2.0.0-beta.14.patch", |
| 4 | "@solidjs/web@2.0.0-beta.14": "patches/@solidjs%2Fweb@2.0.0-beta.14.patch", |
| 5 | "@solidjs/signals@2.0.0-beta.14": "patches/@solidjs%2Fsignals@2.0.0-beta.14.patch" |
| 6 | } |
| 7 | } |
pnpm — same, under the pnpm.patchedDependencies key. npm / yarn — these have no native equivalent; use patch-package with the same diffs.
After installing, verify the fix actually applied — patches can silently mis-apply on a version drift (see below). The quickest check is to open any dialog/popover in Chromium; if it renders instead of throwing, @solidjs/web is patched.
[!NOTE] These patches are pinned to
2.0.0-beta.14. If you bump Solid, the minified identifiers shift and the patches will not carry forward cleanly — re-derive (or drop any that upstream has since fixed). Full rationale, per-patch detail, and re-derivation steps live insolid/patches.md.