@azmr/ui
Accessible React design system on Base UI and Tailwind v4 tokens, with Signal-aware components.
View as Markdown for coding assistants, or see llms.txt.
Installation
pnpm add @azmr/ui @azmr/core react react-dom@azmr/core is a peer dependency: install it yourself so your app and @azmr/ui share one copy. Two copies give incompatible Signal types and separate scheduler state.
Styles and design tokens
@azmr/ui ships the design tokens and the Tailwind v4 theme mapping in two stylesheets. In your global CSS, pick one:
@import "tailwindcss";
@import "@azmr/ui/styles.css"; /* tokens + global base rules (new app) */
@import "@azmr/ui/classes.css"; /* or one list per component you use */@import "tailwindcss";
@import "@azmr/ui/tokens.css"; /* tokens only (app that already has global styles) */
@import "@azmr/ui/classes/button.css";-
styles.cssistokens.cssplus two global base rules: the page background and text colour onbody, and our border and outline colours as the default for every element. Use it in a new app. -
tokens.csshas the same tokens, themes, density, motion and utilities and changes nothing outside our own classes. Use it when the app already has global styles: another UI library mid-migration, or a documentation site. Components look right either way. -
Token names follow shadcn/ui (
--background,--primary,--ring,--radius,--sidebar-*, ...), so shadcn components from any registry look right next to ours. Azmara adds--success,--warning,--info,--chart-1to--chart-8,--motion-*,--elevation-*,--z-*and--density-*. -
Utilities such as
bg-background,text-muted-foreground,rounded-lg,shadow-elevation-2andease-standardwork out of the box. Import the class list for each entry you use (@azmr/ui/classes/button.css), or@azmr/ui/classes.cssfor all of them, and Tailwind generates the package's classes; see Tailwind CSS setup. -
Light and dark use OKLCH colours with
light-dark(). The page follows the system by default; force a mode withclass="dark"/class="light"ordata-theme="dark|light"on any ancestor. No JavaScript, no flash. -
Density: set
data-density="compact"or"spacious"on any ancestor to change control heights, padding and row height. -
Reduced motion: with
prefers-reduced-motion: reduce, every--motion-duration-*token collapses to0.01msand a safety net shortens other animations. Mark genuinely essential motion withdata-motion="essential". -
Accessibility is tested: every foreground/background token pair meets WCAG AA text contrast (4.5:1) in both modes, the focus ring and chart colours meet 3:1, and the eight chart colours stay distinguishable under protanopia, deuteranopia and tritanopia simulation.
Theme, density and locale
For brand colours, dark mode, density and what createTheme guarantees, see the Theming guide; every token is listed in the token reference.
Server layout (Next.js App Router). Read the stored preference from the cookie on the server and render it into <html>, so the first paint is already correct, with no inline script (works under a strict CSP):
// app/layout.tsx
import { cookies } from "next/headers";
import { htmlAttributes, parseDensity, parseThemePreference, THEME_COOKIE, DENSITY_COOKIE } from "@azmr/ui/theme";
import { LocaleProvider, ThemeProvider } from "@azmr/ui/react";
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const jar = await cookies();
const theme = parseThemePreference(jar.get(THEME_COOKIE)?.value);
const density = parseDensity(jar.get(DENSITY_COOKIE)?.value);
return (
<html lang="en-NZ" {...htmlAttributes({ theme, density })}>
<body>
<ThemeProvider defaultTheme={theme} defaultDensity={density}>
<LocaleProvider locale="en-NZ" currency="NZD">{children}</LocaleProvider>
</ThemeProvider>
</body>
</html>
);
}system(the default) needs no attribute and no JavaScript: the tokens uselight-dark(), so the page follows the operating system.useTheme()returns{ theme, resolvedTheme, setTheme, density, setDensity }. Changes update<html>immediately and are remembered in a cookie (persist={false}turns that off).- Static sites that cannot read cookies on the server can inline
themeInitScript()instead (add your CSP nonce to the tag). createTheme({ primary, secondary, success, warning, info, destructive, radius, font, density })turns brand colours (#rrggbb,oklch(L C H)or{ l, c, h }) into token overrides. Every colour option is optional and keeps its default when omitted (destructiveis the "danger" colour). It guarantees WCAG AA in both modes, adjusting lightness where needed and keeping the hue:- each
-foregroundis at least 4.5:1 on its colour, including on a solid button's hover surface (a button blends with the page on hover, which can pull a colour that only just passes below 4.5:1; the derivation checks the blend); success,infoanddestructiveare also readable as text on the page and on cards (Text tone, form errors), andwarninggets a separate readable--warning-text;- the focus ring is at least 3:1. Input is validated strictly because the output is CSS, and an error names the option that was wrong.
- each
import { createTheme } from "@azmr/ui/theme";
const theme = createTheme({
primary: "#1E88E5",
success: "#2E7D32",
destructive: "#D32F2F",
radius: "0.5rem",
});
// <style>{theme.css}</style> or <html style={theme.cssVariables}>Locale. LocaleProvider defaults to en-NZ, NZD and a Monday week start. useLocale() gives cached Intl formatters (formatNumber, formatCurrency, formatDate, formatDateTime) and the strings components show (messages, overridable). Date-only strings such as 2026-10-15 print that calendar day in every time zone; missing or invalid values format to an empty string instead of throwing.
Components
Components read the design tokens only (no raw colours), accept className (merged last, so it can override defaults), forward ref to the real DOM element, and follow the density tokens for height and padding. Import from the root or from a subpath; subpaths and the root are both tree-shakeable.
Button
import { Button } from "@azmr/ui/button";
<Button>Save</Button>
<Button variant="destructive" loading loadingText="Deleting">Delete</Button>
<Button variant="outline" startIcon={<PlusIcon />}>New customer</Button>
<Button size="icon" aria-label="Close"><XIcon /></Button> {/* icon-only needs a name */}
<Button nativeButton={false} render={<a href="/docs" />}>Docs</Button> {/* renders a link */}- Variants:
default,secondary,outline,ghost,destructive,success,warning,info,link. Sizes:sm,md(follows the density token),lg,icon. loading: shows a spinner, setsaria-busy, ignores clicks and keyboard activation, stays focusable (keyboard users keep their place).loadingTextreplaces the label while loading;startIconis replaced by the spinner,endIconstays.typeis"button"by default, so a button inside a form never submits by accident; passtype="submit"explicitly.- Icon-only buttons are type-checked:
size="icon"requiresaria-labeloraria-labelledby.
Input
import { Input } from "@azmr/ui/input";
import { useForm } from "react-hook-form";
const { register } = useForm();
<label htmlFor="email">Email</label>
<Input id="email" type="email" {...register("email")} /> {/* no Controller needed */}ref is the real <input>, so register() works directly. Native onChange and the typed onValueChange(value) both fire. aria-invalid="true" styles the invalid state; focus uses a full-strength ring (WCAG 2.2 focus appearance).
Spinner
<Spinner /> is decorative; <Spinner label="Loading customers" /> is announced as a status. It keeps spinning under prefers-reduced-motion because a frozen spinner reads as a hung page.
Icons
Icon renders any icon component with consistent size, stroke, motion and accessibility. It is a Server Component (no hooks, no "use client"), so it works in loading.tsx, layouts and Suspense fallbacks.
import { Icon } from "@azmr/ui/icons";
import { Search } from "lucide-react";
import { IconBell } from "@tabler/icons-react"; // optional: any set, or your own SVG component
<Icon icon={Search} /> {/* decorative: aria-hidden */}
<Icon icon={Search} aria-label="Search" size="lg" /> {/* meaningful: role="img" + name */}
<Icon icon={IconBell} strokeWidth={1.5} />
<Icon icon={Loader} spin /> {/* keeps spinning under reduced motion */}- Sizes:
xs,sm,md(default),lg,xlfollow the size tokens; a number is a pixel size.classNamemerges last, soclassName="size-8"wins. - Accessible by default: decorative (
aria-hidden) unless you passaria-labeloraria-labelledby. An icon-only button is named by a labelled icon. - Bring any set. Lucide (ISC) is the default; Tabler (MIT) and your own SVG components work through the same
iconprop with no adapter.react-iconsis deliberately not bundled: it mixes licences and only tree-shakes through exact subpath imports. - Tree-shaking is tested: the Icon core bundles no icon at all, and an app that imports one icon bundles that icon only.
By name. Three ways, from most to least isolated:
// 1. The curated Lucide set, with typed names (about 60 everyday icons; Lucide's own kebab-case names)
import { Icon } from "@azmr/ui/icons/lucide";
<Icon name="circle-alert" />
// 2. Your own typed set: nothing global, only the icons you list are bundled
import { createIconSet } from "@azmr/ui/icons";
export const { Icon } = createIconSet({ check: Check, "my-logo": MyLogo });
// 3. A global registry for custom SVGs (names are untyped strings)
import { registerIcons } from "@azmr/ui/icons";
registerIcons({ "my-logo": MyLogo }); // then <Icon name="my-logo" />An unknown name renders nothing and logs one development warning; it never crashes the page.
Migrating from @mui/icons-material. @azmr/ui/icons/mui-map is plain data: resolveMuiIcon("SearchOutlined") returns { name: "Search", variant: "outlined", lucide: "search", tabler: "IconSearch" }. It covers over 300 common Material icons, strips the Outlined / Rounded / Sharp / TwoTone variants (and the legacy Outline spelling older MUI versions export), maps brand icons to Tabler only (Lucide has none), and lists the common names it deliberately does not map in unmappedMuiIcons. Every name in it is checked by tests against the real @mui/icons-material, lucide-react and @tabler/icons-react. The migration codemod uses it and reports anything unmapped instead of guessing.
Entry points
| Import | Contents | Directive |
|---|---|---|
@azmr/ui | everything (Grid, useSignal, cn) | "use client" |
@azmr/ui/hooks | React hooks (useSignal, useDebouncedCallback, useDebouncedValue, useReducedMotion, useUrlTab) | "use client" |
@azmr/ui/theme | server-safe theme helpers: createTheme, cookies, htmlAttributes, createLocale | none, importable from Server Components and middleware |
@azmr/ui/react | ThemeProvider, useTheme, LocaleProvider, useLocale | "use client" |
@azmr/ui/button | Button, buttonVariants | "use client" |
@azmr/ui/input | Input | "use client" |
@azmr/ui/spinner | Spinner | none, importable from Server Components |
@azmr/ui/icons | Icon, createIconSet, registerIcons | none, importable from Server Components |
@azmr/ui/icons/lucide | Icon bound to the curated Lucide set, lucideIcons | none, importable from Server Components |
@azmr/ui/icons/mui-map | resolveMuiIcon, muiIconMap, unmappedMuiIcons (migration data) | none, pure data |
@azmr/ui/lib | cn() and other server-safe helpers | none, importable from Server Components |
cn(...classes) merges class names and resolves conflicting Tailwind utilities, with the last one winning: cn("p-2", "p-4") returns "p-4".
Grid
A reactive data grid that automatically re-renders when its Signal updates.
import { Signal } from "@azmr/core";
import { Grid } from "@azmr/ui";
const customers = new Signal([
{ name: "Aroha", balance: 150 },
{ name: "Mere", balance: 320 },
]);
function App() {
return (
<Grid
signal={customers}
labels={{ name: "Customer", balance: "Balance ($)" }}
/>
);
}When customers.set([...]) is called, the grid updates automatically — no state management needed.
useSignal
Hook to subscribe a React component to any Signal. Uses Signal.subscribe() for push-based updates — the component re-renders only when the signal value actually changes.
import { useSignal } from "@azmr/ui";
function Counter({ count }: { count: Signal<number> }) {
const value = useSignal(count);
return <span>{value}</span>;
}API Reference
<Grid />
| Prop | Type | Description |
|---|---|---|
signal | Signal<T[]> | Reactive data source |
columns | (keyof T)[] | Optional column order |
labels | Partial<Record<keyof T, string>> | Optional column label overrides |
emptyMessage | string | Message shown when data is empty |
useSignal<T>(signal)
Returns the current Signal value and subscribes the component to updates. Built on useSyncExternalStore, so it's safe under React 18/19 concurrent rendering — no tearing between a signal update and a render that's mid-flight.
Security
All cell values are rendered through React's JSX escaping. dangerouslySetInnerHTML is never used.