@batthewz/response-ui-react-components
v0.8.2
Published
React component library for the response-ui design system. Pairs with @batthewz/response-ui-css.
Maintainers
Readme
@batthewz/response-ui-react-components
~80 accessibility-first React 19 components you re-skin from ~1 page of CSS — without touching a single component.
Every visual decision — colour, spacing, type, radii, shadows, motion — comes from a framework-agnostic CSS token contract, not from the components. Flip one attribute and the whole app re-skins, at runtime, with no rebuild:
// The whole idea: same components, different theme, zero component edits.
<html data-theme="grimdark">
{" "}
{/* try: default · events · tech · grimdark */}
<Button variant="primary">Continue</Button>
</html>See it live: ai-website-starter.benmatthews-it.workers.dev/demo — every component, every theme, every responsive scale, in one place. The theme switcher is the whole pitch: same components, one-file theme swaps, live.
Also: zero CSS-in-JS, router-agnostic, headless where it counts, RSC-friendly.
Why this over another React component library?
The headline reason is reskinnability. In most libraries the look is welded to the components — a large JS theme object (MUI, Chakra), per-component source you fork and own forever (shadcn/ui), or a styled-components runtime. Re-skinning means editing components, wrangling a theme config, or a rebuild. Here the look lives outside the components, in a framework-agnostic CSS token contract — and everything below follows from that one decision:
- Re-skin the entire library from ~1 page of CSS. Override ~30 custom properties — colours, spacing, fonts, radii, shadows, motion timing — and every component re-tunes at once. Flip
data-themeand the whole app changes at runtime: no rebuild, no JS theme object, no component edits. Four themes (default,events,grimdark,tech) ship as proof. - The same theme re-skins more than React. Because the design language is pure CSS, one theme file restyles your React components and your Astro / Rails / Phoenix / plain-HTML pages alike. The brand is a single source of truth across your whole stack, not duplicated per framework.
- Responsive tokens, not breakpoint soup.
text-h2,gap-r3,p-r4each carry both breakpoints — and headings/body carry their paired line-height and weight step-ups too. You stop hand-writingsm:variants andleading-*. - Zero CSS-in-JS, zero runtime styling cost. Styling is co-located plain CSS that self-registers with Tailwind v4. Nothing computes styles at render time; presentational primitives carry no
"use client"and stay server-renderable (RSC-friendly out of the box). - Headless where it matters. Router-agnostic links, auth gating that takes a status string, and a Standard-Schema form layer with no validator lock-in — behaviour is decoupled from whichever router / auth / validation library you happen to use.
- Breadth and correctness. ~80 components — including DataTable (three wiring modes), VirtualizedDataTable, CommandPalette, Wizard — with accessibility and a contrast contract baked in, not bolted on.
- Lighter on AI tokens. Terse token syntax plus a shipped
AGENTS.mdmean agents read and generate far less to style a screen.
Want only the design language, no React? It ships standalone as @batthewz/response-ui-css — pure CSS, zero JS, usable from any framework.
Is this for you? Best fit: you want one brand applied consistently across an app — and ideally across non-React stacks too — expressed in tokens (
p-r3,bg-surface-1,text-h2); If ever you think your project (or sections of it) will need to be reskinned/rethemed/rebranded at any point; If your product services many clients or stakeholders who want their own flexible styling.
Quick start
bun add @batthewz/response-ui-react-components @batthewz/response-ui-css \
react react-dom @floating-ui/react lucide-react
bun add -D tailwindcss @tailwindcss/viteThen two CSS imports in your app's CSS entry — foundation (tokens, themes, responsive scales, animations) first, then per-component styles:
/* src/app.css */
@import "@batthewz/response-ui-css";
@import "@batthewz/response-ui-react-components/styles";Order matters: each per-component file reads var(--…) tokens defined by response-ui-css, so the foundation has to load first.
The styles import also registers this package's sources with Tailwind v4 (a self-relative @source), so the utility classes used inside the components are generated automatically — no manual @source workaround needed, regardless of package manager or node_modules layout (hoisted npm, bun's isolated store, pnpm).
import { Button, Card, Stack } from "@batthewz/response-ui-react-components";
export function Hello() {
return (
<Card>
<Stack gap="r3">
<h2 className="text-h3">Hello</h2>
<Button variant="primary">Continue</Button>
</Stack>
</Card>
);
}That renders a themed Card. Now set data-theme on your <html> (or any ancestor element) and everything inside re-skins at once — no other change. That's the whole model; full theming below.
Theming & reskinning the whole library
Every component renders with var(--…) tokens defined by response-ui-css — none of them hard-code a colour, size, or font. So reskinning is editing the foundation, never the components. A theme is just one CSS file overriding the documented custom properties under a data-theme selector.
You don't need the useTheme hook — or any library JS — to apply a theme. Switching is just setting a data-theme attribute, so the simplest path is to set it declaratively on the root element:
// a Next.js root layout, your index.html, your top-level App — wherever <html> lives
<html data-theme="grimdark">Reach for useTheme only when you want a theme switcher: it adds reactive state, localStorage persistence, and SSR-safe hydration on top of that same attribute.
import { useTheme } from "@batthewz/response-ui-react-components";
const { theme, setTheme, themes } = useTheme();
setTheme("grimdark"); // also: "events", "tech", "default"It's pure convenience over the attribute — document.documentElement.setAttribute("data-theme", "grimdark") does the same thing.
Scope a theme to a subtree. The built-in themes target
<html>via a:root[data-theme="…"]selector. Author your own theme with a bare[data-theme="aurora"]selector instead, and you can dropdata-theme="aurora"on any element — a single panel, a dark island in a light page — and only that subtree re-skins, because the tokens cascade to its descendants.
Write your own theme: copy the template, override the contract in ~1 page of CSS, @import it after the foundation, then register its name with the hook:
/* src/app.css */
@import "@batthewz/response-ui-css";
@import "./themes/aurora.css"; /* your ~1 page of token overrides */
@import "@batthewz/response-ui-react-components/styles";const { setTheme } = useTheme({ themes: ["default", "aurora"] as const });
setTheme("aurora");The full reskinning surface lives in the foundation package. These are the canonical, live docs on GitHub:
- Theme contract — the authoritative list of every overridable token (colours, spacing, type, radii, shadows, motion) and the contrast contract themes must honour.
- Extending the foundation — add your own tokens, responsive/theme-aware values, and register sources with Tailwind.
- Theme template — a blank theme to copy as your starting point.
response-ui-cssREADME — the foundation in full: responsive scales, the four built-in themes, subpath exports, and how the token system fits together.
What ships
- UI (50): Accordion, Alert, AppShell, Avatar (+AvatarGroup), AvatarUpload, Badge, Breadcrumbs, Button, Calendar, RangeCalendar, Card, Carousel, CodeBlock, Collapsible, CommandPalette, ContextMenu, CopyButton, DataTable, Dialog, Drawer, DropdownMenu, EmptyState, ErrorBoundary, FileUpload, Hero, HoverCard, IconButton, Kbd, MasonryGrid, MediaCard, Pagination, Popover, Portal, ProgressBar, Rating, Skeleton, Spinner, Spotlight, StatCard, Stepper, Swimlane, Table, Tabs, Text, ThemeSwitcher, Timeline, Toast (+ToastProvider/useToast), Tooltip, VirtualizedDataTable, Wizard (+
useWizard) - Form (22): Checkbox, ColorPicker, Combobox, DatePicker, DateRangePicker, Field, FieldError, FormActions, Input, Label, MultiSelect, NumberInput, OTPInput, Radio, RangeSlider, Repeater, SearchInput, Select, Slider, Switch, TagInput, Textarea
- Form orchestration (headless):
useForm,FormProvider,useFormContext,useFieldState,useFormState,useFieldArray— Standard Schema validation, a unifiedfield()accessor,useSyncExternalStore-backed reactivity - Data display (5): Sparkline, ProgressRing, Meter, DescriptionList, ActivityFeed
- Layout (6): Center, Container, Divider, Row, Spacer, Stack
- Animation (5): AnimatePresence, Parallax, ScrollReveal, Stagger, ViewTransition (+
useViewTransition) - Guards (1): RequireAuth (headless)
- Router (1): RouterAdapterProvider, useLink, usePathname
- Hooks: useActiveSection, useClickOutside, useControllableState, useDebounce, useDocumentTitle, useFloating, useFocusTrap, useMediaQuery, usePrefersReducedMotion, useRovingFocus, useTheme, useVirtualRows
- Util:
cn,createCn,mergeExtension,tailwindMergeExtension,twMerge,mergeRefs,formatBytes, plus date helpers (formatDate,parseDateInput,buildMonthGrid,addDays,addMonths, …)
Router adapter — wire your router once
Components like AppShell.SidebarLink and Breadcrumbs.Item render navigational links. Wrap your app once at the root with the adapter:
import {
RouterAdapterProvider,
type RouterLinkComponent,
type RouterLinkProps,
} from "@batthewz/response-ui-react-components";
import { forwardRef } from "react";
import { BrowserRouter, Link as RRLink, useLocation } from "react-router-dom";
const AdapterLink: RouterLinkComponent = forwardRef<
HTMLAnchorElement,
RouterLinkProps
>(function AdapterLink({ to, replace, children, ...rest }, ref) {
return (
<RRLink ref={ref} to={to} replace={replace} {...rest}>
{children}
</RRLink>
);
});
const adapter = {
Link: AdapterLink,
usePathname: () => useLocation().pathname,
};
export function App() {
return (
<BrowserRouter>
<RouterAdapterProvider value={adapter}>
{/* your routes */}
</RouterAdapterProvider>
</BrowserRouter>
);
}If you skip the provider, links fall back to plain <a href> — fine for static / non-SPA.
Auth gating — headless RequireAuth
The package ships a router/auth-agnostic RequireAuth that takes a status string and renders accordingly:
import { RequireAuth } from "@batthewz/response-ui-react-components";
import { Navigate } from "react-router-dom";
import { useSession } from "your-auth-library";
export function AuthGuard({ children }) {
const { data: session, isPending } = useSession();
const status = isPending
? "loading"
: session
? "authenticated"
: "unauthenticated";
return (
<RequireAuth
status={status}
unauthenticatedFallback={<Navigate to="/login" replace />}
>
{children}
</RequireAuth>
);
}Forms — headless useForm
A store-backed, dependency-free form layer for the form controls. Validation is via Standard Schema, so bring any conforming validator (Zod, Valibot, ArkType, …) — no runtime dependency is added. A single field(name) accessor binds both native inputs and the library's controlled components (Combobox, TagInput, Slider, Select, …) — no register-vs-Controller split.
import {
FormProvider,
Field,
FieldError,
Input,
Label,
useForm,
} from "@batthewz/response-ui-react-components";
import { z } from "zod";
const schema = z.object({
email: z.string().email("Enter a valid email"),
password: z.string().min(8, "At least 8 characters"),
});
export function SignIn() {
const form = useForm({
defaultValues: { email: "", password: "" },
schema,
mode: "onBlur", // onSubmit | onBlur | onChange | onTouched | all
onSubmit: async (values, { setError }) => {
const res = await api.signIn(values);
if (!res.ok) setError("password", "Wrong email or password"); // server error
},
});
return (
<FormProvider form={form}>
<form {...form.props}>
<Field name="email">
<Label>Email</Label>
<Input type="email" {...form.field("email")} />
<FieldError />
</Field>
<Field name="password">
<Label>Password</Label>
<Input type="password" {...form.field("password")} />
<FieldError />
</Field>
<Button type="submit">Sign in</Button>
</form>
</FormProvider>
);
}<Field name="x"> auto-wires that field's error into context; <FieldError /> with no children renders it (with role="alert" + aria-describedby), and the bound input reflects aria-invalid. Manual/server errors set via setError always win and survive a validation pass; schema errors surface only once a field is touched/dirty or the form has been submitted — so errors never flash at a field the user hasn't reached.
For non-string values, annotate the bind: form.field<string[]>("tags"). checked-based controls (Checkbox, Switch) are wired via watch/setValue instead of field():
<Switch
checked={Boolean(form.watch("subscribe"))}
onChange={(v) => form.setValue("subscribe", v)}
/>useFieldArray drives dynamic lists with stable keys (id survives reorders):
const { fields, append, remove } = useFieldArray({ form, name: "items" });
fields.map((item) => (
<Field key={item.id} name={`${item.name}.label`}>
<Input {...form.field(`${item.name}.label`)} />
</Field>
));The component calling useForm re-renders on any change. For render isolation, useFieldState(form, name) and useFormState(form) subscribe to a single field slice / form-level flags only. Other knobs: reValidateMode, criteriaMode, trigger, reset/resetField, focusFirstError, and a reactive external values prop that re-seeds the form when its identity changes.
Adding custom Tailwind tokens
If you add custom design tokens (e.g. bg-brand-foo), build a project-local cn with createCn so it merges both the built-in tokens and yours:
// app/cn.ts
import { createCn } from "@batthewz/response-ui-react-components";
export const cn = createCn({
theme: {
color: ["brand-foo", "brand-accent"],
spacing: ["xtra-tight"],
},
});Then import cn from app/cn everywhere instead of from the package directly. createCn concatenates your arrays onto the built-ins, so customising one key (e.g. color) can't accidentally wipe awareness of the others (spacing, text) — the way a manual spread could. For power users, mergeExtension and the raw frozen tailwindMergeExtension are also exported. See docs/extending.md.
Building your own components
Want to use this library as a base — keep the components you like and add your own that
share the design tokens (charts, dashboards, domain widgets)? Because you style new
components with the same tokens, they inherit every theme for free: a component built on
bg-surface-1 / text-fg-primary / p-r3 re-skins alongside the built-ins when you flip
data-theme, with no extra wiring. See docs/extending.md for the
build-on-top model, the token-compliant component pattern, custom tokens, and the shipped
dashboard vocabulary (bg-chart-*, text-trend-*, Sparkline, Meter, …).
The conventions that keep extensions on-theme and consistent are machine-encoded in
AGENTS.md — token-only styling (never raw p-4 / bg-gray-100), cn()
composition, forwardRef + semantic HTML, the contrast contract, and a "don'ts" list.
Point your AI assistant at it and generated components follow the same patterns the library
applies to itself.
DataTable wiring modes
DataTable supports three wiring modes — pick one and stick to it:
- Client-everything — pass
pageSize(and optionallydefaultSort). The table sorts, slices, and derives pages entirely on the client from the fulldataarray. NoonSortChange/onPageChangeneeded. - Server-controlled — pass
sort+onSortChangeandpage+totalPages+onPageChange, and omitpageSize. The table renders exactly the rows you give it and reports sort/page intent back to you; you do the sorting and paging server-side. - Hybrid / server-paged (lazy-load) — never enable uncontrolled sorting here; use controlled
sort. Accumulate fetched rows into thedataarray as the user pages, and render a footer sentinel (via the footer slot) to trigger the next load.
Expandable rows
Pass renderExpanded to give every row a leading expander toggle that reveals a full-width detail panel beneath it (accordion-animated, respects prefers-reduced-motion). Expansion is uncontrolled by default; pass expandedKeys + onExpandedChange to control it. Composes with selectable — the detail cell spans the expander, selection, and data columns.
Large datasets: VirtualizedDataTable
For tens of thousands of rows, reach for VirtualizedDataTable instead of paginating. It shares DataTable's ColumnDef and sorting contract but windows the rows (via the useVirtualRows hook) so only a small visible slice is mounted in the DOM — scrolling stays smooth and memory flat. Pass a fixed rowHeight (cell content must fit it — truncate overflow) and a height for the scroll viewport; the sticky header is on by default. Differences from DataTable: there's no pagination, select-all toggles the entire dataset, and an optional onEndReached callback supports infinite loading (accumulate into data, keep sort controlled when the server sorts).
<VirtualizedDataTable
data={tenThousandRows}
columns={columns}
rowKey={(r) => r.id}
rowHeight={44}
height={480}
/>RSC / Server Components
Interactive modules ship a "use client" directive, so the components work out of the box in React Server Component frameworks (Next.js App Router, etc.). Pure presentational components (Button, Text, the layout primitives) carry no directive and stay server-renderable — you can use them directly in server components.
Security: these are presentational Client Components — as with any client component, never pass server-only secrets as props (props are serialized to the browser). The components themselves access no server state or secrets (enforced by bun run verify:directives).
Subpath imports for tree-shaking
Deep imports are supported, so you can pull in a single component or hook without going through the barrel:
import { Button } from "@batthewz/response-ui-react-components/components/ui/Button";
import { useDebounce } from "@batthewz/response-ui-react-components/hooks/use-debounce";Importing from the root barrel works too — both resolve to the same tree-shakeable modules.
License
MIT.
