Composable React UI components with scoped, fully customizable light and dark themes.
@bzync/rui (react ui) is the component library behind Bzync.
It ships 79 components plus a small SVG chart set, a token-driven theming system, and
production-grade lifecycle primitives — with zero runtime dependencies.
- Zero runtime dependencies.
dependenciesis empty.react,react-dom, andframer-motionare peer dependencies you already control. - Scoped theming.
ThemeProviderrenders a.rui-themescope, 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
paletteprop — 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-motionsupport. - ESM and CJS. Dual builds, per-component subpath exports,
sideEffectsmetadata 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.
- Installation
- Quick start
- The stylesheet
- Theming
- Importing components
- Server components and SSR
- Accessibility and motion
- Component catalog
- Common recipes
- Charts
- Hooks and utilities
- TypeScript
- Browser and React support
- Versioning
- Documentation
- Contributing
- Security
- License
npm install @bzync/rui framer-motionpnpm add @bzync/rui framer-motionyarn add @bzync/rui framer-motionreact 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.
import { Button, ThemeProvider, ThemeToggle } from "@bzync/rui"
import "@bzync/rui/styles.css"
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.
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).
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, also import the
Tailwind theme file into your Tailwind v4 entry:
@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.
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 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 |
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.
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.
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, 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 |
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.
Drawer, Select, Autocomplete, Overlay and chart tooltips render into document.body.
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. 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>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.
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.
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.
- 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-themescope is disabled when the user hasprefers-reduced-motion: reduceset. - Icon-only controls (
IconButton,InfoButton,CopyButton) require an accessiblelabel.
Import any of these from the root or from @bzync/rui/<kebab-name>.
| 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 |
SnackbarProvider / useSnackbar() |
Programmatic toast notifications |
CommandProvider / CommandPalette |
App-wide searchable command palette |
cn() |
clsx + tailwind-merge class combiner |
| Component | Key props |
|---|---|
Button |
variant: primary | secondary | ghost | outline | destructive | link, 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 |
| Component | Key props |
|---|---|
Input |
label, hint, error, prefix / suffix, size |
Textarea |
label, hint, error, rows |
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 |
Radio / RadioGroup |
value, onChange, options |
Switch |
label, checked, onCheckedChange |
Slider |
value / defaultValue, min, max, step, label |
Rating |
value / defaultValue, max, size, readOnly, onValueChange |
DatePicker |
label, value, onChange |
Calendar |
value: Date, onChange, month/week views |
TimePicker |
value / defaultValue, format: 12 | 24, minuteStep, showSeconds, min / max, clearable |
FileUpload |
label, accept, multiple, onFilesChange |
Label |
htmlFor, required, hint |
FormField |
label, htmlFor, required, hint, error — wraps a control |
Stepper |
steps, activeStep |
Kbd |
keyboard-key children |
| Component | Key props |
|---|---|
Badge / Tag |
variant, dot; Tag adds onRemove |
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 |
Typography: Heading / Text / Prose / Time |
Heading: as h1‥h6, size, tone, weight, balance; Text: variant, size, number/date/currency formatting; Time: renders <time datetime> |
Code / InlineCode / CodeBlock / CodeEditor |
code, filename, showLineNumbers, value / onChange |
Blockquote |
variant, size, cite, source / sourceHref |
Currency |
value: number | bigint, currency, locale, accounting, tone, size |
DescriptionList (DescriptionItem / DescriptionTerm / DescriptionDetails) |
columns: 1 | 2 | 3, density, orientation |
List / ListItem, Timeline, Tree |
item collections; Tree is expandable |
Table (TableHeader / TableHead / TableBody / TableRow / TableCell) |
styled primitive table |
Divider / Separator |
orientation, variant, spacing, optional label |
ScrollArea |
orientation: vertical | horizontal | both, hideScrollbar, keyboardNavigable |
AspectRatio |
ratio: number |
Skeleton (+ SkeletonAvatar / SkeletonCard / SkeletonTable / SkeletonText / SkeletonTopbar) |
loading placeholders |
Spinner / Progressbar |
size; value, max |
EmptyState / ErrorState |
title, description, error, action slot |
RichText / RichTextEditor |
rendered content and editor |
AuthBackdrop |
decorative auth-screen background |
| Component | Key props |
|---|---|
Modal (ModalHeader / ModalTitle / ModalDescription / ModalBody / ModalFooter) |
open, onClose, title, size: sm‥7xl | full, ModalBody scrollable |
Drawer |
open, onClose, title, size, focus trap |
ConfirmDialog |
open, onConfirm, onCancel, title |
Popover / PopoverContent |
trigger, open / onOpenChange |
DropdownMenu |
trigger, items: { label, onClick }[] |
Command / CommandPalette / CommandProvider |
searchable command palette |
SnackbarProvider / useSnackbar() |
queue and dismiss toasts programmatically |
Tabs (TabsList / TabsTrigger / TabsContent) |
value, onValueChange |
Accordion |
items: { id, trigger, content }[] |
Pagination |
page, totalPages, onPageChange |
Terminal / TerminalBlock / TerminalEmulator |
virtual filesystem, runnable shell commands |
| 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 |
items: NavigationItem[], activeId, onSelect |
NavigationLink |
id, label, href, icon, badge, active, compact |
Breadcrumb |
items |
SEO |
document head tags |
| 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. |
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>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>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}
/>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="destructive" onClick={confirmDelete}>Delete</Button>
</ModalFooter>
</Modal>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.
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)}
/>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, LineChart, MultiLineChart, DonutChart, ScatterChart,
GanttChart, HeatmapChart, RadarChart, FunnelChart, WaterfallChart. See
src/components/charts for exact per-chart props.
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.
@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.
- 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.
dependencies: none.clsx/tailwind-mergelogic 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.
sideEffectsis limited to**/*.css, so unused components are dropped by any tree-shaking bundler.
@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.
Full component documentation and live, themeable demos are published at
bzync.github.io/rui. A push to main deploys the
latest docs via GitHub Pages.
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.
Report vulnerabilities privately using the process in SECURITY.md. Please do not open public issues for security reports.
ISC © 2026 Bzync
@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.