@bzync/rui
Composable React UI components with scoped, fully customizable light and dark themes.
@bzync/rui (react ui) is the component library behind Bzync.
It ships 100+ components plus a small SVG chart set, a token-driven theming system, and
production-grade lifecycle primitives — with zero runtime dependencies.
- Zero runtime dependencies.
dependencies is empty. react, react-dom, and
framer-motion are peer dependencies you already control.
- Scoped theming.
ThemeProvider renders a .rui-theme scope, so you can run
multiple themes on one page or theme a subtree without touching <html>.
- Token-driven. Override accent and neutral scales, radii, fonts, spacing, shadows,
or any CSS variable through a single
palette prop — per theme.
- Light and dark, controlled or uncontrolled. System-preference tracking and
persistent selection are built in.
- Accessible by default. Native semantics, keyboard behavior, focus management,
focus trapping, scroll-lock restoration, and
prefers-reduced-motion support.
- ESM and CJS. Dual builds, per-component subpath exports,
sideEffects metadata
for tree-shaking, and readable (unminified) published output.
- Typed. Written in TypeScript; declaration files ship with the package.
- RSC-friendly. Client components are marked
"use client"; server components can
import and render them directly.
- React 18.2 and React 19.
Table of contents
Installation
npm install @bzync/rui framer-motion
pnpm add @bzync/rui framer-motion
yarn add @bzync/rui framer-motion
react and react-dom (^18.2.0 || ^19.0.0) and framer-motion (^13.1.0) are peer
dependencies. framer-motion powers the animated overlays (Modal, Drawer, Popover,
Snackbar); it is kept off the critical path and loaded only by the components that use it.
Quick start
1. Load the styles. Pick the line that matches your app. Tailwind v4 apps need
the first one: it adds the token names your own classes use.
/* Tailwind CSS v4 app: in your CSS entry, after tailwindcss */
@import "tailwindcss";
@import "@bzync/rui/tailwind.css";
// No Tailwind in your app: import the compiled stylesheet once, near the root
import "@bzync/rui/styles.css"
Importing only styles.css into a Tailwind app looks like it works: the token classes
the library uses itself (bg-success, bg-accent-500/10) are compiled in. Any other
token class (bg-accent-50, bare border-success, dark:bg-accent-500/15) renders
nothing, with no warning. tailwind.css imports styles.css and theme.css in the
right order, so this can't happen.
2. Wrap your app in ThemeProvider.
import { Button, ThemeProvider, ThemeToggle } from "@bzync/rui"
const violet = {
50: "#f5f3ff", 100: "#ede9fe", 200: "#ddd6fe", 300: "#c4b5fd",
400: "#a78bfa", 500: "#8b5cf6", 600: "#7c3aed", 700: "#6d28d9",
800: "#5b21b6", 900: "#4c1d95", 950: "#2e1065",
} as const
export function App() {
return (
<ThemeProvider
applyToRoot
palette={{ accent: violet }}
lightPalette={{ tokens: { "--color-bg": "#fafafa", "--color-surface": "#fff" } }}
darkPalette={{ tokens: { "--color-bg": "#09090b", "--color-surface": "#18181b" } }}
>
<ThemeToggle />
<Button>Custom primary</Button>
</ThemeProvider>
)
}
Every component accepts className (and native element props where applicable), so
one-off changes compose with the defaults instead of fighting them.
The stylesheet
Import the core stylesheet once, near the application root:
import "@bzync/rui/styles.css"
It defines the semantic design tokens, the .rui-theme scope, the dark-mode variant,
and component styles. It does not download or bundle webfonts — @bzync/rui uses
system fallbacks by default to keep the stylesheet small and avoid unexpected network
requests. Load any font in your application and override --font-sans, --font-display,
and --font-mono on .rui-theme (or via ThemeProvider's fonts option).
Using the tokens in your own Tailwind classes
Required for Tailwind apps. styles.css is compiled CSS, so your app's Tailwind build
does not learn the library's token names from it. To write bg-success,
text-danger-strong, border-info/25, bg-accent-500, shadow-floating, and so on in
your own markup, import tailwind.css (styles plus token names) into your Tailwind v4
entry, as in the Quick start:
@import "tailwindcss";
@import "@bzync/rui/tailwind.css";
If you already load styles.css from JavaScript, import just the token names instead:
@import "tailwindcss";
@import "@bzync/rui/theme.css";
theme.css only registers the names (@theme reference) and emits no CSS. The values
still come from styles.css and ThemeProvider at runtime, so palette overrides apply
to your utilities too.
Theming
ThemeProvider
ThemeProvider establishes a theme scope. Place one near the root, or wrap any subtree
to give it its own theme.
import { ThemeProvider } from "@bzync/rui"
<ThemeProvider
defaultTheme="system" // "light" | "dark" | "system"
storageKey="app-theme" // localStorage key, or false to disable persistence
applyToRoot // also toggle the `dark` class on <html>
palette={{ accent, neutral, radius, fonts, spacing, shadows, colors, tokens }}
lightPalette={{ /* overrides applied only in light mode */ }}
darkPalette={{ /* overrides applied only in dark mode */ }}
>
{children}
</ThemeProvider>
Controlled usage is supported by passing theme and onThemeChange instead of
defaultTheme.
Palette tokens are emitted as a small generated stylesheet keyed on the provider's scope
(and on :root with applyToRoot), not as inline style. Every mode's values are in it,
selected by a .dark class on the scope or on any ancestor. A pre-paint script that sets
<html class="dark"> therefore gets dark tokens in the server-rendered HTML, before
hydration resolves the stored or system theme.
Setting palette.accent also points --color-primary, --color-primary-hover and
--color-focus-ring at accent 600/700/500 inside the scope. Those derived tokens have
zero specificity, so explicit colors.primary/colors.focusRing or any app rule aimed at
the scope (.rui-theme { --color-primary: … }) wins. If your app defines them on :root
instead, pass deriveAccentTokens={false} so the scope doesn't shadow them.
Palette shape (ThemePalette, all fields optional):
| Field | Type | Purpose |
|---|
accent | Partial<Record<50‥950, string>> | Primary/action color scale |
neutral | Partial<Record<50‥950, string>> | Grayscale / surfaces / borders |
colors | ThemeColors | Named semantic colors (bg, surface, foreground, border, status colors, focus ring…) |
radius | Partial<Record<"sm"‥"2xl" | "full", string>> | Corner radii |
fonts | ThemeFonts | sans, display, mono families |
spacing | Record<string, string> | Spacing scale entries |
shadows | Partial<Record<"xs"‥"2xl", string>> | Elevation shadows |
tokens | Partial<Record<\--${string}`, string | number>>` | Any CSS custom property, escape hatch |
useTheme
import { useTheme } from "@bzync/rui"
function Example() {
const { theme, resolvedTheme, setTheme, toggleTheme } = useTheme()
// theme: "light" | "dark" | "system" (the user's selection)
// resolvedTheme: "light" | "dark" (after resolving "system")
return <button onClick={toggleTheme}>Now: {resolvedTheme}</button>
}
useTheme must be called inside a ThemeProvider.
ThemeToggle
import { ThemeToggle } from "@bzync/rui"
<ThemeToggle
showLabel
lightLabel="Light"
darkLabel="Dark"
lightIcon={<SunIcon />}
darkIcon={<MoonIcon />}
/>
Renders a button with aria-pressed and an aria-label that reflects the current state.
Semantic tokens
Author custom components against the semantic tokens rather than raw palette values:
bg, surface, surface-raised, surface-muted, foreground, muted-foreground,
border, border-strong, primary, primary-foreground, destructive, the status
colors, and focus-ring. They adapt to light/dark and to any palette override
automatically.
Each status color (success, warning, danger, info) has a -strong companion,
a readable text/icon shade derived from it: darker in light mode, lighter in dark mode.
Every status-colored component (Badge, StatusDot, Alert, Callout, Tag,
Timeline, Progressbar, Stat, Text, toasts, ...) uses the same recipe, so
palette.colors.success and friends retint all of them:
| Role | Classes |
|---|
| Tinted chip | bg-success/10 text-success-strong border-success/25 |
| Solid fill (dot, bar) | bg-success |
| Text or icon | text-success-strong |
One tone vocabulary. Tone-bearing components share the names
default | success | warning | danger | info | muted (exported as the Tone type), so a
single status → tone map can feed Badge, Tag, Callout, Alert, Text and
StatusDot (which also keeps its presence states such as online). Button has a
danger variant. The older names still work as deprecated aliases: error → danger
(Badge, Tag, Callout, Alert, Progressbar, Timeline, toasts, Select option colors) and
destructive → danger (Button).
accent-strong does the same for accent text (selected items, links, eyebrows), and
surface-sunken is the recessed well behind code blocks and terminals. Components use one
mode-aware class per property rather than a light dark:dark class pair, so a consumer's
own Tailwind layer can't re-assert the light half in dark mode.
Portals
Modal (and ConfirmDialog), Drawer, Select, Autocomplete, Overlay and chart tooltips
render into document.body, so a dialog opened from a table row can't be clipped or
out-stacked by an overflow, transform or contain ancestor. Pass portalled={false}
to a Modal to render it inline instead.
They keep the nearest ThemeProvider's dark mode and palette through ThemePortal, which
wraps them in a display: contents copy of the theme scope. You don't need
applyToRoot for this. ThemePortal renders nothing on the server or during hydration,
so open and closed overlays hydrate without a mismatch; the portal appears right after.
Use ThemePortal for your own portaled overlays too:
import { ThemePortal } from "@bzync/rui"
<ThemePortal>
<div className="fixed inset-0 z-(--z-modal) bg-surface">...</div>
</ThemePortal>
Light-only subtrees
Add the rui-light class to keep a subtree on the light tokens while the rest of the
scope is dark — for content whose colors must match what gets exported or printed.
ThemeProvider re-applies your lightPalette inside it. Signature uses it so dark ink
stays legible on its signing pad.
<div className="rui-light bg-surface text-foreground">Always light</div>
Stacking order
Every layer the library renders uses one z-index scale, defined as CSS variables in
styles.css:
| Token | Default | Used by |
|---|
--z-sticky | 1100 | AppShellHeader, sticky Footer, SkeletonTopbar |
--z-modal | 1300 | Modal, Drawer, CommandPalette, Overlay |
--z-popover | 1400 | Select, Autocomplete, Popover, DropdownMenu, DatePicker, TimePicker |
--z-toast | 1500 | SnackbarProvider toasts |
--z-tooltip | 1600 | Tooltip, chart tooltips, focused skip links |
Floating panels sit above dialogs so a Select inside a Modal or Drawer is never
painted over, toasts stay visible over an open dialog, and tooltips are always on top.
Overlays on the same layer stack by DOM order, so a Drawer opened from a Modal
covers it. To fit the library into your app's own scale, override the variables on
:root. For your own overlays, use the Z_INDEX constant
(style={{ zIndex: Z_INDEX.modal }}) or z-(--z-modal) in Tailwind.
Importing components
The root entry re-exports everything for convenience:
import { Button, Modal, DataTable } from "@bzync/rui"
Per-component subpath entries let a bundler pull in only what an application uses:
import { Button } from "@bzync/rui/button"
import { Modal } from "@bzync/rui/modal"
import { BarChart } from "@bzync/rui/charts"
Both forms are tree-shakeable — the package sets "sideEffects": ["**/*.css"] and ships
ESM — but subpath imports keep dependency graphs smallest and are the recommended default
for libraries and performance-sensitive apps.
Server components and SSR
Client components are marked with the "use client" directive, so React Server
Components and frameworks like Next.js App Router can import and render them directly.
ThemeProvider reads localStorage only on the client and resolves "system" with
matchMedia; render it in a client boundary and pass a defaultTheme so the server and
first client paint agree.
Accessibility and motion
- Interactive components expose native element semantics and keyboard behavior.
- Modal dialogs label their content, trap keyboard focus, close on Escape,
restore focus to the previously focused element on close, and preserve the page's
prior scroll-lock state.
- Menus, listboxes, tabs, and toggles implement roving focus and arrow-key navigation.
- Non-essential animation inside the
.rui-theme scope is disabled when the user has
prefers-reduced-motion: reduce set.
- Icon-only controls (
IconButton, InfoButton, CopyButton) require an accessible
label.
- Composite skeletons (
SkeletonCard, SkeletonTable, SkeletonList, SkeletonStat)
are busy role="status" regions with a visually hidden "Loading…" label. To announce
a whole loading layout once, wrap it in <SkeletonRegion label="Loading invoices"> and
pass label={false} to the skeletons inside.
- Animated components use framer-motion's
m elements with the domAnimation feature
set rather than the full motion component, so apps don't bundle drag, pan or layout
animation code they never use.
Component catalog
Import any of these from the root or from @bzync/rui/<kebab-name>.
Providers and theming
| Component / export | Notes |
|---|
ThemeProvider | Token-driven theme scope; light/dark, controlled or uncontrolled, persistence, applyToRoot |
useTheme() | { theme, resolvedTheme, setTheme, toggleTheme } |
ThemeToggle | Accessible light/dark switch with custom icons and labels |
ThemeSwitcher | variant: segmented | dropdown | switch | radio | icon-button, size, includeSystem, showLabels, value / onChange |
SnackbarProvider / useSnackbar() | Programmatic toast notifications |
CommandProvider / CommandPalette | App-wide searchable command palette |
cn() | clsx + tailwind-merge class combiner |
Actions
| Component | Key props |
|---|
Button | variant: primary | secondary | ghost | outline | danger | link (destructive is a deprecated alias), size: sm | md | lg | icon, loading, icon, iconPosition |
ButtonGroup | orientation, aria-label; wraps Button children with role="group" |
IconButton / InfoButton | label (required for a11y), icon |
CopyButton | value (text to copy), label, timeout |
Toggle / ToggleGroup / ToggleGroupItem | pressed / defaultPressed, onPressedChange, type: single | multiple, variant, size, orientation, loop |
BillingIntervalToggle | value: monthly | yearly, onChange |
InlineEdit | value / defaultValue, onSave, onCancel, validate, multiline, saveOnBlur |
Forms
| Component | Key props |
|---|
Input | label, hint, error, prefix / suffix, size |
Textarea | label, hint, error, rows |
PasswordInput | Input props plus showStrength, requirements, visible / defaultVisible, onVisibleChange |
SearchInput | onSearch, debounceMs, shortcut, loading, clearLabel |
MaskedInput / CurrencyInput / PhoneInput | mask; currency, locale, decimals, allowNegative; country, countries, onCountryChange |
TagInput | value / defaultValue, onChange, separators, validate, transform, maxTags, allowDuplicates |
NumberInput | value / defaultValue, min, max, step, onChange |
OtpInput | length, value, onChange, label |
Select | options, label, multiple, groups, searchable, onChange |
Autocomplete | options, multiple, custom filter, single/multi triggers |
Checkbox | label, checked / defaultChecked, onCheckedChange |
| / |
Display
| Component | Key props |
|---|
Badge / Tag | variant, dot; Tag adds onRemove |
Icon / Icons | name, size: xs‥xl | number, tone, spin, label; or Icons.<Name> components |
Image | src, alt, aspectRatio, fit, placeholder, blurDataURL, fallbackSrc, rounded |
Banner | tone, variant, title, action, dismissible, storageKey, sticky |
Avatar / AvatarGroup / AvatarGroupOverflow | name, src, size, initials fallback; group spacing, count |
Card (CardHeader / CardTitle / CardDescription / CardBody / CardFooter) | composition |
Callout / Alert | title, variant; Alert adds dismissable, onDismiss |
Stat / StatusDot | Stat: label, value, trend, trendValue; StatusDot: status, label, variants, dotClassName, labelClassName |
Tooltip | content, trigger child, maxWidth (sizes to content, wraps at the cap; default 20rem) |
Link | href, variant |
|
Overlays and feedback
| Component | Key props |
|---|
Modal (ModalHeader / ModalTitle / ModalDescription / ModalBody / ModalFooter) | open, onClose, title, size: sm‥7xl | full, ModalBody scrollable |
Drawer (DrawerHeader / DrawerBody / DrawerFooter) | open, onClose, title, size, focus trap |
Sheet | open / onOpenChange, snapPoints, defaultSnap, onSnapChange, modal, dismissible |
ResponsiveDialog (ResponsiveDialogTrigger / ResponsiveDialogContent / ResponsiveDialogHeader / ResponsiveDialogTitle / ResponsiveDialogDescription / ResponsiveDialogBody / ResponsiveDialogFooter / ResponsiveDialogClose) | modal on desktop, bottom sheet on mobile; open / defaultOpen, onOpenChange |
Overlay / OverlayBackdrop | low-level primitive: open, onClose, portal, closeOnEscape, closeOnOutsideClick, disableScrollLock |
ConfirmDialog | open, onConfirm, onCancel, title |
Popover / PopoverContent | trigger, open / onOpenChange |
HoverCard | content, side, align, openDelay, closeDelay, open / onOpenChange |
DropdownMenu | trigger, items: { label, onClick }[] |
Navigation and layout
| Component | Key props |
|---|
AppShell (AppShellHeader / AppShellBody / AppShellMain / Footer) | app frame; sticky, scrollable, fixed |
Container | size: sm‥xl | full, gutter |
Stack / Inline / PageHeader | spacing and header primitives |
Navbar / Sidebar / Topbar / BottomBar (+ TopbarTitle / BrandLink) | items: NavigationItem[], activeId, onSelect |
NavigationMenu | items, openDelay, closeDelay |
Resizable / ResizablePanel | direction, defaultSizes / sizes, onResize, minSizes / maxSizes, storageKey, withHandle |
NavigationLink | id, label, href, icon, badge, active, compact |
Breadcrumb | items |
SEO (+ SeoImage / StructuredData / BreadcrumbJsonLd / ArticleJsonLd) | document head tags and JSON-LD |
Slot | asChild composition primitive |
Data
| Component | Key props |
|---|
DataTable<T> | columns: ColumnDef<T>[] ({ key, header, cell, sortable?, searchable?, align?, width? }), data: T[], searchable, searchPlaceholder, pageSizeOptions, density, loading, emptyMessage, onRowClick, unstyled — built-in sort, search, and pagination. T must have an id. |
SortableList / Kanban | items + onReorder; columns + onChange; renderItem / renderCard, getLabel; keyboard and pointer reordering |
DndContext / Droppable / Draggable / DragOverlay / DragHandle | low-level drag-and-drop primitives with keyboard support |
PanoViewer / PanoTourDialog / PanoTourButton / PanoTourEditor | 360° panorama tours with hotspots and narration, plus an editor |
Common recipes
Button
import { Button } from "@bzync/rui/button"
<Button variant="primary" size="lg" loading={isSaving} onClick={save}>
Save changes
</Button>
<Button variant="outline" icon={<PlusIcon />} iconPosition="left">
New item
</Button>
A labelled, validated field
import { FormField } from "@bzync/rui/form-field"
import { Input } from "@bzync/rui/input"
<FormField label="Email" htmlFor="email" required error={errors.email}>
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
</FormField>
Select
import { Select } from "@bzync/rui/select"
<Select
label="Environment"
options={[
{ label: "Production", value: "prod" },
{ label: "Staging", value: "staging" },
{ label: "Development", value: "dev" },
]}
value={env}
onChange={setEnv}
/>
Inside a <form>, give it a name (and form for a form elsewhere on the page) to submit
the selection natively, one name=value entry per selected value, as with Input.
required is enforced by native validation, and an uncontrolled Select returns to its
defaultValue when the form resets:
<form action={createServer}>
<Select name="region" required label="Region" options={regions} />
<Select name="tags" multiple label="Tags" options={tags} />
</form>
Autocomplete works the same way: name submits the selected option value(s), not the
typed text, and required requires a selection. Editing the text away from the chosen
option's label clears an uncontrolled selection, so a stale value is never submitted.
Modal
import { Modal, ModalBody, ModalFooter } from "@bzync/rui/modal"
import { Button } from "@bzync/rui/button"
const [open, setOpen] = useState(false)
<Modal open={open} onClose={() => setOpen(false)} title="Delete project" size="sm">
<ModalBody>This action cannot be undone.</ModalBody>
<ModalFooter>
<Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button variant="danger" onClick={confirmDelete}>Delete</Button>
</ModalFooter>
</Modal>
Toasts
import { SnackbarProvider, useSnackbar } from "@bzync/rui/snackbar"
function Root() {
return (
<SnackbarProvider>
<App />
</SnackbarProvider>
)
}
function SaveButton() {
const { show } = useSnackbar()
return <Button onClick={() => show({ title: "Saved", variant: "success" })}>Save</Button>
}
useSnackbar() returns { show, dismiss, dismissAll }. show(opts) returns the toast id;
pass a stable id in opts to replace an existing toast in place instead of stacking.
DataTable
import { DataTable } from "@bzync/rui/datatable"
type Row = { id: string; name: string; role: string; seats: number }
<DataTable<Row>
data={members}
columns={[
{ key: "name", header: "Name", cell: (r) => r.name, sortable: true, searchable: true },
{ key: "role", header: "Role", cell: (r) => r.role, sortable: true },
{ key: "seats", header: "Seats", cell: (r) => r.seats, align: "right", sortable: true },
]}
searchable
pageSizeOptions={[10, 25, 50]}
onRowClick={(row) => open(row.id)}
/>
Charts
The chart set is a separate subpath entry so it stays out of the main graph unless used.
Every chart renders plain SVG and takes data arrays — no canvas, no chart engine.
import { BarChart, LineChart, DonutChart } from "@bzync/rui/charts"
<BarChart data={[{ label: "Jan", value: 42 }, { label: "Feb", value: 55 }]} />
Available: BarChart, StackedBarChart, LineChart, MultiLineChart, AreaChart,
DonutChart, ScatterChart, GanttChart, HeatmapChart, RadarChart, FunnelChart,
WaterfallChart, Sparkline, Gauge. See
src/components/charts for exact per-chart props.
Hooks and utilities
Lifecycle-correct hooks (mount/update/unmount with cleanup), re-exported from the root:
| Hook | Purpose |
|---|
useIsMounted() | Guard async setState after unmount |
useIsomorphicLayoutEffect() | useLayoutEffect on the client, useEffect on the server |
usePrevious(value) | Previous render's value |
useUpdateEffect(fn, deps) | Effect that skips the first render |
useEventCallback(fn) | Stable callback identity with fresh closure |
useControllableState(opts) | Controlled/uncontrolled state pattern |
useMediaQuery(query) | Subscribe to a media query |
useAbortSignal() | Abort in-flight work on unmount |
useFocusTrap(ref, active) | Trap focus within a container |
useOutsideClick(ref, handler) | Detect clicks outside an element |
Utilities: cn(), focus helpers, Portal, and assertion helpers from @bzync/rui/utils;
ErrorBoundary from the root; createSafeEffect and mount helpers from @bzync/rui's
lifecycle exports; KEY, DURATIONS, and FOCUSABLE_SELECTOR constants.
TypeScript
@bzync/rui is written in TypeScript and ships .d.ts files for the root and every
subpath entry. Prop types, variant unions (ButtonVariant, ButtonSize, …), and the
theming types (Theme, ThemePalette, ThemeColors, ColorShade, …) are all exported.
No @types/* package is required.
Browser and React support
- React
^18.2.0 || ^19.0.0
- Modern evergreen browsers. The library relies on CSS custom properties,
matchMedia,
and standard DOM APIs; no polyfills are bundled.
- SSR / RSC via the
"use client" boundary described above.
Bundle and dependencies
dependencies: none. clsx/tailwind-merge logic is inlined; icons are inlined SVG.
- Published output is not minified — readable ESM and CJS ship to the registry so the
code is auditable; your bundler minifies the final app build.
sideEffects is limited to **/*.css, so unused components are dropped by any
tree-shaking bundler.
Versioning
@bzync/rui follows semantic versioning. While the major version
is 0, minor releases may contain breaking changes; pin a version or a tight range and
review the release notes before upgrading. Releases are published from CI with npm
provenance attestations.
Documentation
Full component documentation and live, themeable demos are published at
rui.bzync.com. A push to main deploys the
latest docs via GitHub Pages.
Contributing
Bug reports, accessibility fixes, documentation improvements, and focused component
contributions are welcome. See CONTRIBUTING.md for the development
setup, the verification gate, and the release process, and
CODE_OF_CONDUCT.md.
Security
Report vulnerabilities privately using the process in SECURITY.md.
Please do not open public issues for security reports.
License
ISC © 2026 Bzync
Maintainer
@bzync/rui is maintained by Rayan Reynaldo, Founder of Bzync
(www.bzync.com).
If @bzync/rui is useful to you, you can support its continued development on
Buy Me a Coffee.