haze-ui
v1.32.0
Published
A React UI component library powered by react-use-control and Linaria
Maintainers
Readme
Haze UI
The React UI Component Library with controllable states.
English | 简体中文
Features
- Integrated react use control provides component internal states
- Built for React 19+ (
refas a prop,ControlOrValue<T>state protocol) — no React 18 compatibility layer - Keep strict, provide lightweight, composable, and easily extendable components
- Support themes customization
- Support Tree-shaking
- RTL by construction: CSS logical properties, mirrored horizontal-key
semantics (Tabs, Rating, Slider, Carousel, Calendar, menus, …) and
mirrored floating placements under
dir="rtl" - RSC-aware dist: interactive modules carry
'use client'; 36 static, hook-free modules (tokens, Badge, Card, Typography, …) ship without it so they render as React Server Components - Tested like a product: 120+ unit test files, axe accessibility cases in every component test suite, ~50 Playwright e2e scenarios across Chromium/Firefox/WebKit, and pixel-locked visual baselines
Why npm-distributed (not copy-paste)
Interaction states are formulas, not values. Every hover/active/subtle/focus-ring color is a CSS relative-color expression — oklch(from var(--haze-color-primary) calc(l - 0.045) c h) — baked into tokens.css. Override --haze-color-primary on a theme class and the entire interaction-state family re-derives at runtime in the browser, no rebuild, no codegen. In a copy-paste setup every pasted file owns its own copy of that formula, and each theme tweak means re-applying it by hand, file by file.
haze-ui/css-manifest.json is machine-readable data. The authoritative export → CSS-file mapping (family absorption included) is regenerated on every build, so bundler plugins and codemods read one source of truth instead of re-deriving kebab-case file names. A directory of pasted components has no equivalent — the mapping exists only in your head or your fork, and it drifts.
The ControlOrValue<T> protocol needs a real runtime. One prop — checked?: Control<T> | T — covers controlled and uncontrolled usage because react-use-control carries the wiring; a published package is the contract that keeps that behavior identical across every component and version. None of this is hostile to the shadcn / Base UI ecosystem: haze-ui tokens are plain CSS custom properties, and haze components compose alongside copy-pasted primitives wherever each fits.
Getting Started
Quickstart via create-haze-ui
Scaffold a ready-to-run app with the official templates — the CLI has zero runtime dependencies and needs no network access beyond dependency install:
pnpm create haze-ui my-app # vite template (default)
pnpm create haze-ui my-app --template nextjs # or: npm create haze-ui@latest my-app --The vite template is a React 19 + Vite + TypeScript single-page demo
(Button / Input / Dialog / Toast) with the theme classes mounted on
the page root; the nextjs template is a Next.js 15 App Router starter
whose home page renders the RSC-safe static subset (Badge / Card /
Typography) as a server component, with a client island for the
interactive pieces. Prefer wiring an existing project by hand? Continue
with Installation below.
Installation
npm i haze-ui
// or
pnpm add haze-uiInstall via shadcn CLI (GitHub registry)
The repository is also a shadcn registry, so the shadcn CLI installs any component directly from GitHub — no manual npm setup:
pnpm dlx shadcn@latest add wmzy/haze-ui/button
pnpm dlx shadcn@latest add wmzy/haze-ui/dialog wmzy/haze-ui/tabs
pnpm dlx shadcn@latest add wmzy/haze-ui/base # everything at once
pnpm dlx shadcn@latest list wmzy/haze-ui # browse all itemsThe base item (type registry:base) is the one-command path: it
installs the theme-token activation plus a wrapper for every component in
a single shadcn add — the 107 component items are pulled in through
registryDependencies (full wmzy/haze-ui/<item> addresses, since plain
names would resolve against the official shadcn registry).
Every styled export is covered (107 component items — one per CSS
family, so compound components like tabs arrive as Tabs/TabList/
Tab/TabPanel together). What lands in your project is a thin
wrapper file under components/ui/haze/ that re-exports the
component from haze-ui and imports its stylesheet — not vendored
source. The wrapper is your customization layer (fork it, wrap it,
restyle it), while haze-ui itself is installed as a normal npm
dependency and keeps updating through your package manager. Common items:
button, input, select, dialog, tabs, toast, sidebar,
form, data-table, chart, chat-message, plus tokens (the theme
classes) and haze-tokens (a token onboarding guide doc). Each item also
carries docs (markdown usage — description, install command, theme
activation — printed by shadcn view) and categories (kebab-case tags
— form, overlay, data-display, agent, … — used by registry
search/filter tooling). See registry/README.md
for the full picture.
React 19+ by design
The peer range is react: ^19.0.0 on purpose — haze-ui is built on
modern React rather than carrying a compatibility layer for 18:
refas a prop. React 19 passesrefto function components directly, so components that expose refs accept one prop instead of aforwardRefwrapper layer.- A modern-platform baseline. Floating panels (Popover, DropdownMenu,
Tooltip, ContextMenu, Combobox, Datepicker) choose between three tiers
by feature detection — native
popover+ CSS anchor positioning,popoveralone, or a JS fallback — with the newest platform features as the primary path, not an enhancement layered on top. ControlOrValue<T>— theControl<T> | Tstate protocol — is designed for modern React semantics throughout.
On React 18? Upgrade first. haze-ui ships no React 18 compatibility layer, and none is planned.
Migrating from React 18
Once the app itself is on React 19, run through the haze-ui-relevant checklist:
refis a prop. React 19 passesrefto function components directly — drop anyforwardRefwrapper you had around haze components and passreflike any other prop.- No
defaultValuedual track. Stateful components speakControlOrValue<T>(checked?: Control<T> | boolean): one prop covers controlled and uncontrolled usage, so there is nodefaultChecked/defaultValuepair to migrate. Where you used to passdefaultValue, pass the plain value (uncontrolled) or switch to controlled withonChange. - Floating panels assume the modern-platform baseline. Popover,
DropdownMenu, Tooltip, ContextMenu, Combobox and Datepicker
feature-detect between native
popover+ CSS anchor positioning,popoveralone, or a JS fallback — no polyfills to carry over from your React 18 setup, but the floor from Browser support applies. - Colors are OKLCH with runtime-derived interaction states. Requires Chrome/Edge 119+, Safari 16.4+ or Firefox 128+; no HSL/hex fallbacks are shipped.
'use client'is pre-injected on interactive modules. Every stateful module indist/starts with the directive; the static, hook-free set (tokens and presentational components) ships without it so those render as React Server Components (see Server Components).
Dependencies
haze-ui declares only the host environment as peers: react and
react-dom (^19). Everything else is a regular dependency — one
npm i haze-ui and every component works:
react-use-control— the engine behindControlOrValue<T>react-f0rm(^1.3) — the form engine behindFormItem/FormList(the same author's headless form store)@tanstack/react-table— DataTablerecharts— Chart@dnd-kit/core/@dnd-kit/sortable/@dnd-kit/utilities— the sortable TagInput/TagGroup variantsqrcode— QRCode
Swapping any of these engines for another library is invisible to your
code (the one deliberate exception: DataTable's column API exposes
TanStack's ColumnDef types). The dist is ESM with preserveModules
and side-effect-free JS, and engines are imported only by the components
that need them, so bundlers (Next.js, Vite, webpack, Turbopack, Rollup)
tree-shake the unused re-export chains: import { Button } from
'haze-ui' never pulls @tanstack/react-table into the bundle. Engines
install as regular dependencies, so even bundler-less consumers (bare
Node ESM importing the barrel, which links the module graph eagerly)
work with zero manual installs; the haze-ui/components/DataTable and
haze-ui/components/Chart subpaths bypass the barrel entirely.
Browser support
Since v1.13, haze-ui colors are generated in OKLCH, with interaction states (hover/active/subtle/focus-ring) derived at runtime via CSS relative color syntax. This requires Chrome/Edge 119+, Safari 16.4+, or Firefox 128+; no HSL/hex fallbacks are shipped.
Usage
Import the component and its CSS. Two CSS loading modes:
// Full stylesheet (simplest, ~12kB gzipped)
import 'haze-ui/styles.css';
import { lightTheme, spacing, typography, Button } from 'haze-ui';
// …or per-component CSS (pay only for what you render).
// Always load tokens.css once, then each component you use:
import 'haze-ui/css/tokens.css';
import 'haze-ui/css/button.css';
import { Button } from 'haze-ui';
// …or zero CSS imports at all — the official Vite plugin injects the
// right per-component CSS for you (see "Automatic CSS with haze-ui-vite"):
// vite.config.ts → hazeCss() from 'haze-ui-vite'
import { Button } from 'haze-ui';
export default function MyComponent() {
return <Button>Start</Button>;
}Automatic CSS with haze-ui-vite
haze-ui-vite is the
official companion Vite plugin: it scans each module's named imports from
haze-ui (import { Button, Select } from 'haze-ui'), maps them through
haze-ui/css-manifest.json, and prepends the matching
haze-ui/css/*.css side-effect imports — tokens.css always first. CSS
enters the module graph exactly where it's consumed, so code-split views
carry only their own component CSS and HMR works through Vite's native
pipeline.
// vite.config.ts
import hazeCss from 'haze-ui-vite';
export default {
plugins: [react(), hazeCss()],
};It reads the manifest shipped by your installed haze-ui version (no
hardcoded name mapping), validates every injected file exists in the
actual package, and warns on namespace imports it cannot collect. Works
with Vite 5–8, zero runtime dependencies. Not on Vite? Keep the explicit
imports above, or read haze-ui/css-manifest.json from your own bundler
tooling — it's the same data the plugin uses.
Component CSS files are kebab-case versions of the component name
(OTPInput → haze-ui/css/otp-input.css). Per-component files only
cover that component's rules — tokens (themes, spacing, typography)
always come from haze-ui/css/tokens.css.
Don't hardcode that kebab-case rule in tooling: sub-components and cores
share their directory's family file (InputCore → input.css,
ButtonLink → button.css, Title/Text → typography.css). The
authoritative export → css-file mapping ships as data:
haze-ui/css-manifest.json, generated at build time by
scripts/split-css.mjs from the actual CSS output (never hand-edited):
{
"families": { "Button": "button", "ButtonLink": "button", "InputCore": "input", "useToast": "toast" },
"noCss": ["COMPONENT_TOKENS", "TOKEN_REGISTRY", "useControl", "useTitle"]
}families covers every named export that has styles (family absorption
included); noCss lists pure-logic exports with no css of their own.
Bundler plugins and codemods should read this manifest instead of
re-deriving file names — the mapping changes in lockstep with the build.
The official haze-ui-vite
plugin (above) is the reference consumer of this data.
Styling & overriding (the @layer haze contract)
All shipped CSS lives inside @layer haze, so your own unlayered
styles beat every library rule regardless of source order or
specificity — no !important, no selector wrestling:
/* your stylesheet, plain CSS — always wins over the library */
[data-slot='button'] {
letter-spacing: 0.02em;
}Every element a component renders carries a stable data-slot
attribute (data-slot='button', 'label', 'panel', 'menu-item',
…). It is the official override surface: the attribute set is enforced
by a coverage test, so it cannot silently drift between releases. The
cascade is intentional — prefer, in order:
- tokens —
var(--haze-color-primary)and friends re-derive whole interaction-state families at runtime; per-component--haze-button-*variables retheme one family; ConfigProviderdefaults — app-wide per-component prop defaults (andclassNameswhere the component exposes slots);data-slotCSS targeting — structural overrides (the escape hatch above);className/classNames— per-instance overrides, reserved for one-offs.
If your own framework ships layered styles too, declare order explicitly
(e.g. @layer haze, app;) to keep the last word.
Server Components (Next.js App Router)
Interactive modules in dist/ carry the 'use client' directive,
injected at build time — the same convention Radix, Base UI and React
Aria ship. In an App Router project you import haze-ui straight from
your client components; there is no need for a wrapper module that
re-exports the library under its own 'use client' banner:
// any client component — import straight from the package
import { Button } from 'haze-ui';
export function Actions() {
return <Button>Start</Button>;
}A static subset renders as React Server Components. Modules with no hooks and no DOM access ship without the directive, so they can be imported directly into server components and cost zero client JS:
- tokens: everything on the
haze-ui/tokenssubpath (lightTheme,darkTheme,spacing,typography,TOKEN_REGISTRY, OKLCH utils) - components:
AspectRatio,Badge,Card,CodeBlock,Container,Divider,Flex,Grid/GridItem,Icon,Kbd,Skeleton,Stat/StatGroup,Typography(Title/Text/Paragraph)
Import them from the subpath (import { Badge } from
'haze-ui/components/Badge') or the barrel — in an RSC module the
bundler resolves the module itself, not the client-marked barrel shell.
The emitted markup carries the usual Linaria class names, so CSS loading
is unchanged (haze-ui/styles.css or the css/* subpaths). The safe
list lives in scripts/rsc-safe.mjs and is enforced by
src/lib/dist-esm-contract.test.ts (including a transitive-closure
check: every static import inside a safe module must itself be safe) —
new components only join the list when they stay hook-free.
CSS loading is unchanged from the two modes above — haze-ui/styles.css
in the root layout, or the haze-ui/css/* subpaths. A runnable Next.js
15 project lives in examples/nextjs.
ButtonLink: a real anchor with the Button skin
Navigation that must look like a button should still be a link —
rendering as={Button} drops href onto a <button> (an invalid
attribute: no ⌘/middle-click new tab, nothing for crawlers or no-JS).
ButtonLink renders a native <a> wearing Button's full appearance —
same variant/size/square props, same hover/active/focus and
disabled visual states:
import { ButtonLink } from 'haze-ui';
<ButtonLink href='/page/2' variant='outline'>Next page</ButtonLink>
// anchors have no `disabled` attribute — report the state with
// aria-disabled (+ tabIndex={-1} to leave the focus order); ButtonLink
// styles it exactly like Button's :disabled
<ButtonLink href='/prev' aria-disabled tabIndex={-1}>← Previous</ButtonLink>Everything else extends the native <a> attributes and is spread onto
the anchor (target, rel, download, aria-*, …), with the ref
forwarded — the same composition shape NavLink uses, so routers can
swap their own Link element through an as prop:
// with a typed router Link (href + SPA onClick injected by the router):
<TypedLink to='/articles' search={{offset: 20}} as={ButtonLink}>
Next page
</TypedLink>Both components share one skin (a styles module), so a theme tweak to
Button re-skins ButtonLink in lockstep. CSS: haze-ui/css/button.css
covers both.
AsyncSection: loading / error / content in one place
AsyncSection collapses the three states every async view hand-rolls into
one component: loading renders the spinner placeholder, a non-null
error renders an alert box with an optional Retry button (an Error
instance contributes its message; errorText overrides), and otherwise
the children render. loading wins when both are set — the retry path
(loading again before the old error clears) shows the placeholder, not
the stale error. All copy is configurable; the retry button only renders
when onRetry is provided.
import 'haze-ui/css/tokens.css';
import 'haze-ui/css/async-section.css';
import { AsyncSection } from 'haze-ui';
<AsyncSection loading={loading} error={error} onRetry={refetch}>
{data}
</AsyncSection>useTitle: view-level document.title
useTitle(title) sets document.title while the view is mounted and
restores the pre-entry title (the static <title> from the host page) on
unmount. Two timing pitfalls are baked into the implementation: the write
and the restore are two separate effects (a single [title] effect would
restore the previous round's title, not the entry default, on every prop
change), and the entry snapshot is taken at effect time, not render time —
a route swap is one commit, so at render time the old view's cleanup has
not run yet and document.title still holds the previous page.
import { useTitle } from 'haze-ui';
function SettingsView() {
useTitle('Settings');
// ...
}Anchor: scroll-spy navigation
Sticky anchor navigation with an IntersectionObserver scroll-spy (a
scroll-listener fallback covers engines without IO). Pass items, and
the highlighted section is a controllable state like everything else —
activeId?: ControlOrValue<string>. Clicks scroll the container
(getContainer defaults to the window) with an offsetTop adjustment,
and the active link carries aria-current="true".
import 'haze-ui/css/tokens.css';
import 'haze-ui/css/anchor.css';
import { Anchor } from 'haze-ui';
<Anchor items={[{ id: 'intro', label: 'Intro' }, { id: 'api', label: 'API' }]} />;Watermark: tiled canvas watermark
A canvas-tiled watermark layer: text rows (content: string | string[])
rendered onto an off-screen tile (rotated, gap-spaced,
device-pixel-ratio aware) and repeated as a background over the
container — or the whole viewport with fullscreen. Default color
follows the theme's --haze-color-text-muted; the layer is
pointer-events: none and aria-hidden, and engines without a canvas
2d context degrade to no watermark rather than crashing.
<Watermark content="CONFIDENTIAL" rotate={-22}>
<Report />
</Watermark>Fullscreen: wrap-mode fullscreen binding
useFullscreen(target?) returns [isFullscreen, toggle, handle] — the
Fullscreen API wired to ControlOrValue semantics: Fullscreen
wraps a single trigger child (its onClick is preserved and composed)
and exposes fullscreen?: ControlOrValue<boolean>, so browser-side
exits (Esc, OS gestures) write back through the control and fire
onChange exactly once.
const [fs, setFs, fsCtrl] = useControl(undefined, false);
<Fullscreen fullscreen={fsCtrl} onChange={setFs}>
<Button>Enter focus mode</Button>
</Fullscreen>Utility hooks
All hooks ship on the main barrel (no separate import path) and follow
the same conventions as the components: ControlOrValue where state is
user-controllable, SSR/jsdom-safe when the underlying API is missing.
| Hook | Returns | Notes |
| --- | --- | --- |
| useClipboard(resetMs?) | { copied, copy } | copied resets after resetMs |
| useDebouncedCallback(fn, delayMs) | debounced fn | Stable identity across renders |
| useDebouncedValue(value, delayMs) | T | Trails the input by delayMs |
| useFullscreen(target?) | [isFullscreen, toggle, handle] | See section above |
| useHotkeys(map, options?) | — | 'mod+s'-style specs (mod normalizes to ⌘ on macOS, Ctrl elsewhere); skips events from editable targets unless allowInInput; hotkey(...keys) builds specs type-safely |
| useInView(options?) | [refCallback, inView] | IntersectionObserver; once freezes after the first hit; false forever when the API is missing (SSR) |
| useLocalStorage(key, control?, initial?) | [value, setValue] | ControlOrValue semantics, cross-tab storage sync, corrupt JSON self-heals to initial |
| useSessionStorage(key, control?, initial?) | [value, setValue] | Same kernel, sessionStorage scope |
| useMediaQuery(query) | boolean | Reactive matchMedia; false when missing |
| usePrefersReducedMotion() | boolean | Token-aware animation gating |
| useTitle(title) | — | Document title while mounted |
| useToast() | toast API | Requires a ToastContainer ancestor |
Sortable tags and chips
Drag reordering is an opt-in variant: SortableTagInput /
SortableTagInputCore and SortableTagGroup statically import
@dnd-kit/core / @dnd-kit/sortable / @dnd-kit/utilities (regular
dependencies — the plain TagInput/TagGroup never touch them, so they
stay out of every bundle that doesn't import a Sortable variant).
SortableTagInput writes the new array through onChange;
SortableTagGroup reports onReorder(nextOrder) (the new arrangement of
original child indexes) and leaves re-rendering to the parent. Sorting is
keyboard-complete: focus a tag, Space to lift, arrows to move, Space
to drop, Escape to cancel.
Imperative overlay handles
Dialog, Drawer, BottomSheet, Popover and DropdownMenu accept a
ref (React 19 style — no forwardRef) exposing
{ open(), close(), focusTrigger() }. The methods drive the same state
outset as user interaction — Dialog's close() goes through the
native close event path so onClose and focus restoration fire
exactly once — and they honor controlled mode, writing through the
control.
const dialog = useRef<DialogHandle>(null);
<Dialog ref={dialog} …>…</Dialog>;
<button onClick={() => dialog.current?.open()}>Show</button>;View Transitions on modals (opt-in)
Dialog, Drawer and BottomSheet accept viewTransition?: boolean
(default false). When enabled, their open/close state flips are wrapped
in document.startViewTransition(() => flushSync(...)) — the synchronous
DOM-update pattern React requires — so modals animate through the native
View Transitions API alongside their regular enter/exit animations. The
transition itself degrades to a plain state flip when the engine lacks
startViewTransition or the user prefers reduced motion; transition
appearance is yours to style via ::view-transition-*. Controlled
consumers' own flips happen outside the component and are intentionally
not wrapped.
Design tokens export (Figma)
toDesignTokens() converts the token registry into a W3C Design Tokens JSON file — grouped by category (color / font / spacing / dimension / shadow), each token carrying a W3C $type and the source CSS variable in $extensions['haze-ui.css-var'], with $value being the resolved value of the chosen theme (light by default). The Theme Editor ships a one-click entry: the Export W3C tokens (.json) button in the toolbar downloads the file for the mode currently being edited, live edits included. The exported file can be imported directly into Figma Tokens / Tokens Studio.
{
"color": {
"color-primary": {
"$value": "oklch(0.563 0.241 260.8)",
"$type": "color",
"$extensions": { "haze-ui.css-var": "--haze-color-primary" }
}
},
"spacing": {
"space-2": {
"$value": "8px",
"$type": "dimension",
"$extensions": { "haze-ui.css-var": "--haze-space-2" }
}
}
}Brand themes: presets, runtime generation, CLI
Five preset brand themes ship as complete theme classes — violetTheme,
tealTheme, cyanTheme, orangeTheme, roseTheme — each a drop-in
replacement for lightTheme/darkTheme with primary/info/focus-ring
rerouted to the brand's scale.
Runtime custom brands. createBrandTheme (from haze-ui or
haze-ui/tokens) runs the exact preset pipeline from your own seed hex:
import { createBrandTheme } from 'haze-ui/tokens';
const brand = createBrandTheme({
name: 'acme', // → --haze-acme-1…12 (lowercase kebab; not gray/green/amber/red)
light: '#0066ff', // light-mode primary seed (scale step 9)
dark: '#0a3d99', // optional — derived from light (oklch l +0.2) when omitted
overrides: { light: { 10: '#0052cc' } }, // optional extra anchors
});
// brand.light / brand.dark: bare declaration blocks — wrap in classes once:
// styleEl.textContent = `.acme-light { ${brand.light} }\n.acme-dark { ${brand.dark} }`;The blocks are theme replacements (never stack on lightTheme), and you
own contrast: pick a light seed dark enough for white text — the presets'
step-9 anchors sit near Tailwind's 600/700 range for that reason.
CLI. Zero Node dependencies, identical output:
npx haze-ui-theme --primary '#0066ff' [--dark '#0a3d99'] [--name brand] [--out brand.css]W3C design-token archives. Every preset is also exported per mode as W3C DTF JSON:
import violetLight from 'haze-ui/design-tokens/violet/light.json';Component-level tokens
Global --haze-* tokens theme the whole system; component-level tokens
theme one component family without touching anything else. They are plain
CSS custom properties consumed through a fallback chain baked into the
component's own stylesheet:
/* inside haze-ui/css/button.css */
height: var(--haze-button-height-md, auto);The library never defines these variables — nothing to register, no
runtime. When you set one on :root or any ancestor of the component, your
value wins; when you don't, the built-in fallback applies and the component
looks exactly as shipped. Scoping is ordinary CSS inheritance: set the
variable on a wrapper element to retheme only that subtree.
Button/ButtonLink (pilot — they share one skin, so both change
together; Toggle and the Toolbar items wearing the same skin follow
along):
| Token | Fallback (default look) |
| ---------------------------------- | ----------------------- |
| --haze-button-height-sm/md/lg | auto (content-driven) |
| --haze-button-font-size-sm/md | var(--haze-text-sm) |
| --haze-button-font-size-lg | var(--haze-text-base) |
| --haze-button-radius | var(--haze-radius-md) |
Dialog:
| Token | Fallback (default look) |
| -------------------------------- | ------------------------ |
| --haze-dialog-width | 480px (on max-width) |
| --haze-dialog-radius | var(--haze-radius-xl) |
| --haze-dialog-padding | var(--haze-space-6) |
| --haze-dialog-title-gap | var(--haze-space-4) |
| --haze-dialog-title-font-size | var(--haze-text-lg) |
/* app-wide denser buttons; every other component keeps its sizing */
:root {
--haze-button-height-md: 32px;
}
/* only dialogs inside this subtree get wider */
.wide-dialogs {
--haze-dialog-width: 720px;
}COMPONENT_TOKENS (exported from haze-ui and haze-ui/tokens) lists
each component's component-level tokens alongside the global tokens it
consumes.
AI-friendly distribution
llms.txt — a markdown overview of the whole library (the ControlOrValue<T>
state protocol, both CSS loading modes, all 107 components grouped with one-line
purposes, the token system, the floating-overlay tiers, form integration) written
for AI coding tools and crawlers. It lives at the repo root
(llms.txt) and, on the docs site, at
https://wmzy.github.io/haze-ui/llms.txt (build:demo copies it into dist/).
registry.json — an agent-installable registry shipped as the
haze-ui/registry.json npm artifact (regenerated by
scripts/generate-registry.mjs on every build), also reachable at
https://unpkg.com/haze-ui/registry.json. Point any registry-aware
tooling at it — the shadcn CLI works:
pnpm dlx shadcn@latest add https://unpkg.com/haze-ui/registry.jsonEvery item is a wrapper file, not vendored source: it re-exports the
component from the published haze-ui npm package and imports its
stylesheet, so haze-ui stays a normal npm dependency that keeps
updating underneath — the shadcn CLI is only the distribution channel.
That is the point: AI coding agents (and anyone who wants a fast setup
while keeping the npm update path) get working imports in one step,
without the copy-paste expectation of owning pasted source. When you
want different styles or behavior, fork the generated wrapper file — it
is your customization layer, not a source drop. Coverage: every styled
export (107 css families / 185 components), generated from the build's
own css manifest. The same items are also installable straight from this
GitHub repository — see
Install via shadcn CLI (GitHub registry).
AI / MCP
haze-ui-mcp — an MCP server that puts the
whole component reference in your AI coding agent's tool belt. It serves the
same docs the site renders (props tables with types/defaults/descriptions,
import statements, CSS paths, peer deps, the full token registry), so the
agent never guesses a prop name or a stylesheet path again:
| Tool | What it returns |
| --- | --- |
| haze_list_components | Every component with its css family |
| haze_get_component | One component's full reference — props tables, import statement, CSS imports. Name matching is case-insensitive and accepts kebab-case (data-table) or PascalCase (DataTable) |
| haze_get_tokens | The --haze-* token registry with light and dark values, optionally filtered by category (color, typography, spacing, radius, shadow) |
| haze_search_docs | Top-10 ranked matches across component names, css families, prop names and descriptions |
Claude Code:
claude mcp add haze-ui -- npx haze-ui-mcpCursor (.cursor/mcp.json):
{
"mcpServers": {
"haze-ui": {
"command": "npx",
"args": ["haze-ui-mcp"]
}
}
}The server speaks stdio JSON-RPC and ships its own docs snapshot
(dist/mcp-docs.json, regenerated on every build by
scripts/generate-mcp-docs.mjs) — no project access, no indexing on startup.
In this repository you can run it directly against the local snapshot:
node scripts/generate-mcp-docs.mjs
node mcp/index.mjsPoint it at a custom snapshot with HAZE_UI_MCP_DOCS=/path/to/mcp-docs.json.
AI runtime integration (Vercel AI SDK useChat)
haze-ui ships the agent chat surface as presentational components —
ChatContainer (scrolling), ChatMessage (bubbles), StreamingText
(typewriter reveal), ToolCallCard (tool input/output), ThinkingIndicator
(waiting dots), ChatInput (composer) — with no opinion about where
messages come from. The Vercel AI SDK's useChat is the stateful half: it
owns messages (a UIMessage[] of typed parts), status
(submitted | streaming | ready | error) and the sendMessage/stop
pair. One small adapter bridges them — pure functions over duck-typed
shapes, so the ai package never enters your dependency tree through
haze-ui:
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { Button, ChatContainer, ChatInput } from 'haze-ui';
import { useHazeChat } from './ai-chat-adapter'; // the recipe module
function SupportChat() {
const chat = useChat({ transport: new DefaultChatTransport({ api: '/api/chat' }) });
const { messages, busy, send, stop } = useHazeChat(chat);
return (
<div>
<ChatContainer>{messages}</ChatContainer>
<ChatInput disabled={busy} onSend={send} />
{busy && <Button variant='outline' onClick={stop}>Stop</Button>}
</div>
);
}The mapping rules: text parts → ChatMessage bubbles (StreamingText
typewriter while a part streams), reasoning → ThinkingIndicator then a
collapsible transcript, tool-* parts → ToolCallCard driven by v5
states (with v4 tool-call/tool-result pairs folded into one card by
toolCallId), sources → links; status: submitted → thinking dots before
the first token, error → a system bubble with the transport error. The
whole thing is one small, unit-tested module that never throws on odd
payloads — unknown part types are skipped, so future SDK parts degrade
instead of crashing.
The full recipe — live runnable demo (same adapter over a mock streaming
source, no network), the complete adapter source, the useHazeChat hook,
per-part mapping table and known limits (attachments, branching, voice out
of scope; StreamingText restart-on-grow caveat for char-by-char
transports) — lives on the docs site:
https://wmzy.github.io/haze-ui/recipes.
Headless primitives
The behavior layer the styled components are built on ships as its own
subpath: haze-ui/headless. Component authors assembling their own panels
get the same primitives Popover, DropdownMenu and Tooltip use internally —
with the same version, no re-implementation:
useFloating— the three-tier floating engine (nativepopover+ CSS anchor positioning →popoveralone → JS fallback), plusplaceFloatingPanel,useFloatingPositionand the placement/panel typesPresence— mount/unmount with exit animationsuseFocusScope/isTabbable/getTabbables— focus trapping and tab-order queriescomputeFloatingPosition— the flip/shift collision math as a pure function
import { useFloating, Presence } from 'haze-ui/headless';Stable public API — same semver commitment as the component library: breaking changes land only on a major version.
react-f0rm Integration
react-f0rm owns form field state, and its headless useField hook is
the single binding layer — the same channel its built-in
Field/Checkbox/Select components use. haze-ui contributes the
views: controlled cores (InputCore, SelectCore, SwitchCore, TextareaCore,
TagInputCore, TransferCore, UploadCore, ...) take the plain {value, onChange}
pair with zero adapters, and FormItem wraps the hook's state in label,
error and aria wiring. The sugar components (Input, Select, ...)
keep their ControlOrValue<T> (Control<T> | T) API for standalone use outside forms.
Beyond FormItem, the haze-ui barrel re-exports the whole form layer
so consumers never import react-f0rm directly: FormList (array fields
over useFieldArray — append/prepend/insert/remove/swap/move with
stable row keys), FormProvider/useFormContext (FormItem/FormList
fall back to context when the form prop is omitted), the imperative
APIs (setValue, getValue, reset, trigger, setFocus,
setServerErrors, useWatch, ...), and schema resolvers:
import { FormItem, FormList, zodResolver, standardSchemaResolver } from 'haze-ui';zodResolver adapts any zod schema (v4) as a field validator;
standardSchemaResolver adapts any Standard Schema v1 implementation
(zod, valibot, arktype, ...) the same way. Field controls forward refs
to their focusable element and FormItem wires f0rm's focusRef
channel, so setFocus(form, 'name') and a failed submit's
first-error auto-focus land on the real input.
useField: field → {value, onChange}
import { useForm, useField } from 'react-f0rm';
import { InputCore } from 'haze-ui';
function NameField({ form }) {
const { value, onChange } = useField({ form, name: 'name' });
return <InputCore value={value} onChange={onChange} />; // two-way bound
}The hook subscribes to the field (sibling fields stay isolated), and
onChange writes through react-f0rm's user-change channel — a write
fires exactly the validation a user typing into the field would fire:
the field's effective mode (a FormItem/useField per-field override
included) and the form's reValidateMode. With the default
mode: 'onSubmit' + reValidateMode: 'onChange', typing through a bound
core after a failed submit re-validates per keystroke and clears the
error as soon as the value is valid — no blur, no resubmit. onChange
accepts plain values only (controlled cores emit the next value, never
functional updaters; read the previous value from value on the next
render). reset(form, newValues) re-seeds every binding with no
remounting.
FormItem: label, errors and aria wiring
import { Form, useForm } from 'react-f0rm';
import { FormItem, InputCore } from 'haze-ui';
function ProfileForm() {
const form = useForm({ initialValues: { email: '' } });
return (
<Form form={form} onSubmit={...}>
<FormItem
form={form}
name="email"
label="Email"
validate={(v) => (v.includes('@') ? undefined : 'must be an email')}
>
{({ id, errorId, invalid, value, onChange }) => (
<InputCore id={id} value={value} onChange={onChange} aria-invalid={invalid} aria-describedby={errorId} />
)}
</FormItem>
</Form>
);
}FormItem generates the field/error ids, renders <label htmlFor>, and
surfaces the first error in a role="alert" element — no manual
FieldError wiring.
as: declarative binding (react-f0rm Field-style)
Skip the render-prop: pass any component as as and FormItem wires the
id, aria attributes, onBlur and onChange itself — the same props shape
as react-f0rm's Field, not Radix's asChild. as and the children
render-prop are mutually exclusive.
// text field: nothing else to wire
<FormItem form={form} name="email" label="Email" as={InputCore} />
// checkbox-style control: value lives in `checked`
<FormItem
form={form}
name="subscribed"
label="Subscribe"
as={CheckboxCore}
valueToProps={(checked) => ({ checked: !!checked })}
/>
// DOM-element-shaped control: adapt event and value in one line each
<FormItem
form={form}
name="email"
as={NativeInput}
eventToValue={(e) => e.target.value}
renderError={(error, id) => <em id={id}>{error}</em>}
/>eventToValuedefaults to identity — haze cores'onChangeemits the next plain value; pass(e) => e.target.valuewhenasis a raw DOM element component.asPropsspreads extra props onto the control before the value props, sovalue/valueToPropswin conflicts (Field.tsx precedence).renderError(error, id)replaces the built-in error span's content; the span itself (id,role="alert", styling) stays FormItem's.- With a typed form,
validate's value argument is the field's actual type (PathValueOf<TValues, P>), notany.
input: declarative binding for cores and raw DOM controls (typed prop forwarding)
The ergonomic form for the controlled cores — pass the component and the rest of the JSX goes straight to it, type-checked against its own props:
<FormItem
form={form}
name="email"
label="Email"
input={InputCore}
placeholder="[email protected]"
mode="onBlur"
validate={(v) => (v.includes('@') ? undefined : 'must be an email')}
/>
// JSX children forward too — a SelectCore's options:
<FormItem form={form} name="role" label="Role" input={SelectCore}>
<option value="admin">Admin</option>
<option value="viewer">Viewer</option>
</FormItem>
// checkbox-style controls keep the valueToProps adapter:
<FormItem
form={form}
name="subscribed"
label="Subscribe"
input={CheckboxCore}
valueToProps={(checked) => ({ checked })}
/>input wires the same id/aria/onBlur/onChange/value contract as as
— every haze core (InputCore, TextareaCore, SelectCore,
TagInputCore, TransferCore, UploadCore, CheckboxCore, SwitchCore, …)
speaks the plain {value, onChange} pair, so the default adapters need
nothing (TagInputCore's onChange already emits the next string[];
TransferCore emits the next string[] plus move metadata, UploadCore
the next File[]; a checkbox-style core pairs with valueToProps). The
differences from as:
- Forwarded props are type-checked against the core's own props —
input={InputCore} size="xl"is a compile error, whileasPropsis an untyped bag. - JSX children forward to the core (a
SelectCore's<option>s); the render-prop children andinputare mutually exclusive (a render-prop next toinputthrows — it's a migration leftover). - The wiring (
id,aria-invalid,aria-describedby,onBlur,onChange,value/checked) and FormItem's own prop names are reserved: they are excluded from the forwarded type and always win at runtime. A control prop that collides with one (e.g. CheckboxCore's ownlabel) is unreachable throughinput— use the render-prop oras/asPropsfor it.
input also takes raw DOM bindings — no core required. The two raw
forms are explicit about their eventToValue adapter, so the value
channel is never guessed:
// a native form element: the binding pairs the tag with its adapter,
// and the rest of the JSX is type-checked against that element's own
// HTML attributes (rows on a textarea, options as a select's children)
<FormItem
form={form}
name="bio"
label="Bio"
input={{element: 'textarea', eventToValue: (e) => e.target.value}}
rows={4}
/>
// a DOM-element-shaped component: the top-level eventToValue is the
// explicit opt-in from plain-value (core) to event-emitting (raw)
<FormItem
form={form}
name="email"
label="Email"
input={NativeInput}
eventToValue={(e) => e.target.value}
/>- The element binding accepts
'input' | 'textarea' | 'select'and requires itseventToValue—input={{element: 'input'}}without the adapter is a compile error (at runtime an untyped caller that skips it still getse.target.value, the DOM contract, never an Event in the store). - The top-level
eventToValuenext to a componentinputswitches that binding to raw semantics, mirroring theaschannel; forwarded props still check against the component's own props. - The same reserved-prop rule applies:
id,onBlur,onChange,value/checked, aria-*, and FormItem's own names are never forwarded on the raw channel either.
mode: per-field validation timing (react-f0rm ≥ 0.6)
Pass mode to validate one field on its own schedule instead of the
form-wide validation mode — other fields are unaffected. It accepts
react-f0rm's ValidationMode values: 'onSubmit' (default form
behavior), 'onBlur', 'onChange', 'onTouched' or 'all'. Omit it to
keep the form's mode.
<FormItem
form={form}
name="email"
label="Email"
mode="onBlur"
validate={(v) => (v.includes('@') ? undefined : 'must be an email')}
>
{({ id, errorId, invalid, onBlur, value, onChange }) => (
<InputCore
id={id}
value={value}
onChange={onChange}
aria-invalid={invalid}
aria-describedby={errorId}
onBlur={onBlur}
/>
)}
</FormItem>With mode="onBlur" (and the binding's onBlur passed to the core, as
above) the email field is validated the moment it loses focus — no
submit needed. mode accepts 'onSubmit', 'onBlur', 'onChange',
'onTouched' or 'all'; only this field's schedule changes, the rest
of the form keeps its own mode.
validateDebounce / delayError / rules (react-f0rm ≥ 0.6)
FormItem passes these field-level options straight through to
react-f0rm's useField:
validateDebounce={300}— debounce this field's validation kicks: only the last kick inside the window runs the validator (e.g. keeps a per-keystroke async validator from firing while the user types fast). While the timer is pending the field counts as validating, sotrigger/submit wait it out.delayError={500}— delay showing a newly appearing error in the rendered error span (and the binding'sinvalid/errors). The form's error state stays immediate — submit andgetErrorstill gate on it. An error that clears inside the window never shows.rules={{ required: 'Email is required', minLength: 4, pattern: { value: /@/, message: 'Must be an email' } }}— declarative constraints (a subset of react-hook-form'sregisterrules) compiled into a validator that runs beforevalidate; both sources' errors merge into the field's error list, rules errors ahead.
<FormItem
form={form}
name="email"
label="Email"
validateDebounce={300}
delayError={500}
rules={{ required: 'Email is required' }}
validate={(v) => (v.includes('@') ? undefined : 'must be an email')}
>
{({ id, errorId, invalid, value, onChange }) => (
<InputCore id={id} value={value} onChange={onChange} aria-invalid={invalid} aria-describedby={errorId} />
)}
</FormItem>All three are optional; omit them and the field behaves exactly as
before (immediate validation per the form's mode, immediate error
display, validate-only).
Accessibility & RTL
Accessibility is a baseline, not an opt-in: every component's test suite
includes axe checks, and the token sheet ships a global
@media (prefers-reduced-motion: reduce) block that collapses every
animation duration token to 0ms — motion-sensitive users get instant
state changes with no per-component wiring.
haze-ui supports RTL via CSS logical properties wherever a side is
semantic (the start/end of reading flow), not just decorative. Set
dir="rtl" (or direction: rtl) on an ancestor and those sides mirror
automatically — no component props change. The same goes for keyboard
semantics: horizontal arrow keys invert their meaning in RTL (Tabs,
Rating, Slider, Carousel, Calendar, Toolbar, Cascader, Tour and
horizontal menus), and floating placements are logical — a panel with
placement="bottom" in an RTL document lands on the mirrored inline
side without passing a mirrored placement. src/lib/rtl.test.tsx is a
four-layer contract suite (rendering + axe, keyboard mirroring, floating
mirroring, and a CSS-codemod guard with an explicit exemption list),
backed by e2e/rtl.spec.ts cross-engine checks.
LocaleProvider takes an optional direction prop ('ltr' | 'rtl',
explicit wins over derivation from the locale — Arabic, Hebrew, Farsi
and friends are derived automatically) and feeds useDirection(), the
hook components use internally. getDirection(el) (DOM resolution with
[dir] fallback) and localeDirection(locale) are exported from the
barrel alongside it, plus createStrings(base, overrides) — a
DeepPartial<HazeStrings> deep-merge for deriving locale packs without
forking a whole string table (see the Adding a locale recipe on the
docs site).
Direction-responsive by construction (no physical CSS)
- Progress (bar) — the fill is a normal-flow block with a percentage
width, so it grows from the inline-start edge (right in RTL) with noleft/rightin its CSS. - Slider — a native
<input type="range">; the browser mirrors fill and thumb underdir="rtl". - Flex gap /
flex-direction: row— gap and row order follow the writing mode, so icon-to-label spacing in Tag, ChatMessage, Alert, etc. mirrors for free. - Carousel track scrolling — uses
scrollIntoView({ inline: 'start' }), a logical scroll position.
Fixed: physical → logical conversions
| File | Change |
| --- | --- |
| Carousel.tsx | prev/next buttons left/right → inset-inline-start/inset-inline-end, plus a [dir='rtl'] scale: -1 1 mirror so the ‹/› glyphs point along the reading direction |
| ChatMessage.tsx | bubble tail corners border-bottom-right-radius / border-bottom-left-radius → border-end-end-radius / border-end-start-radius (tail follows the bubble's anchored side) |
| Chip.tsx | close-button margin-left → margin-inline-start |
| Container.tsx | margin-left/right: auto → margin-inline: auto; padding-left/right → padding-inline |
| ContextMenuItem.tsx, DropdownMenuItem.tsx, MenuItem.tsx, ConversationItem.tsx | text-align: left → text-align: start |
| DiffViewer.tsx | line-number gutter text-align: right → end, border-right → border-inline-end (gutter stays on the leading side) |
| List.tsx | padding-left → padding-inline-start (list indent), both variants |
| NavigationBar.tsx | end-slot margin-left: auto → margin-inline-start: auto |
| StepTimeline.tsx | connector line left → inset-inline-start (stays under the inline-start marker column) |
| Stepper/Step.tsx | connector left: 50% → inset-inline-start: 50% (extends toward the next step) |
| TableHead.tsx | th { text-align: left } → start |
| TreeItem.tsx | checkbox/icon margin-right → margin-inline-end; indent guide border-left → border-inline-start |
Kept physical on purpose — glyph geometry or symmetric layout, not
reading-flow sides: the rotated border chevrons in Accordion/Disclosure,
Checkbox's rotated checkmark, Radio's centered dot, Affix's symmetric
left: 0; right: 0 stretch.
Partial support: known gaps
- Floating panels (Popover, DropdownMenu, Tooltip, ContextMenu,
Combobox, Datepicker) — fixed:
FloatingPlacementis logical now. Tier 1 uses logicalposition-areavalues (span-inline-end block-end, …) where engines accept them and falls back to dir-switched physical classes; the tier-2 JS math (utils/collision.ts→computeFloatingPosition(..., { dir })) mirrors the x-axis. In RTL the panels open on the mirrored side automatically. - Input adornments —
SelectCore/ModelPickerchevrons (background-position: right …+padding-right) andPasswordInputCore's absolute reveal button sit on the physical right with matching padding. Consistent under RTL, but not mirrored. - Progress (circle) — SVG
stroke-dashoffsetfill runs clockwise regardless of direction (SVG has no inline axis). - Physical-by-design placement APIs —
Drawerplacement('left'/'right'),Toastplacement ('top-left', …),SwipeActionleft/right action edges,BackToTop's bottom-right corner,CodeBlock's top-right language badge: the side is the API, so it stays physical.
Workaround for remaining gaps
Wrap with dir="rtl" for everything above; for the physical-by-design
cases, override with logical insets through the className every
component accepts:
<Drawer placement="right" className="rtl-drawer" />[dir='rtl'] .rtl-drawer {
/* nudge a physical placement back to the reading-flow side */
inset-inline-end: 0;
}Related Projects
How to Contribute
Anyone and everyone is welcome to contribute.
License
FAQ
Components render without any styles
haze-ui ships styles as separate CSS subpaths — the JS entry does not import any stylesheet. Import the full bundle once:
import 'haze-ui/styles.css';or import the tokens plus each component's own rules:
import 'haze-ui/css/tokens.css';
import 'haze-ui/css/button.css';Is there a CommonJS build?
No — haze-ui is ESM-only ("type": "module"). Use a bundler or runtime
with ESM support (Vite, webpack 5, Next.js, Node ≥ 18, …).
Does haze-ui support React 18?
No — the peer range is react@^19.0.0 by design (see
React 19+ by design). Upgrade to React 19 first;
haze-ui ships no React 18 compatibility layer.
How do I detect (or degrade) the relative-color syntax?
Since v1.13, theme interaction states are derived with CSS relative colors, so the browser baseline from Browser support applies — Chrome/Edge 119+, Safari 16.4+, Firefox 128+, with no HSL/hex fallbacks shipped. To gate your own fallback styling on support, probe the syntax in CSS:
@supports (color: oklch(from red calc(l + 0.05) 0 h)) {
/* relative colors available: derive custom states from tokens */
}or from JS before deciding which stylesheet to load:
if (CSS.supports('color: oklch(from red calc(l + 0.05) 0 h)')) {
// relative colors available — load the v1.13+ token sheet
} else {
// below baseline: pin a pre-OKLCH version or ship your own fallbacks
}