@pajecawav/ui
v0.1.1
Published
Maintainers
Readme
@pajecawav/ui
React 19 component library: accessible Base UI and
Floating UI primitives styled with CSS Modules and
design tokens, dark mode via a single html.dark class, and SSR-safe portals.
Install
react and react-dom ^19.0.0 are peer dependencies.
npm install @pajecawav/ui
# or
yarn add @pajecawav/ui
# or
pnpm add @pajecawav/uiSetup
Import the bundled stylesheet once, in your app entry:
import "@pajecawav/ui/index.css";or from your own stylesheet:
@import "@pajecawav/ui/index.css";One file ships everything, in this order:
- Reset — the full Tailwind-style reset (
@unocss/reset/tailwind-v4.css). - Base page styles — font family, text color, page background and
color-schemeset directly on<html>. - Design tokens — the raw scale (
--neutral-*,--red-*,--size-*,--font-size-*,--radius-*,--shadow-*, …) and the semantic--ui-*layer (see Theming). - Component styles — every component's styles, scoped as
Button__root-<hash>so they never collide with yours.
[!WARNING] This stylesheet restyles your page. The reset plus the base styles on
<html>change margins, typography, form controls and the page background of the entire host document — that is what it is for. If your app already has its own baseline CSS, expect conflicts. A reset-free entry point is planned; until it lands,index.cssis the only supported entry.
Providers
Wrap your app in UIProvider:
import { UIProvider } from "@pajecawav/ui";
export default function App() {
return <UIProvider>{children}</UIProvider>;
}It sets up:
- a shared tooltip delay group (200 ms open / 100 ms close intent), so tooltips behave consistently and don't stick when moving between targets,
- icon defaults — every built-in lucide icon inherits the font size of its context,
- a nested
ThemeProviderfor dark mode.
Dark mode
The theme contract is one class: dark on <html>. Design tokens,
color-scheme and component styles all key off it.
Read and change it with useTheme() (theme, setTheme, toggle,
systemTheme), or drive UIProvider/ThemeProvider with a theme prop
(e.g. resolved from a cookie on the server) and persist changes through
onThemeChange.
In server-rendered apps, apply the theme before first paint with
ThemeScript — a blocking inline <script> for the document <head> that
toggles the class immediately, preventing a light-to-dark flash (FOUC). A
ThemeProvider rendered without a theme prop adopts that pre-hydration
class after mount:
import { ThemeScript, UIProvider } from "@pajecawav/ui";
// Root layout, Next.js App Router style. suppressHydrationWarning on <html>:
// the script mutates the class before React hydrates.
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<ThemeScript theme={resolvedTheme} />
</head>
<body>
<UIProvider onThemeChange={persistTheme}>{children}</UIProvider>
</body>
</html>
);
}Theming
Restyle the whole library by overriding the semantic --ui-* tokens. They
reference the raw scale and are re-mapped under html.dark, so one override
point per token covers both modes:
:root {
--ui-surface-page: #f6f7fb;
--ui-focus-ring-color: #7c3aed;
--ui-radius-control: var(--radius-full);
}
html.dark {
--ui-surface-page: #0b0c10;
--ui-focus-ring-color: #a78bfa;
}Main token groups (the full list lives in
src/styles/tokens.css):
| Group | Tokens |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| Border | --ui-border-width, --ui-border-width-thick, --ui-border-color (+ -strong, -accent, -error, -valid), --ui-separator-thickness |
| Surface | --ui-surface-page, -default, -subtle, -muted, -inverse, -error, -valid |
| Text | --ui-text-default, -inverse, -muted, -subtle, -placeholder, -error, -valid, -info, -warning, -link |
| Focus ring | --ui-focus-ring-width, -thin, --ui-focus-ring-offset, --ui-focus-ring-color |
| Radius | --ui-radius-control, --ui-radius-card, --ui-radius-pill |
| Control height | --ui-control-height-sm / -md / -lg (28 / 32 / 36 px; 36 / 40 / 44 px on coarse pointers) |
| Semantic accent | --ui-accent-positive / -negative / -warning / -info (+ -strong, -indicator, -on, tonal and border steps for Button/Badge) |
| Elevation | --ui-shadow-card, --ui-overlay, --ui-overlay-tint |
| Stacking | --ui-z-modal < --ui-z-popover < --ui-z-tooltip < --ui-z-toast |
| Motion | --duration-short / -medium / -long (+ -safe variants that collapse under prefers-reduced-motion) |
[!NOTE] Border line tokens are HiDPI-aware: at
min-resolution: 2dppxthey swap to hairline values so borders paint as exactly one physical pixel. Use the tokens instead of hardcoding1px.
Design system
The kit's visual rules are consumer-facing, not internal: a neutral primary,
semantic-only color, one control-height grid (28/32/36px, stepped up to
36/40/44px on touch), flat-by-default elevation, and a written exceptions
policy (Switch, InputOTP slots, input-group addons). Build your own
controls on the same tokens so rows stay aligned and themes stay coherent —
see docs/design-system.md for the named rules
and the full policy.
SSR
The library server-renders without hydration mismatches. The contracts worth knowing as a consumer:
- Portals render nothing on the server.
Modal,ModalCard,Alert,Menu,MobileMenu,TooltipandToastersuppress their portal subtrees during SSR — even when open — so only in-page parts (e.g.Menu.Trigger) appear in the HTML. Popup content mounts after hydration. Don't assert on popup markup in your own SSR tests. Avatarrenders its fallback; loading the image is client-only.Imagerenders the plain<img>(native semantics, no layout shift); the errorfallbackappears after hydration.NavigationProgressrenders nothing while idle.Textarearenders plain on the server; autosize applies after hydration.ThemeScriptis the deliberate exception: it must render its inline script in the server HTML (see Dark mode).
Components
Everything is exported from the package root:
import { Button, Menu, Tabs } from "@pajecawav/ui";Actions
Button— action button with variants, sizes and modes.IconButton— square, icon-only button.ButtonLayout— rows of mixed buttons/controls with pixel-aligned heights.CloseButton— ready-made ✕ button for dismissing modals and banners.Clickable— polymorphic clickable primitive; resolves its root element fromhref,Componentorrender.Toggle— two-state (pressed/unpressed) button.ToggleGroup— a group of toggles sharing pressed state.
Forms
FormItem— field wrapper providing label, status and disabled context to nested controls.Input— text input.InputGroup— composable input row with before/after addons.Textarea— multiline input that autosizes.InputPassword— password field with a visibility toggle.InputOTP— one-time-password input of character slots.InputDate— segmented date field (dd.mm.yyyy) with a calendar popup.InputDateRange— segmented date-range field.NumberField— numeric input with increment/decrement steppers.Checkbox,Radio,RadioGroup,Switch,Slider— standard controls styled on the token system.Select— custom select with a listbox popup; falls back to a native<select>on touch devices.NativeSelect— styled native<select>.Search— search input with a clear button.Calendar— month-grid date picker (also powersInputDate).Autocomplete— input with a suggestion popup (see Async items).Combobox— single- or multi-select combobox with chips.
Overlays
Modal— centered dialog with focus trap and backdrop.ModalCard— dialog that renders its content in a card.Alert— compact alert dialog for confirmations.Menu— dropdown menu: items, links, checkbox/radio items, groups, submenus.MobileMenu— drawer/bottom-sheet menu for mobile viewports.Tooltip— hover/focus hint positioned with Floating UI.Toaster— toast notifications; render<Toaster />once and fire toasts through thetoastmanager.Command— command-palette input over a list of actions (the ⌘K pattern).
Navigation
Link— hyperlink.Breadcrumbs— breadcrumb trail with separators.Pagination— page navigation.Tabs— tab list with panels.Sidebar— collapsible app sidebar (Sidebar.Provider,Sidebar.Trigger).NavigationProgress— top-of-page progress bar driven byisPending.
Typography
Title— heading text.Text— body text.Paragraph— paragraph block.Footnote— small caption text in three weights.ClampText— text clamped to a max number of lines.Kbd— keyboard key cap.Mark— highlighted text fragment.Marker— vertical accent marker for headers and list items.
Layout
Card— grouped content container.Cell— list row with before/after slots.Flex— flexbox layout primitive.Separator— horizontal or vertical divider.AspectRatio— constrains children to a ratio.Placeholder— empty-state block (icon, title, action).
Feedback
Banner— inline alert banner with icon, title and actions.Progress— progress bar, determinate or indeterminate.Spinner— loading spinner.Skeleton— shimmering loading placeholder.Status— colored status dot with a label (info/positive/warning/negative).
Media & utility
Avatar— user avatar with an automatic image → initials fallback.Image—<img>with border, placeholder and error fallback.VisuallyHidden— content exposed to screen readers only.
Compound components
Many components expose their parts via dot notation only — Menu.Item,
Tabs.Tab, Banner.Icon — the bare parts are not exported:
<Menu>
<Menu.Trigger>Actions</Menu.Trigger>
<Menu.Item>Rename…</Menu.Item>
<Menu.Separator />
<Menu.Item disabled>Delete</Menu.Item>
</Menu>Typed families
Command, Autocomplete and Combobox accept item values of any type, but
JSX children can't inherit those type parameters from the parent component.
Create a typed family once at module scope instead:
import { createAutocomplete } from "@pajecawav/ui";
interface Country {
code: string;
label: string;
}
const CountryAutocomplete = createAutocomplete<Country>();
// createCommand<Item, Group>() and createCombobox<Value, Multiple>() follow
// the same pattern.
<CountryAutocomplete items={countries} itemToStringValue={c => c.label}>
<CountryAutocomplete.Input placeholder="Country" />
<CountryAutocomplete.Content>
<CountryAutocomplete.List>
{country => (
<CountryAutocomplete.Item key={country.code} value={country}>
{country.label}
</CountryAutocomplete.Item>
)}
</CountryAutocomplete.List>
</CountryAutocomplete.Content>
</CountryAutocomplete>;The bare Autocomplete/Command/Combobox exports remain for untyped,
simple usage.
Async items (Autocomplete & Combobox)
There is no first-class async API yet (it's on the backlog) — async data is a usage pattern over the existing controlled props:
- control the input with
value/onValueChange, - set
mode="none"so the component stops filtering client-side — you feed it server-filtereditems, - announce state through the
Statuslive region (keep it mounted, swap its children) and hideEmptywhile a request is in flight, - guard the race yourself: keep a request id in a ref, bump it on every query, and drop responses whose id is no longer current.
The full pattern — debounce, stale-response guard, loading feedback — is spelled out in the Autocomplete → Async Storybook story.
Helpers
import { cn, mergeProps, useMergeRefs, useRender } from "@pajecawav/ui";cn— class-name merger, re-exported from@pajecawav/utils.useMergeRefs— re-export of the@floating-ui/reacthook.mergeProps,useRender— re-exports from@base-ui/react.
These are deliberate convenience re-exports, not reimplementations: the library is built on those exact packages, so custom components composed from the same primitives don't need to add or version-pin the dependencies themselves.
Also exported: the FormFieldStatus ("default" | "error" | "valid") type.
Development
pnpm install
pnpm --filter @pajecawav/ui dev # Storybook + token/CSS-module watchers- AGENTS.md — repo structure, conventions and test tiers for contributors.
- docs/modals.md, docs/clickable.md
— deep dives on the modal family and the
Clickableprimitive.
