npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

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 Field wrapper
  • 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-components

BUN

bun add neogestify-ui-components

Setup

1. Make sure Tailwind CSS is set up in your project

bun add -D tailwindcss

Your 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-dom

sweetalert2 is an optional peer: install it only if you use the Alerta* functions or InfoAlert. Everything else works without it.

bun add sweetalert2


Theming (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 now

For 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, *-hover darkens the resting colour; in dark it lightens it, because darkening against a dark background reads as "disabled" rather than "hovered". If you override a *-hover variable, override its -dark counterpart 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:

  1. Tailwind must scan the library's source. The @source path is resolved relative to the CSS file where you write it — that is what changes from one framework to the next.
  2. 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/vite

astro.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

  1. Is the @source path right relative to the CSS file? A wrong path fails silently — the components render unstyled.
  2. Did you restart the dev server after touching the CSS? @source is read once at startup.
  3. Is the dark variant declared? Without @variant dark (&:where(.dark, .dark *)) every dark: class in the library is dead code.
  4. Is sweetalert2 installed? 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' for icon)
  • 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 loading
  • isActive: Active state for the toggle or nav variant (boolean)
  • disabled: Disables the button
  • type: HTML type (button, submit, reset)
  • animate: false makes hover and active changes instant (boolean)
  • className: Extra classes
  • children: 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 clicked
  • placeholder: Placeholder
  • value: Controlled value
  • onChange: Change handler
  • error: Error message (string)
  • helperText: Helper text
  • icon: Icon to display (ReactNode)
  • iconSide: Icon side ('left' | 'right')
  • required: Shows a * on the label (boolean)
  • disabled: Disabled
  • className: Extra classes
  • id: 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: Placeholder
  • value: Controlled value
  • onChange: Change handler
  • rows: 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. With maxLength shows 12 / 200 (boolean)
  • resize: Resize control ('vertical' | 'horizontal' | 'both' | 'none'). Default: 'vertical'
  • required: Shows a * on the label (boolean)
  • error: Error message
  • helperText: Helper text
  • disabled: Disabled
  • className: Extra classes
  • id: 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 handler
  • variant: 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 a 1rem gap; 1 behaves like default)
  • 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: Placeholder
  • options: Array of options:
    • value: Option value (string | number)
    • label: Display text
    • disabled: 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 to size='sm')
  • size: Size ('sm' | 'md' | 'lg'). Default: 'md'
  • icon: Icon on the left side (ReactNode)
  • value: Selected value (controlled)
  • onChange: Change handler
  • error: Error state. A string shows the message; true only applies error styles
  • helperText: Helper text (shown when there is no error string)
  • required: Shows a * on the label (boolean)
  • disabled: Disables the select
  • className: Extra classes
  • id: 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 of ColumnDef or plain strings/ReactNode
  • rows: 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 below
  • maxHeight: 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 when loading=true. Default: 4
  • getRowStyle: Inline style per row ((rowIndex: number) => CSSProperties)
  • rounded: Adds rounded-lg to the wrapper (boolean)
  • shadow: Adds a shadow to the wrapper (boolean)
  • hoverable: Disables the hover effect when false. Default: true
  • sortState: 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 recordsdata 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 (0 shows 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: Content
  • footer: Footer content
  • onClose: Close handler
  • size: Predefined size ('sm' | 'md' | 'lg' | 'xl' | 'full')
  • maxWidth: Custom width class (deprecated, use size)
  • 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 with showModal(), which puts it in the browser's top layer — always above everything else, no matter what z-index anything on the page has. Still accepted so existing code keeps compiling
  • animate: false opens 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 icon
  • overlay: Covers the nearest position: relative container with a semi-transparent background (boolean)
  • fullPage: fixed overlay 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 / onExpandedChange
  • selected / 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 as mixed, not as unchecked
  • label, description, error, size (sm/md/lg), variant (plain/card)
  • disabled, required, name, valuename emits 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)null is "empty"
  • min, max, step, largeStep (defaults to step * 10)
  • prefix, suffix, controls, size, format
  • label, 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 peru finds Perú. It searches description too, not just the label.
  • Options grouped via group, keeping the order they arrive in.
  • Multiple mode shows chips with maxTags and a +N summary; 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