Accessible, unstyled, low-level UI primitives for Fict, ported from Radix UI.
ui-primitives is a monorepo that brings the full Radix Primitives surface — and the Radix Themes styled layer — to Fict, a fine-grained reactive (signals-based) UI framework. Every component keeps Radix's accessibility guarantees, composable "one component renders one DOM node" API, and data-* state model, while adapting the implementation to Fict's reactivity primitives (accessors, prop(), scoped signal contexts) instead of React hooks.
The published packages live under the
@fictjsnpm scope. The umbrella entry point is@fictjs/radix-ui.
- Why this project
- Features
- Requirements
- Installation
- Quick start
- The Fict reactivity model
- Repository layout
- Package catalog
- Development
- Testing
- Fict export metadata
- Releasing
- Contributing
- Acknowledgements
- License
Radix Primitives are the de-facto standard for building accessible design systems on React. Fict applications could not use them directly because Radix relies on the React runtime (hooks, forwardRef, React.createElement, synthetic events).
This repository is a faithful, behavior-compatible port:
- Same API shape — component names, sub-components, props, and
data-state/data-*attributes mirror Radix so existing Radix knowledge and documentation transfer directly. - Same accessibility — WAI-ARIA roles, keyboard navigation, focus management, and RTL support are preserved.
- Fict-native internals — reactivity is expressed with Fict signals and accessors, positioning uses
@floating-ui/dom, and scroll-locking/focus utilities are re-implemented for the Fict runtime. - Radix Themes included —
@fictjs/radix-ui-themesports the styled component layer and CSS token system so you can ship a polished UI immediately.
- 60+ packages — one focused package per primitive plus shared utilities and hooks.
- Accessible by default — keyboard interaction, focus trapping, collision-aware positioning, scroll locking, live-region announcements.
- Controlled or uncontrolled — every stateful primitive supports both modes via
useControllableState. - Unstyled core, styled themes — primitives ship zero styles;
@fictjs/radix-ui-themesadds the full Radix Themes design system. - Tree-shakeable ESM + CJS — dual builds with
.d.tstypes produced by tsup; packages include source maps except for the size-optimized Radix Themes bundle. - Thoroughly tested — unit tests in Vitest (jsdom) and end-to-end smoke tests in Playwright.
- Modern toolchain — pnpm workspaces, Turborepo task graph, Changesets releases, Conventional Commits.
| Tool | Version |
|---|---|
| Node.js | >= 22.13.0 (CI and local dev pin 22.21.1 via .nvmrc) |
| pnpm | 10.31.0 (declared as packageManager) |
| Fict runtime | fict 0.26.x brings @fictjs/runtime 0.26.x; runtime-backed packages peer-depend on @fictjs/runtime >= 0.26.0 |
Enable pnpm via Corepack:
corepack enable
corepack prepare pnpm@10.31.0 --activateInstall the umbrella package (all primitive namespaces) plus the Fict runtime:
pnpm add @fictjs/radix-ui fictOr install only the primitives you need:
pnpm add @fictjs/accordion @fictjs/dialog @fictjs/tooltip fictFor a batteries-included, styled experience add Radix Themes:
pnpm add @fictjs/radix-ui-themes @fictjs/radix-ui fict/** @jsxImportSource fict */
import { Accordion } from '@fictjs/radix-ui'
export function FAQ() {
return (
<Accordion.Root type="single" collapsible defaultValue="what">
<Accordion.Item value="what">
<Accordion.Header>
<Accordion.Trigger>What is Fict?</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>A fine-grained reactive UI framework.</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="why">
<Accordion.Header>
<Accordion.Trigger>Why these primitives?</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>Accessible building blocks you can style yourself.</Accordion.Content>
</Accordion.Item>
</Accordion.Root>
)
}Primitives ship no styles. Target the data-state attributes (data-state="open" | "closed", data-disabled, data-orientation, …) to style each state, exactly as you would with Radix.
/** @jsxImportSource fict */
import '@fictjs/radix-ui-themes/styles.css'
import { Button, Card, Flex, Text, Theme } from '@fictjs/radix-ui-themes'
export function App() {
return (
<Theme accentColor="teal" grayColor="slate" radius="large">
<Card>
<Flex direction="column" gap="3">
<Text size="3">Hello from Fict Radix Themes.</Text>
<Button>Continue</Button>
</Flex>
</Card>
</Theme>
)
}JSX is enabled either with the
/** @jsxImportSource fict */pragma or by configuring@fictjs/vite-pluginin your Vite project (seeapps/playground/vite.config.tsfor a working setup).
Fict is signals-based, so the port differs from Radix/React in a few important, consistent ways. The full details live in docs/ARCHITECTURE.md, but the essentials are:
- Props can be values or accessors. Reactive props accept
MaybeAccessor<T> = T | (() => T). Pass a getter (e.g.open={() => isOpen()}) to keep a prop live. - Hooks return accessors, not values.
useId(),useSize(),useControllableState()etc. return getter functions (() => T) you call to read the current value. - Reactive prop forwarding uses
mergeProps()andprop(() => …)from@fictjs/runtimeinstead of object spreads, so downstream updates stay fine-grained. - Context is scoped and signal-based via
createContextScope, mirroring Radix's scoping mechanism for nested/composed components. - State is controllable through
useControllableState, which warns (in dev) if a component flips between controlled and uncontrolled.
import { useControllableState } from '@fictjs/radix-ui/internal'
const [open, setOpen] = useControllableState<boolean>({
prop: () => props.open, // controlled value (accessor)
defaultProp: () => props.defaultOpen ?? false,
onChange: props.onOpenChange,
caller: 'MyComponent',
})
open() // read current value
setOpen(true) // update (no-op in controlled mode, emits onChange)ui-primitives/
├── apps/
│ └── playground/ # @fictjs/radix-ui-themes-playground — Vite demo + Playwright e2e
├── libs/
│ ├── radix-ui-themes/ # Styled Radix Themes port + CSS token system
│ ├── floating-ui-dom/ # @floating-ui/dom bindings for Fict positioning
│ ├── use-sidecar/ # Code-splitting sidecar utilities
│ ├── fict-remove-scroll/ # Scroll isolation
│ ├── fict-remove-scroll-bar/
│ └── fict-style-singleton/
├── packages/ # 60+ primitive, utility, and hook packages (see catalog)
├── scripts/ # fict-metadata.mjs (+ e2e verifier) for export metadata
├── others/primitives/ # Upstream Radix Primitives checkout, kept for reference only
├── turbo.json # Turborepo task graph
├── pnpm-workspace.yaml # Workspaces + dependency catalog (catalogMode: strict)
└── tsconfig.base.json # Shared strict TypeScript compiler options
others/primitives/ is a read-only reference copy of the upstream Radix repository used while porting; it is not part of the pnpm workspace and is not published.
All packages are published under the @fictjs scope, share MIT license, and build to ESM + CJS with types. Import them individually or via the @fictjs/radix-ui aggregate.
| Package | Description |
|---|---|
@fictjs/primitive |
Low-level polymorphic DOM element components (Primitive.div, …, asChild) |
@fictjs/core-primitive |
DOM/event helpers (composeEventHandlers, getOwnerDocument, …) |
@fictjs/slot |
Slot / asChild merging |
@fictjs/compose-refs |
Ref composition |
@fictjs/context |
Scoped context (createContextScope) |
@fictjs/collection |
Item collections for keyboard navigation |
@fictjs/direction |
LTR/RTL direction context |
@fictjs/dismissable-layer |
Outside/escape dismissal |
@fictjs/focus-guards |
Focus guards |
@fictjs/focus-scope |
Focus trapping |
@fictjs/presence |
Mount/unmount presence + exit animations |
@fictjs/roving-focus |
Roving tabindex |
@fictjs/rect |
Rectangle observation utilities |
| Package | Description |
|---|---|
@fictjs/id |
Stable id accessor |
@fictjs/use-callback-ref |
Stable callback ref |
@fictjs/use-controllable-state |
Controlled/uncontrolled state (+ reducer variant) |
@fictjs/use-effect-event |
Stable event callback |
@fictjs/use-escape-keydown |
Escape-key listener |
@fictjs/use-is-hydrated |
Hydration state accessor |
@fictjs/use-layout-effect |
SSR-safe layout effect |
@fictjs/use-previous |
Previous value accessor |
@fictjs/use-rect |
Element rect observation |
@fictjs/use-size |
Element size observation |
| Package | Description |
|---|---|
@fictjs/radix-ui |
Umbrella package: all primitive namespaces + @fictjs/radix-ui/internal utilities |
@fictjs/radix-ui-themes |
Styled Radix Themes port with CSS token entrypoints |
@fictjs/floating-ui-dom |
@floating-ui/dom positioning bindings for Fict |
@fictjs/use-sidecar |
Sidecar code-splitting utilities |
@fictjs/fict-remove-scroll |
Scroll isolation |
@fictjs/fict-remove-scroll-bar |
Body scroll removal without layout shift |
@fictjs/fict-style-singleton |
Deduplicated <style> injection |
# 1. Use the pinned Node version
nvm use # reads .nvmrc (22.21.1)
# 2. Install dependencies (frozen in CI)
pnpm install
# 3. Run the interactive playground on http://localhost:3100
pnpm devAll builds/tests/lints run through Turborepo, which respects the inter-package dependency graph and caches results.
| Command | What it does |
|---|---|
pnpm build |
Build every package (turbo run build) |
pnpm dev |
Run the playground app (builds its deps, then Vite on port 3100) |
pnpm dev:workspace |
Watch-build every package in parallel |
pnpm test |
Run all unit tests |
pnpm test:coverage |
Run unit tests with V8 coverage |
pnpm test:watch |
Watch-mode unit tests |
pnpm typecheck |
Type-check every package |
pnpm lint / pnpm lint:fix |
ESLint (flat config, type-checked) |
pnpm format / pnpm format:check |
Prettier |
pnpm metadata:verify |
Verify Fict export metadata (see below) |
pnpm verify |
Full gate: format:check → lint → typecheck → test → build → metadata:verify |
pnpm commit |
Commitizen prompt (cz-git) for Conventional Commits |
pnpm changeset |
Record a changeset for the next release |
Scope any command to a single package with pnpm filters:
pnpm --filter @fictjs/accordion test
pnpm --filter @fictjs/radix-ui-themes build- Package manager: pnpm workspaces with a strict dependency catalog in
pnpm-workspace.yaml(catalogMode: strict). Third-party versions are declared once in the catalog and referenced ascatalog:. - Task runner: Turborepo.
- Bundler: tsup — ESM + CJS,
.d.ts,platform: neutral,target: es2022, tree-shaking, and source maps except in the size-optimized Radix Themes bundle. - TypeScript: strict mode,
NodeNextmodules,verbatimModuleSyntax— seetsconfig.base.json. - Lint/format: ESLint flat config with
typescript-eslinttype-checked rules (eslint.config.mjs); Prettier (no semicolons, single quotes, width 100, trailing commas). - Git hooks: Husky runs
lint-stagedon pre-commit andcommitlinton commit-msg.
- Unit tests — Vitest with the jsdom environment, one config per package, aggregated by
vitest.workspace.ts. Run everything withpnpm test, or a package withpnpm --filter <pkg> test. - End-to-end tests — Playwright specs in
apps/playground/e2eexercise the primitives through the styled playground:
pnpm --filter @fictjs/radix-ui-themes-playground test:e2e # headless
pnpm --filter @fictjs/radix-ui-themes-playground test:e2e:ui # Playwright UISome Fict hooks return reactive containers whose shape the Fict compiler needs to understand (e.g. which return values are signals vs. memos). Those packages ship a *.fict.meta.json sidecar plus a fict field in their package.json.
This metadata is generated and verified by scripts/fict-metadata.mjs:
buildscripts of the relevant packages runnode ../../scripts/fict-metadata.mjs emit .after tsup.pnpm metadata:verify(invoked bypnpm verify) re-checks that everypackage.json#fictblock and metadata file matches the canonical config, and — with--pack— that the files are actually included in the published tarball.pnpm metadata:e2eruns the standalone end-to-end verifier (scripts/verify-fict-metadata-e2e.mjs).
See docs/ARCHITECTURE.md for the full rationale.
Releases are managed with Changesets and published to npm by CI:
- Add a changeset describing your change:
pnpm changeset. - Merge to
main. When ready, apply version bumps:pnpm version-packages(runschangeset version). - Pushing a git tag triggers
.github/workflows/npm-publish.yml, which runspnpm verifyand thenpnpm release(changeset publish).
Publishing uses npm trusted publishing (OIDC) with provenance when NPM_TOKEN is not set, and falls back to token auth when it is. The CI workflow runs pnpm verify, metadata consumer validation, and Playwright browser tests on every push and pull request, plus changeset validation on pull requests.
Contributions are welcome! Please read CONTRIBUTING.md for the development workflow, coding conventions, how to add a new package, and the PR checklist. All commits must follow Conventional Commits (enforced by commitlint).
This project is a port of Radix Primitives and Radix Themes by WorkOS. Huge thanks to the Radix team for the design, accessibility research, and reference implementation. Positioning is powered by Floating UI.
MIT © Fict contributors. Radix Primitives are © WorkOS.