Unofficial ReScript React bindings for Radix Primitives.
Use Radix's accessible, unstyled React components from ReScript. Documentation and live examples: rescript-radix.dev.
Status: 0.1.0, published. All Radix Primitives components and utilities are bound (see Component coverage). Prop coverage per part is deliberately lean; see Prop coverage.
Radix provides interaction behavior, keyboard navigation, focus management, and accessibility foundations for common UI components. ReScript provides a typed way to compose them into your application.
This package aims to connect the two through a thin binding layer. Radix remains responsible for component behavior; your application controls styling, content, and composition. Accessible results still depend on following each primitive's usage requirements.
- Stay close to Radix. Preserve component names, compound component structure, and behavior wherever practical.
- Use ReScript types. Represent optional props, callbacks, and finite values with appropriate types.
- Keep styling in your hands. Support ordinary classes and styles without requiring a CSS framework or theme.
- Support incremental adoption. Use the primitives you need alongside existing React and ReScript components.
- Keep abstractions optional. Put higher-level composition helpers in a separate package rather than making them part of the core bindings.
Each primitive's parts live under one module, namespaced as Radix:
@react.component
let make = () => {
<Radix.Dialog.Root>
<Radix.Dialog.Trigger className="button">
{React.string("Edit profile")}
</Radix.Dialog.Trigger>
<Radix.Dialog.Portal>
<Radix.Dialog.Overlay className="dialog-overlay" />
<Radix.Dialog.Content className="dialog-content">
<Radix.Dialog.Title>
{React.string("Edit profile")}
</Radix.Dialog.Title>
<Radix.Dialog.Description>
{React.string("Update your profile information.")}
</Radix.Dialog.Description>
<ProfileForm />
<Radix.Dialog.Close className="button">
{React.string("Close")}
</Radix.Dialog.Close>
</Radix.Dialog.Content>
</Radix.Dialog.Portal>
</Radix.Dialog.Root>
}In the code above,
ProfileFormrepresents an application component.
openandtypeare ReScript keywords, so those props areopen_andtype_. They serialize to the Radix names.aria-labelis bound asariaLabelwhere Radix documents it.- Parts whose Radix
typeprop changes the value shape are split in two:Accordion.Single/Accordion.Multiple,ToggleGroup.Single/ToggleGroup.Multiple,Toolbar.ToggleGroupSingle/Toolbar.ToggleGroupMultiple. Passtype_=#singleortype_=#multiple; the type checker rejects a mismatch. Checkboxtri-state usesRadix.Shared.checkedState:Bool(true),Bool(false),Indeterminate.- Dismissal callbacks (
onPointerDownOutside,onFocusOutside,onInteractOutside) receiveRadix.Shared.dismissEvent; callRadix.Shared.preventDefault(event)to cancel. OneTimePasswordFieldandPasswordToggleFieldbind Radix'sunstable_exports and may change with Radix minors.
Each part binds the props Radix documents for it, plus asChild, className, style, id, ref and children. Button-like parts also take disabled and onClick. The full HTML attribute surface is not bound. When you need another attribute, use asChild and supply your own element.
Radix Primitives are unstyled. Apply your own CSS using the exposed DOM props and Radix's state attributes, such as data-state where supported.
The core package is intended to work with plain CSS, CSS modules, utility classes, or another styling approach supported by your application. It will not provide a theme or a set of finished visual components.
The core bindings are intended to expose Radix's optional asChild prop. Ordinary usage can rely on each primitive's default DOM element and style it directly.
Use asChild when you need a different underlying element, an existing component, or multiple primitive behaviors on the same element. Radix handles the composition internally, but a custom child must pass the injected props and ref through to the appropriate DOM element. The bindings cannot automatically make an arbitrary component compatible.
See the Radix composition guide for the underlying requirements.
A separate companion package, provisionally named rescript-radix-compose, is being considered for typed button/link adapters and an explicit attachment API for custom components. These helpers are exploratory and are not a prerequisite for using the core bindings.
npm install rescript-radix radix-uiAdd it to rescript.json:
{ "dependencies": ["@rescript/react", "rescript-radix"] }The package ships ReScript source and is compiled by your project. Tested against:
| Package | Version |
|---|---|
| rescript | 12.3 |
| @rescript/react | 0.15 |
| react | 19.2 |
| radix-ui | 1.8 |
Bindings import from the unified radix-ui package (import * as RadixUi from "radix-ui"; RadixUi.Dialog.Root). The per-primitive @radix-ui/react-* packages are not referenced directly.
One ReScript module per Radix primitive, each exposing Radix's parts as submodules.
| Module | Parts |
|---|---|
| Accordion | Single, Multiple, Item, Header, Trigger, Content |
| AlertDialog | Root, Trigger, Portal, Overlay, Content, Title, Description, Action, Cancel |
| AspectRatio | Root |
| Avatar | Root, Image, Fallback |
| Checkbox | Root, Indicator |
| Collapsible | Root, Trigger, Content |
| ContextMenu | Root, Trigger, Portal, Content, Arrow, Group, Label, Item, CheckboxItem, RadioGroup, RadioItem, ItemIndicator, Separator, Sub, SubTrigger, SubContent |
| Dialog | Root, Trigger, Portal, Overlay, Content, Title, Description, Close |
| DropdownMenu | same parts as ContextMenu |
| Form | Root, Field, Label, Control, Message, Submit |
| HoverCard | Root, Trigger, Portal, Content, Arrow |
| Label | Root |
| Menubar | Root, Menu, Trigger, plus the ContextMenu parts |
| NavigationMenu | Root, Sub, List, Item, Trigger, Content, Link, Indicator, Viewport |
| OneTimePasswordField | Root, Input, HiddenInput |
| PasswordToggleField | Root, Input, Toggle, Slot, Icon |
| Popover | Root, Trigger, Anchor, Portal, Content, Arrow, Close |
| Progress | Root, Indicator |
| RadioGroup | Root, Item, Indicator |
| ScrollArea | Root, Viewport, Scrollbar, Thumb, Corner |
| Select | Root, Trigger, Value, Icon, Portal, Content, Viewport, Group, Label, Item, ItemText, ItemIndicator, ScrollUpButton, ScrollDownButton, Separator, Arrow |
| Separator | Root |
| Slider | Root, Track, Range, Thumb |
| Switch | Root, Thumb |
| Tabs | Root, List, Trigger, Content |
| Toast | Provider, Viewport, Root, Title, Description, Action, Close |
| Toggle | Root |
| ToggleGroup | Single, Multiple, Item |
| Toolbar | Root, Button, Link, Separator, ToggleGroupSingle, ToggleGroupMultiple, ToggleItem |
| Tooltip | Provider, Root, Trigger, Portal, Content, Arrow |
| AccessibleIcon, Direction, Portal, Slot, VisuallyHidden | utilities |
Not bound: Form.ValidityState (render-prop API), async Form.Message matchers, and Radix's unstable_ sub-parts of Slider and Checkbox.
Feedback on the proposed API is welcome, particularly around prop types, refs, and composition.
Contributions should preserve Radix behavior and keep the binding layer small: every part is an external and the package contains no runtime code of its own.
npm install
npm test # rescript build (warnings are errors) + vitest under happy-domThe tests check the bindings, not Radix's behavior:
test/Smoke_test.resserver-renders each primitive. It catches wrong module or export names and props that serialize incorrectly.test/Callbacks_test.resmounts primitives in happy-dom, fires DOM events throughtest/Browser.res, and asserts that each callback receives a correctly typed value (for example,onCheckedChangeyieldsBool(true)rather than a raw JS value).
To add or extend a part, read its props in node_modules/@radix-ui/react-<name>/dist/index.d.mts, bind them in src/<Name>.res following the pattern in src/Dialog.res, and add a case to test/Smoke_test.res. If a callback passes a value shape not yet covered in test/Callbacks_test.res (a new variant, record, or array type), add an interaction test there too.
docs/ is a separate Vite app that depends on this package via file:.., so its examples compile against the current bindings. It is not part of the published package.
cd docs && npm install
npm run res:watch # in one terminal
npm run dev # in anotherEach component page renders docs/src/demos/<Name>Demo.res and shows that file's source. To document a new part, update the demo and the entry in docs/src/Catalog.res. The build also emits /llms.txt, /llms-full.txt and a .md copy of every page for AI agents (llmstxt.org), generated from the same data by docs/src/LlmsTxt.res. The site routes by path, so the host needs to serve index.html for unknown paths (Cloudflare Pages and Netlify do this by default).
This is an independent community project. It is not affiliated with or endorsed by Radix or WorkOS.
Radix Primitives is maintained by WorkOS and distributed under the MIT license. This repository is distributed under the MIT license.