neogestify-ui-components
v3.9.0
Published
Biblioteca de componentes UI reutilizables con React, Tailwind y SweetAlert: formularios, tarjetas, calendario, editor de mapas (VenueMapEditor) y colores configurables
Maintainers
Readme
UI Components
Reusable UI component library built with React, Tailwind CSS and SweetAlert2.
Features
- Pre-styled HTML components (Button, Input, TextArea, Form, Select, Table, Modal, Loading)
DataTable: sorting, search, pagination and selection over your records- Presentation components (Card, Avatar, Badge, Alert, Skeleton, Progress, Timeline)
- Navigation and structure (Tabs, Accordion, Collapsible, Breadcrumb, Pagination, Stepper, Tree, ScrollArea) with full keyboard support
- Form controls (Switch, Checkbox, Radio, NumberInput, Slider, TagInput, Rating, FileDropzone, ToggleGroup, and their group versions) sharing one option type and one
Fieldwrapper - Floating layers (Dropdown, Popover, Tooltip, Toast, Drawer, CommandPalette) that survive inside a modal
- The primitives the library is built on, exported at
/hooks - Animations on everything that moves, switchable globally and per component
- SVG icon collection (80+ icons)
- Preconfigured SweetAlert2 alerts + InfoAlert component
- Theme system (light/dark) with a Context Provider
- Configurable colors: every color is a CSS variable you can override — no CSS import, no config file
- Interactive venue map editor (VenueMapEditor/VenueMapViewer) with full touch support (pinch-zoom, two-finger pan)
- Element library builder (ElementLibraryBuilder)
- Mobile-friendly calendar and date picker (Calendar/DatePicker) with single, multiple and range selection
- Light/dark mode support
- TypeScript included
- Compatible with Tailwind CSS 4.x
Installation
NPM
npm i neogestify-ui-componentsBUN
bun add neogestify-ui-componentsSetup
1. Make sure Tailwind CSS is set up in your project
bun add -D tailwindcssYour project must have Tailwind configured, since the components only use Tailwind classes (no compiled CSS is shipped).
2. Configure Tailwind to scan the library's source
⚠️ IMPORTANT: This library requires Tailwind to scan its source files.
In your main CSS file (e.g. src/index.css):
@import "tailwindcss";
@source "../node_modules/neogestify-ui-components/src";
@theme {
/* Tailwind v4 dark mode configuration */
}
@variant dark (&:where(.dark, .dark *)) {
/* dark mode variant */
}Add this script to your index.html
<script>
// Prevent flash of unstyled content (FOUC)
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
</script>3. Install the peer dependencies
bun add react react-domsweetalert2 is an optional peer: install it only if you use the Alerta*
functions or InfoAlert. Everything else works without it.
bun add sweetalert2Theming (custom colors)
Every color in the library is a CSS variable with the current color as its fallback, so an existing project keeps working with zero changes and a new one can retint the whole library by declaring a handful of variables.
The quick version
/* your stylesheet — nothing to import from the library */
:root {
--nui-accent: #059669; /* light theme */
--nui-accent-hover: #047857;
--nui-accent-text: #059669;
--nui-accent-soft: #ecfdf5;
--nui-accent-dark: #10b981; /* dark theme */
--nui-accent-hover-dark: #059669;
--nui-accent-text-dark: #34d399;
--nui-accent-soft-dark: rgb(16 185 129 / .18);
}Buttons, inputs, calendar, tabs, focus rings and the map editor chrome all follow. Anything you don't declare keeps its default.
The naming rule is always the same: --nui-<token> for the light theme,
--nui-<token>-dark for the dark one. Values can be any CSS color — hex,
rgb(), oklch(), color-mix().
From JavaScript
Useful when the colors come from an API or from user settings:
import { ThemeProvider } from 'neogestify-ui-components';
<ThemeProvider colors={{
light: { accent: '#059669', 'accent-hover': '#047857' },
dark: { accent: '#34d399' },
}}>
<App />
</ThemeProvider>Or imperatively, outside React:
import { applyNuiColors, nuiColorsToCss, resolveColor } from 'neogestify-ui-components';
const undo = applyNuiColors({ light: { accent: '#059669' } }); // returns an undo fn
nuiColorsToCss({ light: { accent: '#059669' } }); // ":root{--nui-accent:#059669}" — for SSR
resolveColor('accent'); // the color actually in effect right nowFor SSR, put nuiColorsToCss(...) in a <style> in your <head> so the colors
are right on the first paint instead of after hydration.
Tokens
| Token | Role | Default (light / dark) |
|-------|------|------------------------|
| surface | Cards, panels, inputs | white / gray-800 |
| surface-muted | Headers, footers, prefixes | gray-50 / gray-700 |
| surface-hover | Row and button hover | gray-100 / gray-700 |
| surface-sunken | Page background | gray-100 / gray-900 |
| surface-band | Header and footer bands inside a panel (modal, card) | gray-50 / gray-900 |
| surface-inverted | Inverted table header | gray-800 / gray-900 |
| skeleton | Loading placeholders (Skeleton, Table while loading) | gray-200 / gray-700 |
| border | Field borders | gray-300 / gray-600 |
| border-subtle | Separators, dividers | gray-200 / gray-700 |
| text | Main text | gray-900 / white |
| text-muted | Labels, cells | gray-700 / gray-300 |
| text-subtle | Helper text, placeholders | gray-500 / gray-400 |
| text-faint | Decorative icons | gray-400 / gray-500 |
| accent | Primary buttons, selection | indigo-600 / indigo-500 |
| accent-hover | Accent hover | indigo-700 / indigo-600 |
| accent-fg | Text on accent | white / white |
| accent-text | Accent-coloured text | indigo-600 / indigo-400 |
| accent-text-hover | Accent text hover | indigo-700 / indigo-300 |
| accent-soft | Tints (range band, chips) | indigo-50 / indigo-500 15 % |
| accent-subtle | Accent borders | indigo-100 / indigo-800 |
| ring / ring-offset | Focus ring and its gap | indigo-500 / indigo-400 |
| danger, danger-hover, danger-text, danger-text-hover, danger-subtle | Errors, destructive actions | red |
| success, success-hover, success-text | Confirmation | green |
| warning, warning-hover, warning-text | Warnings | yellow |
| info, info-text | Information | blue |
| scrim | Modal/overlay backdrop | gray-900 |
Import NUI_DEFAULTS if you need the exact default values.
Hover tokens go the other way in dark mode. In light,
*-hoverdarkens the resting colour; in dark it lightens it, because darkening against a dark background reads as "disabled" rather than "hovered". If you override a*-hovervariable, override its-darkcounterpart too.
The map canvas
The editor's SVG can't use Tailwind classes in fill/stroke, so its colors
travel through a prop instead. It merges with the active theme's palette, so you
only pass what you want to change:
<VenueMapEditor
palette={{
light: { accent: '#059669', gridMinor: '#e7f5ee' },
dark: { accent: '#34d399' },
}}
/>Available keys: canvasBg, gridMinor, gridMajor, artboardFill,
artboardStroke, artboardShadowOpacity, wallFill, wallStroke,
wallMaterials (per material, merged one by one), accent, handleFill,
label, previewFill. VENUE_PALETTES and resolvePalette are exported if
you'd rather start from the defaults.
Alerts
The SweetAlert2 alerts paint their own DOM outside Tailwind, so they read the
variables at call time: background from surface-muted, text from text,
buttons from accent / danger. Nothing to configure — set the variables and
the alerts follow.
Contrast
The library can't validate the colors you pick. Keep at least 4.5:1 on these
pairs: text over surface, accent-fg over accent, accent-text over
surface, and text-subtle over surface.
Framework guides
The library ships prebuilt ESM + CJS + types, so no framework needs to transpile it. Two things are always true, whatever the stack:
- Tailwind must scan the library's source. The
@sourcepath is resolved relative to the CSS file where you write it — that is what changes from one framework to the next. - The components are client-side. They use
useState,useEffect,ResizeObserver, pointer events and (for the alerts) SweetAlert2, so they need to run in the browser.
Vite (React)
src/index.css:
@import "tailwindcss";
@source "../node_modules/neogestify-ui-components/src";
@variant dark (&:where(.dark, .dark *));Nothing else: vite.config.ts only needs @tailwindcss/vite and
@vitejs/plugin-react.
Next.js (App Router)
app/globals.css — note the path only goes up one level:
@import "tailwindcss";
@source "../node_modules/neogestify-ui-components/src";
@variant dark (&:where(.dark, .dark *));The package has no "use client" banner, so import it from a Client
Component. Either mark your own component:
'use client';
import { Button, Calendar, VenueMapEditor } from 'neogestify-ui-components';
export function BookingForm() {
return <Calendar mode="range" />;
}…or re-export the pieces you use once, and import that file everywhere:
// components/ui.ts
'use client';
export { Button, Input, Modal, Calendar, DatePicker } from 'neogestify-ui-components';VenueMapEditor and ElementLibraryBuilder measure the DOM on mount, so if
you hit a hydration mismatch, load them without SSR:
'use client';
import dynamic from 'next/dynamic';
const VenueMapEditor = dynamic(
() => import('neogestify-ui-components').then(m => m.VenueMapEditor),
{ ssr: false },
);Avoid the flash of the wrong theme by setting the class before React hydrates —
in app/layout.tsx:
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="es" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `try{if(localStorage.getItem('theme')==='dark')document.documentElement.classList.add('dark')}catch(e){}`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}Install with @tailwindcss/postcss (Next.js compiles CSS through PostCSS):
npm i neogestify-ui-components react react-dom
npm i -D tailwindcss @tailwindcss/postcss// postcss.config.mjs
export default { plugins: { '@tailwindcss/postcss': {} } };Next.js (Pages Router)
Same CSS, imported from pages/_app.tsx. There are no Server Components here,
so no 'use client' is needed — but next/dynamic with ssr: false still
applies to the map editor. The anti-flash script goes in pages/_document.tsx,
inside <Head>.
Astro
npm create astro@latest
npx astro add react
npm i neogestify-ui-components
npm i -D tailwindcss @tailwindcss/viteastro.config.mjs:
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
integrations: [react()],
vite: { plugins: [tailwindcss()] },
});src/styles/global.css — from src/styles/ you go up two levels:
@import "tailwindcss";
@source "../../node_modules/neogestify-ui-components/src";
@source "../../src";
@variant dark (&:where(.dark, .dark *));Astro renders islands as static HTML by default, so every component needs a client directive or it will not be interactive:
---
import '../styles/global.css';
import { Calendar } from 'neogestify-ui-components';
import { VenueMapEditor } from 'neogestify-ui-components';
---
<Calendar client:load mode="range" />
<VenueMapEditor client:only="react" height="520px" />Use client:only="react" for VenueMapEditor / ElementLibraryBuilder: they
measure their container, so there is nothing useful to prerender.
Remix / React Router v7
Vite-based, so the CSS setup is the Vite one. Import the stylesheet from
app/root.tsx and, since these components are browser-only, either render them
inside a <ClientOnly> boundary or guard with a mounted flag:
import styles from './tailwind.css?url';
export const links = () => [{ rel: 'stylesheet', href: styles }];TanStack Start
Vite-based, so the CSS setup is the Vite one. Import the stylesheet from the
root route (src/routes/__root.tsx).
It does SSR, and these components are browser-only: wrap them in clientOnly
or mount them after the first render.
import { clientOnly } from '@tanstack/react-router';
const VenueMapEditor = clientOnly(() =>
import('neogestify-ui-components').then(m => ({ default: m.VenueMapEditor })),
);Storybook
With the Vite builder, add the Tailwind plugin and import the CSS from
preview:
// .storybook/main.ts
import tailwindcss from '@tailwindcss/vite';
export default {
framework: '@storybook/react-vite',
viteFinal: async config => {
config.plugins?.push(tailwindcss());
return config;
},
};// .storybook/preview.ts
import '../src/index.css';For dark mode, put the class on a global decorator:
export const decorators = [
(Story, ctx) => (
<div className={ctx.globals.theme === 'dark' ? 'dark' : ''}>
<Story />
</div>
),
];Other setups
| Stack | What to do |
|-------|------------|
| Create React App / Craco | Tailwind v4 needs PostCSS: add @tailwindcss/postcss to postcss.config.js. The @source path from src/index.css is ../node_modules/neogestify-ui-components/src |
| Gatsby | Same as CRA, plus gatsby-plugin-postcss |
| Monorepo (pnpm / workspaces) | node_modules may be hoisted. Point @source at the real folder, e.g. @source "../../../node_modules/neogestify-ui-components/src", or use the package root: @source "../node_modules/.pnpm/**/neogestify-ui-components/src" |
| Tailwind CSS 3.x | There is no @source; add the path to content in tailwind.config.js: content: ['./src/**/*.{ts,tsx}', './node_modules/neogestify-ui-components/src/**/*.{ts,tsx}'] and set darkMode: 'class' |
| No bundler / CDN | Not supported: the package is distributed as ESM/CJS modules and expects a bundler |
Checklist when classes don't show up
- Is the
@sourcepath right relative to the CSS file? A wrong path fails silently — the components render unstyled. - Did you restart the dev server after touching the CSS?
@sourceis read once at startup. - Is the
darkvariant declared? Without@variant dark (&:where(.dark, .dark *))everydark:class in the library is dead code. - Is
sweetalert2installed? It is a peer dependency, not a bundled one.
Usage
Import everything from a single entry point:
import {
Button,
Input,
TextArea,
Form,
Select,
Table,
Modal,
Loading,
// Icons
HomeIcon,
SaveIcon,
DeleteIcon,
// Alerts
AlertaExito,
AlertaError,
AlertaAdvertencia,
AlertaConfirmacion,
AlertaToast,
AlertaInfo,
InfoAlert,
// Theme
ThemeProvider,
useTheme,
ThemeToggle,
// VenueMapEditor
VenueMapEditor,
VenueMapViewer,
// ElementLibraryBuilder
ElementLibraryBuilder,
// Presentation
Card, CardHeader, CardBody, CardFooter,
Avatar, AvatarGroup,
Badge,
Alert,
Skeleton, SkeletonText,
Progress,
// Navigation
Tabs,
Accordion,
Breadcrumb,
Pagination,
// Controls
Switch,
Tooltip,
// Calendar
Calendar,
DatePicker,
rangePresets,
// Theming
applyNuiColors,
applyMotion,
nuiColorsToCss,
resolveColor,
NUI_DEFAULTS,
} from 'neogestify-ui-components';Note: the alert functions keep their original Spanish names (
AlertaExito,AlertaError, …) as part of the public API.
Subpath imports
Everything is available from the root entry point, which is what most projects want. If you prefer to import only one area, each one also has its own subpath:
| Subpath | Contains |
|---------|----------|
| neogestify-ui-components | Everything below |
| neogestify-ui-components/html | Button, Input, Table, Modal, Card, Tabs, Tooltip… |
| neogestify-ui-components/icons | The ~98 SVG icons |
| neogestify-ui-components/alerts | Alerta* (SweetAlert2) and InfoAlert |
| neogestify-ui-components/theme | ThemeProvider, useTheme, ThemeToggle |
| neogestify-ui-components/tokens | Colour tokens and motion helpers (bg, text, applyNuiColors, motion…) |
| neogestify-ui-components/config | NuiConfigProvider, useMessage and the message dictionaries |
| neogestify-ui-components/hooks | The primitives the library is built on (see Building your own) |
| neogestify-ui-components/calendar | Calendar, DatePicker, date utilities |
| neogestify-ui-components/venue-map | VenueMapEditor, VenueMapViewer, its hooks and utilities |
| neogestify-ui-components/element-library-builder | ElementLibraryBuilder |
import { Button } from 'neogestify-ui-components/html';
import { SaveIcon } from 'neogestify-ui-components/icons';Both styles produce the same bundle: the entry points share their code, so mixing root and subpath imports does not ship anything twice. Pick whichever reads better in your project.
HTML Components
Button
Variants: primary, secondary, danger, success, warning, outline, ghost, icon, nav, link, toggle, custom
<Button variant="primary" size="lg" isLoading loadingText="Saving...">
Save
</Button>
<Button variant="ghost" leftIcon={<SaveIcon className="w-4 h-4" />}>
Export
</Button>
<Button variant="primary" fullWidth shape="pill">
Continue
</Button>
<Button variant="toggle" isActive={active} onClick={toggle}>
Toggle
</Button>Props:
variant: Button variant (primary|secondary|icon|danger|success|outline|ghost|nav|custom|link|warning|toggle)size: Size ('sm'|'md'|'lg'). Default:'md'shape: Border shape ('rounded'|'pill'|'square'). Default:'rounded'('pill'foricon)leftIcon: Icon before the text (ReactNode)rightIcon: Icon after the text (ReactNode)fullWidth: Takes 100% width (boolean)isLoading: Shows a loading state (boolean)loadingText: Text shown while loadingisActive: Active state for thetoggleornavvariant (boolean)disabled: Disables the buttontype: HTML type (button,submit,reset)animate:falsemakes hover and active changes instant (boolean)className: Extra classeschildren: Button content
Input
Supports types: text, email, password, number, checkbox, radio, date, tel, url, file
<Input
label="Email"
type="email"
required
error="Invalid email"
helperText="Enter your email address"
/>
{/* Visual variants */}
<Input label="Name" variant="filled" size="lg" />
<Input label="Search" variant="minimal" />
{/* With icon */}
<Input
label="Search"
icon={<SearchIcon className="w-4 h-4" />}
iconSide="left"
/>
{/* Text addons (prefix / suffix) */}
<Input label="Price" prefix="$" suffix="USD" />
<Input label="Website" prefix="https://" suffix=".com" />
{/* Clearable */}
<Input
label="Filter"
value={filter}
onChange={e => setFilter(e.target.value)}
clearable
onClear={() => setFilter('')}
/>
{/* Checkbox */}
<Input type="checkbox" label="I accept the terms" />Props:
label: Field label (string | ReactNode)type: HTML input type (text,email,password,number,checkbox,radio,date,tel,url,file)variant: Visual variant ('default'|'outline'|'filled'|'minimal'). Default:'default'size: Size ('sm'|'md'|'lg'). Default:'md'prefix: Addon attached to the left edge (ReactNode)suffix: Addon attached to the right edge (ReactNode)clearable: Shows a×button to clear when there is a value (boolean)onClear: Callback when the clear button is clickedplaceholder: Placeholdervalue: Controlled valueonChange: Change handlererror: Error message (string)helperText: Helper texticon: Icon to display (ReactNode)iconSide: Icon side ('left'|'right')required: Shows a*on the label (boolean)disabled: DisabledclassName: Extra classesid: Input ID (auto-generated if omitted)
Native widgets (the date picker calendar, number spinners) follow the active theme via
color-scheme, so they no longer render light-on-light in dark mode.
TextArea
<TextArea
label="Description"
placeholder="Write a description..."
variant="outline"
size="large"
autoResize
/>
{/* With character counter */}
<TextArea
label="Bio"
value={bio}
onChange={e => setBio(e.target.value)}
maxLength={200}
showCount
variant="filled"
/>
{/* No resize */}
<TextArea label="Notes" resize="none" rows={4} />Props:
label: Label (string | ReactNode)placeholder: Placeholdervalue: Controlled valueonChange: Change handlerrows: Number of rows (inherited from HTML)variant: Visual variant ('default'|'outline'|'filled'|'minimal')size: Size ('small'|'medium'|'large')autoResize: Grows automatically as you type (boolean)showCount: Shows a character counter. WithmaxLengthshows12 / 200(boolean)resize: Resize control ('vertical'|'horizontal'|'both'|'none'). Default:'vertical'required: Shows a*on the label (boolean)error: Error messagehelperText: Helper textdisabled: DisabledclassName: Extra classesid: Textarea ID (auto-generated if omitted)
Form
{/* Card variant with real border and shadow */}
<Form onSubmit={handleSubmit} variant="card">
<Input label="Name" placeholder="Your name" />
<Input label="Email" type="email" />
<Button variant="primary" type="submit">Submit</Button>
</Form>
{/* 2-column grid */}
<Form variant="card" columns={2}>
<Input label="First name" />
<Input label="Last name" />
<Input label="Email" type="email" />
<Input label="Phone" type="tel" />
<Button variant="primary" type="submit" fullWidth>Register</Button>
</Form>
{/* 3-column grid */}
<Form columns={3}>
<Input label="Street" />
<Input label="City" />
<Input label="Country" />
</Form>
<Form variant="inline">
<Input label="Search" placeholder="..." />
<Button variant="secondary">Search</Button>
</Form>Props:
onSubmit: Submit handlervariant: Layout variant ('default'|'modal'|'card'|'inline'|'compact')card: includes a white/dark background, real border and shadow
columns: Number of CSS grid columns (any integer ≥ 2 enables the grid layout with a1remgap;1behaves likedefault)className: Extra classes- Inherits
<form>props (method, action, etc.)
Select
<Select
label="Category"
placeholder="Select..."
required
options={[
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2', disabled: true },
{ value: '3', label: 'Option 3', selected: true },
]}
error="You must select a category"
/>
{/* Visual variants */}
<Select label="Country" variant="outline" size="lg" />
<Select label="Status" variant="filled" />
<Select label="Type" variant="minimal" />
{/* With left icon */}
<Select
label="Category"
icon={<CategorieIcon className="w-4 h-4" />}
options={options}
/>Props:
label: Label (string | ReactNode)placeholder: Placeholderoptions: Array of options:value: Option value (string | number)label: Display textdisabled: Disables the option (boolean)selected: Pre-selects the option in uncontrolled mode (boolean)
variant: Visual variant ('default'|'outline'|'filled'|'minimal'|'custom').'small'is still accepted for backward compatibility (equivalent tosize='sm')size: Size ('sm'|'md'|'lg'). Default:'md'icon: Icon on the left side (ReactNode)value: Selected value (controlled)onChange: Change handlererror: Error state. Astringshows the message;trueonly applies error styleshelperText: Helper text (shown when there is noerrorstring)required: Shows a*on the label (boolean)disabled: Disables the selectclassName: Extra classesid: Select ID (auto-generated if omitted)
The native dropdown list follows the active theme via
color-scheme, so it no longer opens with the system's light colors (or white-on-white on Chrome for Windows/Linux) when the app is in dark mode.
Table
<Table
columns={[
{ header: 'ID', align: 'center', width: 60 },
{ header: 'Name', className: 'font-bold', sticky: true },
{ header: 'Email' },
{ header: 'Sales', key: 'sales', sortable: true, align: 'right' },
]}
rows={[
['1', 'John', '[email protected]', '$1,200'],
['2', 'Mary', '[email protected]', '$3,400'],
]}
variant="striped"
size="sm"
rounded
shadow
onRowClick={(index) => console.log('Row click', index)}
sortState={{ key: 'sales', direction: 'desc' }}
onSort={(key) => console.log('Sort by', key)}
/>Variants
| Variant | Description |
|----------|-------------|
| default | White background with horizontal dividers and gray hover |
| striped | Alternating gray/white rows with blue hover |
| bordered | Borders on every cell |
| minimal | No backgrounds, just a bottom line on header and cells |
| ghost | No backgrounds, double bottom border on header, subtle dividers |
| card | Header with a soft background, thin dividers between rows |
| accent | Blue header (bg-blue-600) with white text |
| dark | Dark header (bg-gray-800) with light text |
| custom | No predefined styles, full control via classes |
ColumnDef
interface ColumnDef {
header: ReactNode; // Header content
className?: string; // Class for this column's th and td
align?: 'left' | 'center' | 'right';
width?: string | number; // Fixed width (px, %, rem…)
minWidth?: string | number; // Minimum width
sticky?: boolean; // Pins the column to the left on horizontal scroll
thStyle?: CSSProperties; // Inline styles for <th> only
tdStyle?: CSSProperties; // Inline styles for <td> only
sortable?: boolean; // Shows a sort indicator (requires key)
key?: string; // Key used in sortState and onSort
}Props
columns: Array ofColumnDefor plain strings/ReactNoderows: Body data (ReactNode[][])variant: Visual variant (see table above). Default:'default'size: Padding size ('sm'|'md'|'lg'). Default:'md'className: Extra classes for the wrapper<div>tableClassName: Extra classes for the<table>thClassName: Extra classes for each<th>tdClassName: Extra classes for each<td>trClassName: Classes per row (string|(rowIndex: number) => string)emptyState: Content shown when there is no data (ReactNode)onRowClick: Callback when a row is clicked ((rowIndex) => void)hideHeader: Hides the<thead>(boolean)style: Inline styles for the<table>stickyHeader: Pins the<thead>on vertical scroll (boolean). Needs a height — see the note belowmaxHeight: Maximum wrapper height before the table scrolls ('24rem',400,'60vh')caption: Accessible caption rendered in<caption>footerRows:<tfoot>rows (ReactNode[][])loading: Shows an animated skeleton instead of rows (boolean)loadingRows: Number of skeleton rows whenloading=true. Default:4getRowStyle: Inline style per row ((rowIndex: number) => CSSProperties)rounded: Addsrounded-lgto the wrapper (boolean)shadow: Adds a shadow to the wrapper (boolean)hoverable: Disables the hover effect whenfalse. Default:truesortState: Active sort state ({ key: string, direction: 'asc' | 'desc' })onSort: Callback when a sortable header is activated ((key: string) => void)getRowKey: Stable identity per row ((rowIndex: number) => string | number). Without it the key is the index, and the index does not identify a row: on sort, filter or delete React reuses row N's<tr>for a different record, and any state living inside a cell — a half-typed input, an open menu, the focus — stays on the wrong row
stickyHeader needs a height
overflow-x-auto turns the wrapper into a scroll container on both axes, so
without a height there is no vertical scrolling for the header to stick to and
it never moves. Give it one:
<Table stickyHeader maxHeight="24rem" columns={cols} rows={rows} />Sorting is keyboard-accessible
A sortable header renders a real <button>, so it takes focus and responds to
Enter, and the <th> carries aria-sort — which is what tells a
screen-reader user which column the table is sorted by. A clickable row
(onRowClick) is focusable and responds to Enter and
Space too.
Additional examples
{/* With loading skeleton */}
<Table columns={['Name', 'Email', 'Role']} rows={[]} loading loadingRows={5} />
{/* With totals footer */}
<Table
columns={['Product', 'Quantity', 'Total']}
rows={[['Keyboard', '2', '$60'], ['Mouse', '3', '$45']]}
footerRows={[['', 'Total', '$105']]}
variant="card"
rounded
shadow
/>
{/* Sticky header + sticky column + sort */}
<Table
columns={[
{ header: '#', sticky: true, width: 50 },
{ header: 'Name', sticky: true },
{ header: 'Date', key: 'date', sortable: true },
{ header: 'Amount', key: 'amount', sortable: true, align: 'right' },
]}
rows={data}
stickyHeader
sortState={sort}
onSort={(key) => setSort(prev => ({ key, direction: prev?.key === key && prev.direction === 'asc' ? 'desc' : 'asc' }))}
/>
{/* Dynamically colored rows */}
<Table
columns={['Level', 'Message']}
rows={logs.map(l => [l.level, l.message])}
getRowStyle={(i) => logs[i].level === 'error' ? { background: '#fef2f2' } : {}}
variant="minimal"
/>DataTable
Table receives ReactNode[][] and has no idea what is inside, so it cannot
sort, filter or tell which row is selected. DataTable works on the records
— data plus columns with their accessor — and sorting, search, pagination
and selection come out of that. It still draws with Table, searches with
Input, pages with Pagination and ticks with Checkbox.
<DataTable
data={orders}
getRowId={(o) => o.id}
searchable
selectable
pageSize={20}
onSelectedChange={(ids, rows) => setSelection(rows)}
columns={[
{ key: 'id', header: 'Order', sortable: true },
{ key: 'customer', header: 'Customer', sortable: true },
{
key: 'total', header: 'Total', sortable: true, align: 'right',
cell: (o) => `${o.total.toFixed(2)} €`,
},
{
key: 'date', header: 'Date', sortable: true,
accessor: (o) => o.date, // sorts by the Date
cell: (o) => o.date.toLocaleDateString(), // renders the text
},
]}
/>DataColumn<T>
interface DataColumn<T> {
key: string; // Unique; also what travels in SortState
header: ReactNode;
accessor?: (row: T) => unknown; // Where the value comes from. Defaults to row[key]
cell?: (row: T, i: number) => ReactNode; // How it is drawn. Defaults to the value
sortable?: boolean;
sortFn?: (a: T, b: T) => number; // When the default comparator does not fit
searchable?: boolean; // Excludes the column from search
align?: 'left' | 'center' | 'right';
width?: string | number;
minWidth?: string | number;
sticky?: boolean;
className?: string;
}Sorting
Clicking a header cycles through three states: ascending, descending and back to the original order. Without that third step there is no way back short of reloading.
The default comparator sorts numbers, Dates and booleans by value, and text
with localeCompare + numeric — so Artículo 2 comes before Artículo 10,
and case and accents do not change the order. Empty values always go last,
whichever way the sort runs: a cell with no data is not "the smallest", it is
no data.
getRowId is required
On purpose. With the position as identity, sorting or paging reuses row N's
<tr> for a different record, and the selection stops meaning anything as soon
as the order changes.
Server-side data
Pass manual and the in-memory work is switched off: data is drawn as it
comes and the controls only report. Use totalRows so pagination knows how many
there really are.
<DataTable
manual
data={page.rows}
totalRows={page.total}
getRowId={(r) => r.id}
page={page.number}
onPageChange={fetchPage}
sortState={sort}
onSortChange={setSort}
query={q}
onQueryChange={setQ}
searchable
pageSize={20}
columns={columns}
/>Props
data,columns,getRowId— the three required ones- Sorting:
sortState,defaultSort,onSortChange - Search:
searchable,searchPlaceholder,query,onQueryChange - Pagination:
pageSize(0shows everything),page,onPageChange,totalRows - Selection:
selectable,selected,defaultSelected,onSelectedChange manual,onRowClick,toolbar,emptyState,loading,loadingRows- Passed straight through to
Table:variant,size,stickyHeader,maxHeight,rounded,shadow,hoverable,caption,getRowStyle,trClassName,tableClassName
Modal
const modalRef = useRef<ModalRef>(null);
<Modal
ref={modalRef}
title="Confirm action"
size="md"
variant="danger"
closeOnBackdrop
closeOnEsc
onClose={() => setShowModal(false)}
footer={
<>
<Button variant="secondary" onClick={() => modalRef.current?.handleClose()}>
Cancel
</Button>
<Button variant="danger" onClick={handleConfirm}>
Delete
</Button>
</>
}
>
<p>Are you sure you want to continue?</p>
</Modal>
{/* With title as ReactNode */}
<Modal
title={<span className="flex items-center gap-2"><InfoIcon className="w-5 h-5" /> Information</span>}
size="lg"
onClose={onClose}
>
{children}
</Modal>Header variants
| Variant | Description |
|----------|-------------|
| default | Neutral gray header |
| danger | Red header for destructive actions |
| success | Green header for positive confirmations |
| warning | Yellow header for warnings |
Sizes
| Size | Max width |
|------|-------------|
| sm | max-w-sm |
| md | max-w-md |
| lg | max-w-2xl |
| xl | max-w-4xl |
| full | 95vw |
Props:
title: Modal title (string | ReactNode)children: Contentfooter: Footer contentonClose: Close handlersize: Predefined size ('sm'|'md'|'lg'|'xl'|'full')maxWidth: Custom width class (deprecated, usesize)variant: Header style ('default'|'danger'|'success'|'warning')closeOnBackdrop: Close when clicking outside the modal (boolean, default:false)closeOnEsc: Close when pressing Escape (boolean, default:false)showCloseButton: Shows a close button (boolean, default:true)zIndex: Deprecated, no effect. The dialog opens withshowModal(), which puts it in the browser's top layer — always above everything else, no matter whatz-indexanything on the page has. Still accepted so existing code keeps compilinganimate:falseopens and closes instantly (boolean)className: Extra classes for the panel
The modal is a native <dialog> opened with showModal(), so the browser
handles the focus trap, returns focus to whatever opened it, and marks the rest
of the page inert — which also hides it from screen readers, not just from the
tab order. Page scroll is locked while it is open.
Ref methods (ModalRef):
handleClose(): Closes the modal with an animation
Loading
<Loading variant="spinner" size="large" color="primary" label="Loading..." />
<Loading variant="dots" size="medium" color="white" />
<Loading variant="pulse" size="small" color="success" />
<Loading variant="bars" size="xl" color="danger" />
<Loading variant="ring" color="warning" />
<Loading variant="cube" size="large" />
{/* Overlay over the container (the parent must be position: relative) */}
<div className="relative h-48">
<MyContent />
{loading && <Loading overlay variant="ring" color="primary" />}
</div>
{/* Full-page overlay */}
{loading && <Loading fullPage label="Processing..." />}Props:
variant: Loader variant ('spinner'|'dots'|'pulse'|'bars'|'ring'|'cube')size: Size ('small'|'medium'|'large'|'xl')color: Color ('primary'|'white'|'gray'|'success'|'danger'|'warning')label: Text below the iconoverlay: Covers the nearestposition: relativecontainer with a semi-transparent background (boolean)fullPage:fixedoverlay covering the whole screen (z-50) (boolean)className: Extra classes
Presentation Components
Card
<Card
title="Monthly sales"
description="Compared with last month"
action={<Badge variant="success" dot>+12 %</Badge>}
footer={<span className="text-xs">Updated 5 min ago</span>}
>
<p className="text-3xl font-bold">48,320 EUR</p>
</Card>| Prop | Type | Default | Description |
|------|------|---------|-------------|
| variant | 'default' \| 'outlined' \| 'elevated' \| 'ghost' \| 'custom' | 'default' | Border and shadow |
| padding | 'none' \| 'sm' \| 'md' \| 'lg' | 'md' | Applies to every section |
| title / description / action | ReactNode | — | Header. A string title is wrapped in an <h3> |
| footer | ReactNode | — | Footer with a separator and a muted background |
| media | ReactNode | — | Full-bleed image above the header |
| interactive | boolean | false | Hover highlight, pointer cursor and keyboard focus |
| href | string | — | Renders as <a> and implies interactive |
| fullHeight | boolean | false | Fills the row height — for grids of uneven cards |
CardHeader, CardBody and CardFooter are exported for layouts the props
can't express.
Avatar / AvatarGroup
Falls back in stages: image → initials → icon. Initials and the tint come from
name, and the tint is stable — the same person is always the same colour.
<Avatar name="Ada Lovelace" src="/ada.jpg" status="online" />
<AvatarGroup max={4} avatars={[{ name: 'Ada' }, { name: 'Alan' }, …]} />| Prop | Type | Default |
|------|------|---------|
| src / name / alt | string | — |
| size | 'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| '2xl' | 'md' |
| shape | 'circle' \| 'square' | 'circle' |
| status | 'online' \| 'offline' \| 'busy' \| 'away' | — |
| icon / ring | ReactNode / boolean | — |
AvatarGroup takes avatars, max (default 4), size and shape. The
overflow becomes a +N chip. initialsOf(name) is exported too.
Badge
<Badge variant="success" dot>Active</Badge>
<Badge variant="accent" pill onRemove={() => remove(tag)}>{tag}</Badge>variant: neutral (default), accent, success, warning, danger,
info, outline, solid · size: sm | md | lg · dot prefixes a coloured
dot · pill rounds it fully · onRemove adds a labelled close button.
Alert
An inline notice, in the page flow — unlike the Alerta* functions, which
are SweetAlert2 dialogs that interrupt the user.
<Alert variant="warning" title="Quota almost full" onClose={hide}
actions={<Button size="sm" variant="outline">Upgrade</Button>}>
You have used 92 % of your space.
</Alert>variant: info (default), success, warning, danger, neutral ·
title, icon (false removes it), onClose, actions. The danger
variant uses role="alert" so it interrupts a screen reader; the rest use
role="status" and wait their turn.
Skeleton
<Skeleton variant="circle" width={40} />
<Skeleton lines={3} />variant: text (default), circle, rect, rounded · width, height,
lines (the last one comes out shorter), animated. Marked aria-hidden:
announce loading on the container with aria-busy, not on every grey block.
Its colour comes from the skeleton token (gray-200 / gray-700). If your page
background is not the default white, tune it to taste:
:root { --nui-skeleton: oklch(87.2% .01 258.338); } /* stronger, gray-300 */
:root.dark { --nui-skeleton-dark: oklch(44.6% .03 256.802); }Progress
<Progress value={72} label="Uploading" showValue />
<Progress indeterminate label="Processing…" variant="info" />value / max, variant (accent | success | warning | danger | info),
size (xs | sm | md | lg), label, showValue, indeterminate,
valueText.
Navigation Components
Tabs
Full tablist pattern: roving tabindex, arrow keys, Home and End.
<Tabs items={[
{ id: 'general', label: 'General', content: <Form /> },
{ id: 'security', label: 'Security', badge: <Badge size="sm" variant="danger">2</Badge>, content: <Security /> },
{ id: 'archived', label: 'Archived', disabled: true },
]} />| Prop | Type | Default | Description |
|------|------|---------|-------------|
| items | TabItem[] | — | { id, label, content?, icon?, badge?, disabled? } |
| value / defaultValue / onChange | — | — | Controlled or uncontrolled |
| variant | 'line' \| 'pill' \| 'enclosed' | 'line' | |
| size | 'sm' \| 'md' \| 'lg' | 'md' | |
| fullWidth | boolean | false | Splits the width evenly |
| activation | 'automatic' \| 'manual' | 'automatic' | manual only moves focus; Enter confirms |
Omit content and only the bar renders — you own the panel.
Accordion
<Accordion type="multiple" items={[
{ title: 'Shipping', content: <p>…</p>, meta: '3 days' },
]} />items: { id?, title, content, meta?, icon?, disabled? } · type:
single (default) or multiple · value / defaultValue / onValueChange ·
collapsible (default true) · variant: separated (default), bordered,
plain. Arrow keys, Home and End move between headers. Collapsed panels are
hidden, not unmounted, so their internal state survives.
Collapsible
The accordion of one. The animation runs on grid-template-rows: 0fr → 1fr,
which is the only way to animate to the content's real height without
measuring it in JavaScript or inventing a max-height that falls short. The
content is not unmounted when closed — so it keeps its state and can be animated
— which means it has to be taken out of reach of the keyboard and screen
readers: that is exactly what inert does.
<Collapsible variant="bordered" title="Advanced options" meta="3 fields">
<TextArea label="Notes" />
</Collapsible>CollapsibleRegion is exported on its own for collapsibles that do not fit the
component: a table's detail row, a side panel, a card body. Accordion uses it.
Stepper
Progress through steps. A real <ol>, not a row of circles: a screen reader
announces "3 of 4" without anyone writing it, and the current step carries
aria-current="step".
<Stepper
current={step}
onStepClick={setStep}
steps={[
{ label: 'Details' },
{ label: 'Payment', description: 'Card or transfer' },
{ label: 'Done', optional: true },
]}
/>clickable defaults to 'completed': you can go back but not jump forward,
which is what stops steps being marked done before they are filled in. Pass
errorSteps={[1]} to mark failures, orientation="vertical" for the vertical
layout.
Timeline
An ordered list of events. The line and the dots are marked decorative so a screen reader reads the list, not the geometry.
<Timeline items={[
{ title: 'Order created', meta: '10:04', variant: 'accent' },
{ title: 'Payment confirmed', meta: '10:06', variant: 'success' },
]} />Tree
Follows the ARIA tree pattern: one tab stop for the whole tree, and inside
you move with the arrows — up and down through what is visible, right to open or
step into the first child, left to close or go up to the parent. It is what a
screen-reader user expects, and it is far quicker than tabbing through hundreds
of nodes.
<Tree
nodes={folders}
selectable="multiple"
defaultExpanded={['root']}
onNodeClick={open}
/>nodes:{ id, label, icon?, children?, disabled?, meta? }[]expanded/defaultExpanded/onExpandedChangeselected/defaultSelected/onSelectedChange,selectable('none' | 'single' | 'multiple')onNodeClick,showGuides,size
ScrollArea
A scrollable area with a themed scrollbar. It does not replace the native bar with one drawn in JavaScript: the native one keeps the wheel, the touch gesture, the drag and — the thing that usually breaks when you reimplement it — scrolling automatically when the tab key lands on something out of view. Only the looks change.
<ScrollArea maxHeight="16rem" stableGutter>{rows}</ScrollArea>stableGutter reserves the scrollbar's space so content does not jump by two
pixels the moment a filtered list stops fitting. hideScrollbar hides the bar
without removing the scrolling.
Watch out for focus: a scrollable container with nothing focusable inside
cannot be reached with the keyboard. If the content is just text, give it
tabIndex={0} and an aria-label.
Breadcrumb
<Breadcrumb items={[{ label: 'Home', href: '/' }, { label: 'Order 42' }]} />The last item is the current page: it gets aria-current="page" and is not a
link. Past maxItems (default 4) the middle collapses into … — the start
and the end are what orient the user.
Pagination
<Pagination page={p} totalPages={12} onChange={setP} />
<Pagination page={p} totalPages={12} onChange={setP} compact />siblings (pages either side, default 1), boundaries (fixed pages at each
end, default 1), compact (just Previous/Next with «Page X of Y» — the
sensible option on mobile), size, labels for translation. Returns null
when there is a single page. The pageRange() helper is exported.
A note on narrow layouts
Tabs and Table scroll horizontally rather than wrapping, so their bar keeps
its shape on a phone. Both carry min-w-0 / overflow-x-auto internally, which
means they will not stretch a grid or flex track wider than the viewport.
If you build your own grid around library components, give the columns
min-w-0. Grid and flex items default to min-width: auto, so a child that
doesn't wrap forces its track to grow instead of scrolling:
<div className="grid gap-6 lg:grid-cols-2 [&>*]:min-w-0">Calendar, VenueMapEditor and ElementLibraryBuilder measure their own
container rather than the viewport, so they reflow correctly inside a sidebar
or a modal, not just at page level.
Controls
One option type for all of them
SelectOption, ComboboxOption, RadioOption, CheckboxOption,
SegmentedOption and ToggleOption used to be near-identical and mutually
incompatible in TypeScript: an array prepared for a RadioGroup could not be
passed to a SegmentedControl without mapping it. They are all aliases of
NuiOption now:
interface NuiOption<V extends string | number = string> {
value: V;
label: ReactNode;
description?: ReactNode; // Supporting text under the label
disabled?: boolean;
icon?: ReactNode;
group?: string; // Groups options under a heading
}Extra fields are used by whichever component knows what to do with them and ignored by the rest — there is spare information, never missing information.
Select and Combobox narrow label to string: one of them renders into a
native <option>, which only paints text, and the other searches on it.
Option components also take the short form, where the label is the value:
<RadioGroup options={['S', 'M', 'L']} />
<SegmentedControl options={['Day', 'Week', 'Month']} />Checkbox
A single checkbox. Until 3.9.0 only the group version existed, so an "I accept
the terms" meant declaring a CheckboxGroup of one.
<Checkbox label="I accept the terms" required checked={ok} onChange={setOk} />
<Checkbox
variant="card"
label="Email notifications"
description="One summary per day, never more"
checked={notify}
onChange={setNotify}
/>checked,defaultChecked,onChange(checked: boolean)indeterminate— the third state. Announced asmixed, not as uncheckedlabel,description,error,size(sm/md/lg),variant(plain/card)disabled,required,name,value—nameemits a hidden input so it travels in a normal form submit
CheckboxBox is exported separately when you only need the square: inside a
table cell, a list row, anywhere the state lives elsewhere.
Radio
A single exclusive option. You almost always want RadioGroup, which also gives
you the radiogroup role, the shared label and arrow-key movement. This one is
for building a group by hand when the options do not fit in a list — spread
across a table, inside pricing cards.
{plans.map(p => (
<Radio
key={p.value}
value={p.value}
variant="card"
label={p.label}
description={p.description}
checked={plan === p.value}
onChange={setPlan}
/>
))}RadioDot is exported for the same reason as CheckboxBox.
NumberInput
Not an <input type="number">. That one drags two problems that show up fast in
a business form: the mouse wheel changes the value as you scroll past it — so
quantities get corrected by accident just by moving down the page — and it
accepts e, + and - anywhere, so 1e5 is "valid" until someone reads it.
Here the field is text, the number is validated by the component, and the
spinbutton role announces the value, the minimum and the maximum.
<NumberInput label="Quantity" min={1} max={99} value={n} onChange={setN} />
<NumberInput label="Price" step={0.01} prefix="€" value={p} onChange={setP} />
<NumberInput label="Discount" suffix="%" controls={false} max={100} step={5} />value,defaultValue,onChange(value: number | null)—nullis "empty"min,max,step,largeStep(defaults tostep * 10)prefix,suffix,controls,size,formatlabel,error,helperText,placeholder,disabled,readOnly,required,name
Keyboard: ↑ ↓ by step, PageUp
PageDown by largeStep, Home and End to the
limits. The comma works as a decimal separator — on a Spanish keyboard it is the
key under your finger, and Number(',5') is NaN. Values are clamped to the
step grid on blur, rounded to the step's decimals so a price does not end up
with twelve digits after the point.
Slider
Underneath it is a real <input type="range">, so dragging, touch, arrow keys,
PageUp/PageDown, Home and End come
for free, along with the slider role and its announced value. Only the paint
is ours.
<Slider
label="Capacity"
min={0} max={500} step={10}
showValue
formatValue={(v) => `${v} people`}
marks={[0, 250, 500]}
value={n}
onChange={setN}
onChangeEnd={save}
/>onChangeEnd fires when you let go, not on every pixel — it is the one you want
for saving or hitting the server, since onChange arrives dozens of times per
gesture. marks takes the same NuiOption shape (or plain numbers).
TagInput
Values that do not come from a list: whoever types invents them. That is
what you want for free tags, invite emails or keywords. For values that do come
from a list, use Combobox with multiple.
<TagInput
label="Tags"
value={tags}
onChange={setTags}
max={10}
transform={(t) => t.toLowerCase()}
/>Enter or a comma closes a tag, Backspace on the empty field
removes the last one, and pasting a spreadsheet column creates one tag per line.
transform normalises each tag and returning null drops it, which is how you
validate without painting an error on every attempt.
Rating
A group of exclusive options, not an ornament: arrow keys move through it, each
star says "3 of 5", and the whole thing is one tab stop. With readOnly it
stops being a control and becomes an image with a label, which is the right
thing for an already-published average.
<Rating label="Rating" value={n} onChange={setN} showValue />
<Rating value={4} readOnly size="sm" />icon={(filled) => …} swaps the symbol; getLabel changes the announced text.
FileDropzone
The <input type="file"> is still there, hidden but focusable: that is what
makes the control work with the keyboard, opens the browser's native picker and
lets the field travel in a form. The big area is its <label>, so clicking it
is clicking the field.
<FileDropzone
label="Attachments"
accept="image/*,.pdf"
multiple
maxFiles={4}
maxSize={5 * 1024 * 1024}
value={files}
onChange={setFiles}
onReject={(rejected) => toast.error(`${rejected.length} file(s) rejected`)}
/>accept follows the browser's own rules — extension (.pdf), exact type
(image/png) or wildcard (image/*). Rejections arrive in onReject with a
reason ('type' | 'size' | 'count') instead of failing silently.
The visible list is kept in sync with the real input through a DataTransfer,
so name submits what you actually see, not the last thing picked in the
dialog.
acceptsFile(file, accept) and formatBytes(bytes) are exported for reuse.
ToggleGroup
Buttons that stay pressed. It looks like SegmentedControl, and the difference
matters when choosing: the segmented control is a field — a row of exclusive
options with its sliding indicator, meant for a form — and this is a toolbar:
several active at once, icon-only buttons, and it attaches to other controls.
Bold/italic/underline is this; "Monthly / Yearly" is a segmented control.
<ToggleGroup options={views} value={view} onChange={setView} />
<ToggleGroup
type="multiple"
attached
iconOnly
options={formatOptions}
value={format}
onChange={setFormat}
aria-label="Format"
/>type decides the value's type: single gives onChange(value: string),
multiple gives onChange(value: string[]), and TypeScript knows which without
any casting on your side.
Switch
<Switch label="Email notifications" description="We ping you on every order."
checked={on} onChange={setOn} />role="switch" with aria-checked. Props: checked / defaultChecked /
onChange, label, description, labelPosition, size (sm | md | lg),
disabled, required, name (hidden input for forms), value.
RadioGroup
<RadioGroup
label="Shipping method"
variant="card"
options={[
{ value: 'std', label: 'Standard', description: '3-5 business days' },
{ value: 'exp', label: 'Express', description: 'Tomorrow before 2pm' },
]}
value={shipping}
onChange={setShipping}
/>A loose Input type="radio" doesn't make a group: no shared label, no
role="radiogroup", and Tab stops on every circle. Here the group is a
single tab stop and arrows move within it — and moving focus selects, like the
native control.
Props: options, value / defaultValue / onChange, label, description,
error, name (hidden input), required, disabled, orientation
(vertical | horizontal), variant (plain | card).
CheckboxGroup
<CheckboxGroup
label="Permissions"
selectAllLabel="Select all"
options={permissions}
value={granted}
onChange={setGranted}
/>Same idea for checkboxes, plus selectAllLabel: a header checkbox with a real
indeterminate state (aria-checked="mixed"), not a yes/no that lies when half
the list is ticked. "Select all" leaves disabled options alone — they aren't the
user's to change.
SegmentedControl
<SegmentedControl
options={[{ value: 'day', label: 'Day' }, { value: 'month', label: 'Month' }]}
value={range}
onChange={setRange}
/>For picking a value, not a view — it's a radiogroup, not tabs. If what
changes is the page content, use Tabs. The pill is measured from the active
button so it slides instead of jumping.
Props: options (value, label, icon, disabled), size, fullWidth,
disabled, aria-label.
Combobox
<Combobox label="Country" options={countries} value={country} onChange={setCountry} />
<Combobox multiple label="Tags" options={tags} value={active} onChange={setActive} />Select wraps the native <select>, which can't search and has no usable
multiple mode. Reach for Combobox once the list passes a dozen entries — below
that the native one is still better, since on mobile it opens the system picker.
- Accent-insensitive filtering: typing
perufindsPerú. It searchesdescriptiontoo, not just the label. - Options grouped via
group, keeping the order they arrive in. - Multiple mode shows chips with
maxTagsand a+Nsummary; Backspace on an empty field removes the last one.
Follows the combobox ARIA pattern: focus never moves to the list, the
highlighted option is announced through aria-activedescendant. If focus
jumped, you couldn't keep typing — which is the whole point.
Props: options (value, label, description, disabled, group),
multiple, value / defaultValue / onChange, label, placeholder,
helperText, error, clearable, filter, emptyState, maxListHeight,
maxTags, name, disabled, required.
Tooltip
<Tooltip content="Export as JSON">
<Button variant="icon" aria-label="Export"><SaveIcon /></Button>
</Tooltip>Shows on hover and on keyboard focus; on touch screens it appears on
press-and-hold. It renders in a portal with fixed positioning, so no ancestor's
overflow: hidden can clip it, and it repositions on scroll. Escape hides it.
Props: content, placement (top | bottom | left | right), delay (ms),
disabled, maxWidth.
The tooltip is not an accessible name. An icon-only button still needs its
own aria-label.
Floating layers
All three share one positioner: they flip to the opposite side when they
don't fit and shift along the cross axis to stay on screen, and they live in
a portal so no ancestor's overflow: hidden can clip them.
placement accepts a side (top, bottom, left, right) optionally with an
alignment (bottom-start, right-end…).
Dropdown
<Dropdown
trigger={<Button variant="icon"><MenuIcon /></Button>}
items={[
{ id: 'edit', label: 'Edit', icon: <EditIcon />, shortcut: '⌘E' },
{ id: 'del', label: 'Delete', danger: true, separatorBefore: true },
]}
onSelect={id => …}
/>Full keyboard: arrows, Home, End, Escape, Tab to close, and typeahead —
typing de jumps to "Delete", with the one-second reset a native <select>
uses.
Focus does not travel between items with Tab: a menu is a single tab stop
and you move inside it with arrows, which is what a screen reader expects from
a role="menu".
Item props: id, label, icon, shortcut (decorative — it doesn't register
the binding), disabled, danger, separatorBefore, onSelect.
Popover
<Popover trigger={<Button>Filters</Button>} placement="bottom-start">
<Form>…</Form>
</Popover>Unlike Tooltip it opens on click and accepts focus inside, so it can hold
fields and buttons. Closes on outside click or Escape and returns focus to the
trigger. unstyled drops the default padding and surface when you want to paint
the whole panel yourself.
Toast
<ToastProvider position="bottom-right">
<App />
</ToastProvider>
const { toast, dismiss, dismissAll } = useToast();
toast({ title: 'Saved', variant: 'success' });
toast({
title: 'Record deleted',
action: { label: 'Undo', onClick: restore },
duration: 8000,
});The timer pauses while the pointer is over the stack or anything inside has
focus — otherwise a toast with an "Undo" button vanishes exactly as you reach
for it. limit (4 by default) drops the oldest on overflow; an uncapped stack
ends up covering the app.
role="alert" only on the danger variant, which interrupts the screen reader;
role="status" for the rest.
Options: title, description, variant (info | success | warning | danger),
duration (0 = stays until dismissed), action, dismissible, onDismiss.
Provider: position (6 corners), duration, limit, aria-label.
CommandPalette
The ⌘K action finder. Built on Modal, so it inherits the scrim, the scroll
lock, the trapped focus and the top layer. What is its own is the combobox
pattern: focus never leaves the text field, and what moves up and down with
the arrows is aria-activedescendant. That is what lets you keep typing while
you walk the list.
<CommandPalette
open={open}
onClose={() => setOpen(false)}
items={[
{ id: 'new', label: 'Create order', group: 'Actions', shortcut: ['⌘', 'N'] },
{ id: 'bill', label: 'Bill order', group: 'Actions', keywords: ['charge'] },
{ id: 'go-customers', label: 'Customers', group: 'Go to' },
]}
onSelect={(item) => run(item.id)}
/>The shortcut that opens it is yours to wire up: the component does not listen to the global keyboard so it cannot stomp on anyone else's shortcuts.
Search ignores accents and matches per word, so cl cr finds "Create client"
and charge finds "Bill order" through its keywords. filter replaces it
outright, and query + onQueryChange hand the text to you for searching on
the server.
Drawer
{open && (
<Drawer title="Filters" side="right" onClose={() => setOpen(false)}>
<Form>…</Form>
</Drawer>
)}A <dialog> opened with showModal(), same as Modal, so it inherits the same
things from the browser: top layer above any z-index, a real focus trap, the
rest of the page inert — for screen readers too — and focus restored on close.
Props: side (left | right | top | bottom), size (sm | md | lg | xl |
full), title, footer, showCloseButton, closeOnBackdrop, closeOnEsc.
Controlled by mounting/unmounting, like Modal; onClose fires when the exit
animation finishes.
Stacking: zIndex and the top layer
Modal and Drawer open with showModal(), which puts them in the browser's
top layer — above the entire document, ignoring every z-index on the page.
That is what buys you a real focus trap, inert on everything behind, and
immunity to any overflow or transform in the tree.
It also means a plain z-index cannot put anything on top of them. If you need
that, pass zIndex:
<Modal zIndex={40} … /> /* your own element at z-50 now sits above it */Passing zIndex implies topLayer={false}: the dialog becomes a normal element
of the document and obeys stacking again. Modality is preserved — the library
marks everything else in <body> as inert by hand, which covers focus,
pointer and the accessibility tree.
With topLayer={false} and no value, it stacks at NUI_LAYERS.modal (50),
below the library's own menus and toasts, which is where a dialog belongs.
| | topLayer (default) | topLayer={false} |
| --- | --- | --- |
| Above everything | always | up to your z-index |
| Focus trap | browser | inert, by the library |
| Obeys z-index | no | yes |
You usually don't need this. The library's own floating layers already work
on top of an open modal: Dropdown, Combobox, Popover and Tooltip are
portalled into the active dialog, Toast raises itself with the popover API,
and another Modal or Drawer opened afterwards stacks above by open order.
zIndex is for putting something of your own above.
The scale the library uses for everything outside the top layer is exported as
NUI_LAYERS: modal 50, popover 60, tooltip 70, toast 80.
Composition pieces
Divider
<Divider />
<Divider label="or" />
<Divider orientation="vertical" />Without a label it's a semantic <hr>. With one it becomes a
role="separator" carrying text — the ──── or ──── between two blocks.
EmptyState
<EmptyState
icon={<BoxIcon className="h-10 w-10" />}
title="No orders yet"
description="The first one will show up here."
action={<Button>Create order</Button>}
/>An empty list without this is indistinguishable from one that failed to load.
Pass action: there's almost always something the user can do — create the
first record, clear a filter — and without it the screen is a dead end.
Stat
