@quanticjs/react-ui
v9.1.1
Published
UI component library for QuanticJS — Spinner, Toast, Dialog, Skeleton, DataTable, ErrorBoundary, EmptyState
Readme
@quanticjs/react-ui
Cross-cutting UI primitives for QuanticJS apps. Built with Tailwind CSS.
Install
pnpm add @quanticjs/react-uiPeer dependencies: react, react-dom.
Requires @quanticjs/tailwind-preset >= 8 — components render with the preset's v8 token utilities (shadow-surface|raised|overlay, z-(--z-*), animate-*); on an older preset those compile to nothing. See docs/MIGRATION-8.md.
Components
Button
Canonical button. Defaults to type="button" (pass type="submit" explicitly in forms).
import { Button } from '@quanticjs/react-ui';
<Button onClick={save}>Save</Button>
<Button variant="outline" size="sm">Cancel</Button>
<Button variant="destructive">Delete</Button>
<Button variant="ghost" size="icon" aria-label="Settings"><GearIcon /></Button>
<Button variant="link">Learn more</Button>variant:'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive' | 'link'(default'primary')size:'sm' | 'md' | 'lg' | 'icon'(default'md')- Solid/outline variants carry the
surfaceelevation tier;ghost/linkstay flat. Inline<svg>icons auto-size to1rem. - All native
<button>props pass through;classNamemerges.
Badge
Generic inline label/count badge — for non-semantic tags ("New", a version, a role name, a count). For status meaning, use StatusBadge instead.
import { Badge } from '@quanticjs/react-ui';
<Badge>New</Badge>
<Badge variant="secondary">v8</Badge>
<Badge variant="outline">Draft</Badge>
<Badge variant="destructive">Overdue</Badge>variant:'default' | 'secondary' | 'destructive' | 'outline'(default'default')- Renders a
<span>; all native span props pass through;classNamemerges.
Card
Surface container family for grouped content.
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '@quanticjs/react-ui';
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Your account details</CardDescription>
</CardHeader>
<CardContent>…</CardContent>
<CardFooter><Button>Save</Button></CardFooter>
</Card>DescriptionList
Semantic <dl> label/value list for detail and profile views.
import { DescriptionList, DescriptionItem } from '@quanticjs/react-ui';
<DescriptionList columns={2}>
<DescriptionItem label="Name">Admin User</DescriptionItem>
<DescriptionItem label="Email">[email protected]</DescriptionItem>
<DescriptionItem label="Phone" /> {/* renders an em dash */}
</DescriptionList>columns:1 | 2 | 3(default1) — responsive grid from thesmbreakpoint.
StatusBadge
Canonical status indicator — use instead of hand-rolled status pills.
import { StatusBadge } from '@quanticjs/react-ui';
<StatusBadge variant="success">Active</StatusBadge>
<StatusBadge variant="destructive" appearance="solid">Disabled</StatusBadge>variant:'success' | 'warning' | 'destructive' | 'info' | 'neutral'appearance:'dot'(default — colored dot + foreground text, calm enough to repeat on every table row) or'solid'(filled pill for a single emphatic status). Both are WCAG AA safe by construction.
Spinner
Accessible loading indicator with three sizes.
import { Spinner } from '@quanticjs/react-ui';
<Spinner /> // default (md)
<Spinner size="sm" />
<Spinner size="lg" />
<Spinner className="text-primary" />| Prop | Type | Default |
|---|---|---|
| size | 'sm' \| 'md' \| 'lg' | 'md' |
| label | string | 'Loading' |
| className | string | — |
Renders a role="status" div with the label as aria-label — pass a translated string for non-English apps.
Skeleton
Placeholder pulse animation for loading states.
import { Skeleton } from '@quanticjs/react-ui';
<Skeleton className="h-4 w-48" /> // text line
<Skeleton className="h-24 w-full" /> // card
<Skeleton className="h-10 w-10 rounded-full" /> // avatar| Prop | Type | Default |
|---|---|---|
| className | string | — |
ToastProvider / useToast
Toast notification system with four types and ApiError support.
Setup
Wrap your app (once, typically in the root):
import { ToastProvider } from '@quanticjs/react-ui';
<ToastProvider duration={5000}>
<App />
</ToastProvider>| Prop | Type | Default |
|---|---|---|
| duration | number | 5000 (ms) |
| dismissLabel | string | 'Dismiss' — aria-label of every toast's dismiss button; override per toast via toast.success('…', { dismissLabel: '…' }) |
Usage
import { useToast } from '@quanticjs/react-ui';
const toast = useToast();
toast.success('Project created');
toast.error('Something went wrong');
toast.warning('Rate limit approaching');
toast.info('New version available');
// With description
toast.success('Saved', { description: 'All changes have been saved' });
// Custom duration
toast.info('Copied', { duration: 2000 });
// Dismiss programmatically
const id = toast.info('Processing...');
toast.dismiss(id);ApiError support
toast.error() accepts an Error object. If the error has a .detail property (like ApiError), it's shown as the description:
try {
await client.post('/items', data);
} catch (err) {
toast.error(err as Error);
// Title: "Validation failed" (from error.message)
// Description: "Name must be unique" (from error.detail)
}Dialog
Modal dialog with portal, backdrop, Escape key handling, body scroll lock, focus trapping, and automatic ARIA labelling.
import { Dialog, DialogHeader, DialogBody, DialogFooter } from '@quanticjs/react-ui';
const [open, setOpen] = useState(false);
<Dialog open={open} onClose={() => setOpen(false)}>
<DialogHeader>Confirm Delete</DialogHeader>
<DialogBody>
Are you sure you want to delete this item? This cannot be undone.
</DialogBody>
<DialogFooter>
<button onClick={() => setOpen(false)}>Cancel</button>
<button onClick={handleDelete}>Delete</button>
</DialogFooter>
</Dialog>Dialog Props
| Prop | Type | Description |
|---|---|---|
| open | boolean | Controls visibility |
| onClose | () => void | Called on backdrop click or Escape key |
| className | string | Applied to the dialog panel |
| aria-label | string | Accessible name when there is no DialogHeader |
| aria-describedby | string | Forwarded to the dialog panel |
Sub-components (DialogHeader, DialogBody, DialogFooter) accept children and className.
The panel is automatically labelled by its DialogHeader (aria-labelledby); without one, pass aria-label. To choose which element receives focus on open, mark it with data-autofocus. See Accessibility for the keyboard behavior.
EmptyState
Zero-data placeholder with optional icon and action.
import { EmptyState } from '@quanticjs/react-ui';
<EmptyState
icon={<FolderIcon className="h-12 w-12" />}
title="No projects yet"
description="Create your first project to get started."
action={<Button onClick={handleCreate}>Create Project</Button>}
/>| Prop | Type | Description |
|---|---|---|
| icon | ReactNode | Optional icon above the title |
| title | string | Main message |
| description | string | Secondary text |
| action | ReactNode | CTA button or link |
| className | string | — |
DataTable
Typed, dependency-free table for list views: declarative columns, controlled sorting, controlled (server-side) pagination, loading/empty/error states, clickable rows, sticky header.
import { DataTable, type ColumnDef, type SortState } from '@quanticjs/react-ui';
interface User { id: string; name: string; email: string; createdAt: string }
const columns: ColumnDef<User>[] = [
{ key: 'name', header: 'Name', sortable: true },
{ key: 'email', header: 'Email' },
{
key: 'createdAt',
header: 'Joined',
align: 'end',
cell: (user) => new Date(user.createdAt).toLocaleDateString(),
},
];
const [sort, setSort] = useState<SortState | undefined>();
<DataTable<User>
columns={columns}
data={users}
rowKey={(user) => user.id}
sort={sort}
onSortChange={(next) => setSort(next ?? undefined)}
pagination={{ page, pageSize, total }}
onPageChange={setPage}
onPageSizeChange={setPageSize}
onRowClick={(user) => navigate(`/users/${user.id}`)}
loading={isLoading}
error={isError ? <QueryErrorPanel error={error} onRetry={refetch} /> : undefined}
/>| Prop | Type | Description |
|---|---|---|
| columns | ColumnDef<T>[] | Column definitions (see below) |
| data | T[] | Rows for the current page |
| rowKey | (row: T) => string | Stable React key per row |
| sort | { key, direction } | Controlled sort state |
| onSortChange | (sort \| null) => void | Clicking a sortable header cycles asc → desc → null |
| pagination | { page, pageSize, total } | Controlled pagination; renders the pagination bar |
| onPageChange / onPageSizeChange | (n: number) => void | Pagination callbacks |
| pageSizeOptions | number[] | Default [25, 50, 100] |
| onRowClick | (row: T) => void | Rows become hoverable, focusable, Enter/Space-activatable |
| loading | boolean | Skeleton rows when data is empty; subtle top bar when refetching with data |
| error | ReactNode | Rendered in place of the body |
| emptyState | ReactNode | Default: <EmptyState title="No data" /> |
| stickyHeader | boolean | Pair with containerClassName="max-h-96" (or similar) |
| labels | DataTableLabels | i18n overrides for loading/empty/pagination text |
| className / containerClassName | string | Outer wrapper / scrollable table container |
ColumnDef<T>: key, header, cell? (defaults to String(row[key]); nullish renders empty), sortable?, align? ('start' \| 'end' \| 'center'; direction-aware, follows dir), width?, headerClassName?, cellClassName?. The values 'left'/'right' are deprecated aliases for 'start'/'end' — still accepted, mapped internally.
Behavior notes:
- Fully controlled. The component never sorts or slices
data— fetch the right page server-side (pairs naturally withusePaginatedQueryfrom@quanticjs/react-query). - Stale page after deletes. If
totalshrinks below the current page offset, the range display clamps (e.g.10–10 of 10), but the component will not callonPageChangefor you — clamp the page in your data layer. - Long content. Default-rendered cells truncate with a
titleattribute; opt out per column viacellClassName(e.g.whitespace-normal). Customcellrenderers control their own layout. - Mobile. The table sits in a horizontal scroll container by default.
- Accessibility.
<th scope="col">, sortable headers are real buttons witharia-sorton theth, clickable rows are keyboard-activatable, pagination controls are labelled (override vialabelsfor i18n). While the empty-table skeleton shows, the table isaria-hiddenand arole="status"region announces loading.
Pagination
The pagination bar used by DataTable, exported standalone for non-table lists (card grids, etc.):
import { Pagination } from '@quanticjs/react-ui';
<Pagination
pagination={{ page, pageSize: 25, total: 312 }}
onPageChange={setPage}
onPageSizeChange={setPageSize} // omit to hide the page-size select
labels={{ previousPage: 'Vorherige Seite', nextPage: 'Nächste Seite' }}
/>QueryErrorPanel
Categorized error panel for query failures — 403 renders an access message (no retry), 404 a not-found message (no retry), anything else a generic message with a "Try again" button when onRetry is provided.
import { QueryErrorPanel } from '@quanticjs/react-ui';
<QueryErrorPanel
error={error}
onRetry={refetch}
labels={{ retry: 'Erneut versuchen' }} // Partial<QueryErrorPanelLabels>
/>QueryErrorPanelLabels keys: forbiddenTitle, forbiddenBody, notFoundTitle, notFoundBody, errorTitle, retry — all resolvable from the provider catalog (ui.queryErrorPanel).
ErrorBoundary
React error boundary with configurable fallback.
import { ErrorBoundary } from '@quanticjs/react-ui';
// Default fallback (shows error message + "Try again" button)
<ErrorBoundary>
<Dashboard />
</ErrorBoundary>
// Static fallback
<ErrorBoundary fallback={<p>Something broke</p>}>
<Dashboard />
</ErrorBoundary>
// Render function fallback (receives error + reset callback)
<ErrorBoundary
fallback={(error, reset) => (
<div>
<p>Error: {error.message}</p>
<button onClick={reset}>Retry</button>
</div>
)}
onError={(error, info) => Sentry.captureException(error)}
>
<Dashboard />
</ErrorBoundary>| Prop | Type | Description |
|---|---|---|
| fallback | ReactNode \| (error, reset) => ReactNode | What to render on error |
| onError | (error, info) => void | Side effect on catch (e.g., Sentry) |
| title | string | Heading of the default fallback (default 'Something went wrong') |
| retryLabel | string | Reset button text of the default fallback (default 'Try again') |
useTheme / ThemeToggle
Theme management for the light/dark tokens shipped by @quanticjs/tailwind-preset. Persists an explicit 'light' | 'dark' | 'system' preference to localStorage, resolves 'system' against prefers-color-scheme (live — follows OS changes), and toggles the dark class on document.documentElement. All consumers share one store and stay in sync, including across tabs via the storage event.
import { useTheme, ThemeToggle, type ThemePreference } from '@quanticjs/react-ui';
function ThemeMenu() {
const { theme, resolvedTheme, setTheme } = useTheme();
// theme: 'light' | 'dark' | 'system' (the preference)
// resolvedTheme: 'light' | 'dark' (what is actually applied)
return (
<select value={theme} onChange={(e) => setTheme(e.target.value as ThemePreference)}>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="system">System</option>
</select>
);
}
// Or use the ready-made cycling button:
<ThemeToggle /> // light → dark → system
<ThemeToggle modes={['light', 'dark']} /> // two-state toggle
<ThemeToggle labels={{ light: 'Hell', dark: 'Dunkel', system: 'Auto', switchTo: (n) => `Wechseln zu ${n}` }} />useTheme(options?) options
| Option | Type | Default | Description |
|---|---|---|---|
| storageKey | string | 'quantic.theme' | localStorage key the preference persists under |
| darkClass | string | 'dark' | Class toggled on document.documentElement |
| defaultTheme | ThemePreference | 'system' | Used when nothing (or an invalid value) is stored |
The store is a singleton per storageKey — pass the same options everywhere (differing options log a dev warning; the first-initialized options win). Distinct storageKeys create independent stores (e.g. embedded micro-frontends).
ThemeToggle props
| Prop | Type | Default |
|---|---|---|
| modes | ThemePreference[] | ['light', 'dark', 'system'] — cycle order |
| labels | ThemeToggleLabels | { light: 'Light', dark: 'Dark', system: 'System' } |
| labels.switchTo | (nextLabel: string) => string | `Switch to ${next} theme` — the button's aria-label, always naming the next mode |
| className | string | — |
| …plus all useTheme options | | |
Preventing the flash of wrong theme (FOUC)
useTheme applies the class after hydration. To set it before first paint, inline this script in your HTML <head> (it reads the same storage key):
<script>
(function () {
try {
var t = localStorage.getItem('quantic.theme');
var dark = t === 'dark' || ((t === 'system' || !t) &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark');
} catch (e) {}
})();
</script>If you customized storageKey or darkClass, mirror the values here.
Edge-case behavior
- SSR-safe — render never touches
window; the server snapshot returnsdefaultTheme(resolved'light'for'system'), so there are no hydration warnings. - localStorage unavailable (privacy mode, quota) — the hook works in-memory; persistence is silently skipped.
- Invalid stored value — treated as
defaultTheme, overwritten on the nextsetTheme. matchMediaabsent —'system'resolves to'light'.
Migrating from a local theme toggler
If your app already manages the dark class itself (its own useTheme hook, an inline toggler, etc.), uninstall it when adopting this hook — two writers of the same class fight each other. Delete the local hook, point imports at @quanticjs/react-ui, and keep your old storage key readable by passing it via storageKey (or let users re-pick once under the default quantic.theme).
Form primitives — FormField, Input, Textarea, Select, Checkbox, Radio/RadioGroup, Switch
Presentational, token-styled, RTL-safe form controls plus an accessible field wrapper. They contain no form logic — pair them with @quanticjs/react-forms (useForm, fieldError) for validation and server error mapping.
import { FormField, Input } from '@quanticjs/react-ui';
<FormField label="Email" description="Work email preferred" error={fieldError(form, 'email')}>
<Input type="email" {...form.register('email')} />
</FormField>FormField renders the label, optional description, and error, generates stable ids via useId, and provides them through FormFieldContext. The control inside consumes the context automatically and receives:
idlinked to the<label>(sogetByLabelTextfinds it),aria-describedbyreferencing the description and error ids (only the ones actually rendered; the error id first so AT announces the problem before the hint),aria-invalid="true"ifferroris set,aria-requiredwhenrequiredis set (which also renders anaria-hiddenvisual indicator, default*, overridable vialabels.requiredIndicator/ catalog keyui.formField.requiredIndicator).
Explicit props always win over context values, and every control works standalone (outside FormField) with consumer-supplied id/aria-*. The error renders in a role="alert" region (text-destructive); the description is text-muted-foreground. One control per FormField — a second context consumer dev-warns.
Custom widgets can join the contract by calling useFormField() (returns FormFieldContextValue | null).
Per-control notes:
Input—size: 'sm' | 'md' | 'lg'(heightsh-8/h-9/h-10, matching Button; the nativesizeattribute is type-excluded),invalidfor standalone use, andstartAdornment/endAdornmentReactNode slots positioned with logical properties. Adornments are decorative by default (aria-hidden, click-through so clicking the chrome focuses the input); interactive adornments (a<button>, an element withonClick/href) automatically stay visible to AT and clickable.typepasses through untouched.Textarea— same recipe,invalidprop, no size variants.Select— styled native<select>(size/invalidlike Input).appearance-noneplus anend-*-positioned chevron, so the OS arrow is never double-rendered. A rich combobox is out of scope (future component).Checkbox/Radio— native inputs (keyboard, forms, and AT support come from the platform), styled viaappearance-none+checked:token classes with an inline glyph.RadioGroup— rendersrole="radiogroup", labelled by the surrounding FormField (oraria-labelstandalone), and distributesname/checked state/onValueChange/disabledto childRadios via context. Same-name radios get native arrow-key movement. Give eachRadioits own per-option<label>.Switch— native checkbox withrole="switch": reports its checked state, toggles with Space; the thumb travels viainset-inline-startso it flips under RTL.
All primitives accept className, forward refs, use semantic tokens and the canonical focus ring only, carry disabled:opacity-50 disabled:cursor-not-allowed, and put motion-reduce:transition-none on their transitions.
Field errors for fields without a FormField (e.g. _root from server validation) are not FormField's job — render them via QueryErrorPanel or an inline alert (see the react-forms README).
cn() Utility
Combines clsx and tailwind-merge for conditional class names:
import { cn } from '@quanticjs/react-ui';
cn('px-4 py-2', isActive && 'bg-primary', className);Utilities
Shared formatters and hooks so consuming packages stop re-implementing them. All formatters are pure functions backed by Intl, accept Date | string | number, and return '' for null/undefined/invalid dates (never "Invalid Date", never a throw). Intl formatter instances are cached per (locale, options), so they are safe to call in table cells.
import {
formatDate,
formatDateTime,
formatRelativeTime,
formatDuration,
formatBytes,
setDefaultLocale,
useDebounce,
} from '@quanticjs/react-ui';
formatDate('2026-06-10T12:00:00Z'); // "Jun 10, 2026" ({ dateStyle: 'medium' })
formatDateTime('2026-06-10T12:00:00Z'); // "Jun 10, 2026, 12:00 PM" (adds { timeStyle: 'short' })
formatRelativeTime(Date.now() - 180_000); // "3 minutes ago" ("in 2 days" for future values)
formatDuration(4_980_000); // "1h 23m" (compact; zero minor units omitted: "1h", "45s")
formatBytes(1_400_000); // "1.4 MB" (decimal units by default)
formatBytes(1_536, { binary: true }); // "1.5 KiB" (1024-based units)
formatDate(value, { locale: 'de-DE' }); // per-call locale overrideLocale handling
Every formatter takes an optional opts.locale. Without it, the module-level default applies — set it once at app bootstrap:
import { setDefaultLocale } from '@quanticjs/react-ui';
setDefaultLocale(userProfile.locale); // e.g. 'de-DE'Until setDefaultLocale is called, Intl uses the runtime locale. Formatters are pure functions called per render — changing the default later affects the next render but does not trigger a re-render by itself, so set it before mounting.
For a reactive locale, wrap the app in a TranslationProvider with a locale prop: components in this package and the domain packages read it via useLocale() and pass it to the formatters, so a locale switch re-renders with the new formatting. setDefaultLocale remains the non-React fallback.
formatBytes options
| Option | Default | Behavior |
|---|---|---|
| binary | false | true switches to 1024-based units (KiB, MiB, …) |
| maximumFractionDigits | 1 | Passed to Intl.NumberFormat |
Zero and negative values render 0 B (negative warns in development).
useDebounce
const debouncedSearch = useDebounce(search, 300); // delayMs defaults to 300Returns the value once it has stayed unchanged for delayMs. Rapid changes coalesce; changing the delay mid-flight restarts the timer; the pending timer is cleaned up on unmount.
Internationalization
TranslationProvider injects a translation catalog and a locale once, app-wide — no more threading labels props through every call site. The catalog is a plain typed object keyed by package namespace; every component resolves each label with the precedence explicit labels prop > provider catalog > English default, per key. No provider in the tree means English defaults and setDefaultLocale-based formatting — byte-identical to previous behavior.
import { TranslationProvider } from '@quanticjs/react-ui';
<TranslationProvider
locale="de-DE"
translations={{
ui: {
dataTable: { empty: 'Keine Daten' },
queryErrorPanel: { retry: 'Erneut versuchen' },
spinner: { label: 'Wird geladen' },
},
// Domain packages register their own namespaces:
files: { upload: { preparing: 'Upload wird vorbereitet…' } },
layouts: { shell: { logout: 'Abmelden' } },
}}
>
<App />
</TranslationProvider>The ui namespace (UiTranslations) covers: dataTable (incl. pagination keys), themeToggle, spinner, errorBoundary, toast, queryErrorPanel (QueryErrorPanelLabels: forbiddenTitle, forbiddenBody, notFoundTitle, notFoundBody, errorTitle, retry).
Hooks and helpers
useLocale(): string | undefined— the nearest provider's locale;undefinedoutside a provider (formatters then fall back tosetDefaultLocale/ runtime locale).useTranslations(namespace)— the catalog slice for a registry namespace, typed viaTranslationRegistry.resolveLabels(defaults, ...overrides)— per-key merge (later sources win,undefinedskipped,''is a valid override). Component authors resolve in one line:
const labels = resolveLabels(DEFAULT_LABELS, useTranslations('ui')?.dataTable, props.labels);Registry augmentation
TranslationRegistry is an augmentable interface — domain packages add their namespace:
declare module '@quanticjs/react-ui' {
interface TranslationRegistry {
files?: FilesTranslations;
}
}Notes
- Nested providers deep-merge their catalog over the parent's at leaf level; an unset child
localeinherits the parent's. Useful for overriding one namespace in a sub-tree without re-declaring the app catalog. - Function-valued labels (e.g.
PaginationLabels.range,ThemeToggleLabels.switchTo) handle interpolation/pluralization without an ICU dependency. JSON-only translation pipelines wrap them:range: (s, e, t) => t('ui.range', { s, e, t }). ICU/message-syntax parsing is out of scope. - RTL/
diris out of scope — the provider does not setdir.
Accessibility
Dialog keyboard map
| Key | Behavior |
|---|---|
| Tab | Moves to the next focusable element inside the dialog; wraps from the last to the first |
| Shift+Tab | Moves to the previous focusable element; wraps from the first to the last |
| Escape | Closes the dialog |
On open, focus moves to the first element marked data-autofocus, else the first focusable element, else the dialog panel itself. Focus cannot leave the dialog while it is open (focus moved out programmatically is pulled back). On close, focus returns to the element that was focused before the dialog opened (or document.body if it was removed). Nested dialogs stack — the innermost dialog holds the trap, and closing it hands focus back to the outer one.
Labelling guidance
- Give every
Dialogan accessible name: use aDialogHeader(wired up automatically viaaria-labelledby) or passaria-label. - All built-in accessible strings are customizable for i18n:
Spinnerlabel,ToastProvider/per-toastdismissLabel,ErrorBoundarytitleandretryLabel. Defaults are the English strings listed above.
useFocusTrap
The focus trap is exported for sibling packages building other overlay surfaces (drawers, popovers). It is internal-grade API — minimal and unstyled:
import { useFocusTrap } from '@quanticjs/react-ui';
const ref = useRef<HTMLDivElement>(null);
useFocusTrap(ref, isOpen); // trap while active; restores focus on deactivationGive the container tabIndex={-1} so it can receive focus when it has no focusable children.
useExitAnimation
Keeps an overlay mounted until its exit animation finishes, so close choreography (Dialog, Toast, drawers, dropdowns) can play out before unmount. Used by every framework overlay and exported for sibling packages and app-level overlay surfaces:
import { useExitAnimation } from '@quanticjs/react-ui';
const { mounted, state, onAnimationEnd } = useExitAnimation(open);
if (!mounted) return null;
return (
<div
data-state={state} // 'open' | 'closed'
onAnimationEnd={onAnimationEnd}
className={state === 'open' ? 'animate-pop-in' : 'animate-pop-out [animation-fill-mode:forwards]'}
>
…
</div>
);open: true→mounted: true,state: 'open'immediately;open: false→state: 'closed', unmount on the element'sanimationend(or a ~290ms safety timeout — covers jsdom andanimation: noneuser styles).- Re-opening during an exit cancels the unmount without remounting.
- Key focus traps and scroll locks off
open(release on close start), not offmounted, so keyboard users are never trapped during an exit. - Under
prefers-reduced-motion, the preset's global rule shrinks animations to 0.01ms, soanimationendstill fires and close is effectively instant.
Testing
This package's components are covered by axe assertions in unit tests. Consuming apps should still run axe (e.g. @axe-core/playwright) in their own E2E suites — page-level issues like color contrast and heading structure can only be caught in the real app.
RTL & reduced motion
- RTL is attribute-driven. Components use Tailwind logical properties (
start-*,ps-*,text-start,border-e, …) exclusively — setdir="rtl"on<html>(or any ancestor) and the toast stack, table alignment, and pagination mirror automatically. There is noDirectionProvideror runtime API. Pagination chevrons flip with the reading direction (rtl:-scale-x-100);ColumnDef.aligntakes direction-aware'start' | 'end'. - Reduced motion.
Spinner,Skeleton, and the DataTable refetch bar carrymotion-reduce:animate-none. A frozenSpinnerremains perceivable: the static ring plusrole="status"/aria-labelstill communicate loading. The preset'stheme.cssadditionally disables all animation/transition durations globally underprefers-reduced-motion: reduce. - A guard test in this package (
logical-properties.test.ts) scans every sibling package and fails CI when a physical direction utility (left-*,pl-*,text-left, …) or a literal shadow/z-index (shadow-md/lg/xl,z-10,z-50,z-[60]) is introduced — elevation and stacking must go through the preset'sshadow-surface|raised|overlayandz-(--z-*)scales. Justified exceptions go in itsALLOWLISTwith a reason.
Exports
cn
Button, type ButtonProps, type ButtonVariant, type ButtonSize
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter
type CardProps, type CardTitleProps, type CardDescriptionProps
DescriptionList, DescriptionItem, type DescriptionListProps, type DescriptionItemProps
StatusBadge, type StatusBadgeProps, type StatusBadgeVariant, type StatusBadgeAppearance
Avatar, type AvatarProps, type AvatarSize
Progress, type ProgressProps, type ProgressTone, type ProgressLabels
StatCard, type StatCardProps, type StatTrend
Breadcrumb, type BreadcrumbProps, type BreadcrumbItem, type BreadcrumbRenderLinkProps, type BreadcrumbLabels
TranslationProvider, useLocale, useTranslations, resolveLabels
type TranslationProviderProps, type TranslationRegistry, type UiTranslations
formatDate, formatDateTime, formatRelativeTime, formatDuration, formatBytes
setDefaultLocale, type FormatOptions, type BytesOptions
useDebounce
useFocusTrap
useExitAnimation, type ExitAnimationState
Spinner, type SpinnerProps
Skeleton, type SkeletonProps
ToastProvider, useToast, type ToastProviderProps
Dialog, DialogHeader, DialogBody, DialogFooter, type DialogProps
EmptyState, type EmptyStateProps
DataTable, type DataTableProps, type DataTableLabels
Pagination, type PaginationProps, type PaginationLabels
type ColumnDef, type SortState, type PaginationState
QueryErrorPanel, type QueryErrorPanelProps, type QueryErrorPanelLabels
ErrorBoundary, type ErrorBoundaryProps
useTheme, type ThemePreference, type UseThemeOptions, type UseThemeResult
ThemeToggle, type ThemeToggleProps, type ThemeToggleLabels
FormField, useFormField, type FormFieldProps, type FormFieldLabels, type FormFieldContextValue
Input, type InputProps
Textarea, type TextareaProps
Select, type SelectProps
Checkbox, type CheckboxProps
Radio, RadioGroup, type RadioProps, type RadioGroupProps
Switch, type SwitchProps