README.md
quoin
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.6.x). The API is still settling and breaking changes should be expected. It targets the solid-js@2.0.0-rc line (pinned to 2.0.0-rc.18) and runs on stock Solid, no patches required.
Installation
| 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-rc.18 @solidjs/web@2.0.0-rc.18 @solidjs/signals@2.0.0-rc.18 |
Two ways to build
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.
Compound components
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.
Headless primitives
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({ … }).
Styling
Quoin does not depend on a styling library. Component parts accept Solid's
native class and style values, and either prop may be a state callback.
Tailwind class strings, CSS Module names, ordinary stylesheet classes, and
inline styles keep working directly:
| 1 | import styles from "./button.module.css"; |
| 2 | import { Button } from "quoin-ui/button"; |
| 3 | |
| 4 | <Button class="inline-flex min-h-10 items-center px-3">Tailwind</Button>; |
| 5 | <Button class={styles.primary}>CSS Modules</Button>; |
| 6 | <Button |
| 7 | class={(state) => state.disabled && "opacity-50"} |
| 8 | style={{ "margin-inline": "0.5rem" }} |
| 9 | > |
| 10 | State-aware |
| 11 | </Button>; |
When a styling integration computes a class, inline style, and source metadata
together, use the optional attrs prop to keep them in one state-aware
computation:
| 1 | import * as stylex from "@stylexjs/stylex"; |
| 2 | |
| 3 | const buttonAttrs = (state: { disabled: boolean }) => |
| 4 | stylex.attrs(buttonStyles.root, state.disabled && buttonStyles.disabled); |
| 5 | |
| 6 | <Button attrs={buttonAttrs}>Save</Button>; |
attrs is deliberately narrow: it accepts only class, style, and
data-style-src. It is framework-neutral and does not add a StyleX dependency.
Existing class and style props remain supported. When combined, Quoin
composes styling in this order: its internal behavioral styles, class /
style, then attrs.
Components
| 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 |
How it works
Quoin heavily references Base UI's component model in idiomatic Solid:
- Composable parts. Each component is a namespace of parts (
Dialog.Root,Dialog.Trigger,Dialog.Popup, …) that you nest to build the markup you want. - Controlled by default. Root components take controlled props (
open/onOpenChange,value/onValueChange) — pass a signal and wire the callback. - Floating UI positioning. Popovers, menus, selects, and tooltips position with
@floating-ui/dom. - SSR & hydration ready. Ships a dual build — a DOM-compiled bundle (
import/default) plus a JSX-preserved bundle (thesolidcondition) so Solid-aware bundlers can recompile for your target.
Project status
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.
Acknowledgements
Built on the work of the Base UI team (from the creators of Radix, Floating UI, and Material UI) and the SolidJS project.