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

zenput

v1.1.0

Published

Zenput — a production-ready, accessible React TypeScript input components library

Downloads

331

Readme

Zenput

CI codecov Quality Gate Status npm bundle size License: MIT

A production-ready, accessible React TypeScript input components library with 18 fully-featured components.

Features

  • 🎯 18 input components — TextInput, TextArea, NumberInput, PasswordInput, SelectInput, Checkbox, CheckboxGroup, RadioGroup, Toggle, DateInput, TimeInput, FileInput, RangeInput, ColorInput, SearchInput, PhoneInput, OTPInput, AutoComplete
  • Fully accessible — ARIA attributes, keyboard navigation, screen reader support
  • 🎨 Themeable — CSS custom properties with ThemeProvider
  • 📐 3 sizessm, md, lg
  • 🖼️ 3 variantsoutlined, filled, underlined
  • Validation statesdefault, error, success, warning
  • 🔒 TypeScript strict — No any types, full type safety
  • 📦 Tree-shakeable — ESM + CJS dual output
  • 🧪 Tested — comprehensive test coverage with React Testing Library

Installation

npm install zenput

Quick Start

import { TextInput, ThemeProvider } from 'zenput';

function App() {
  return (
    <ThemeProvider theme={{ primaryColor: '#6366f1' }}>
      <TextInput
        label="Email"
        placeholder="[email protected]"
        required
        fullWidth
      />
    </ThemeProvider>
  );
}

Components

TextInput

<TextInput
  label="Username"
  placeholder="Enter username"
  size="md"
  variant="outlined"
  validationState="error"
  errorMessage="Username is required"
  required
  fullWidth
/>

TextArea

<TextArea
  label="Bio"
  placeholder="Tell us about yourself"
  autoResize
  showCharCount
  maxLength={500}
  rows={4}
/>

NumberInput

<NumberInput
  label="Quantity"
  min={0}
  max={100}
  step={1}
  defaultValue={1}
/>

PasswordInput

<PasswordInput
  label="Password"
  showStrengthIndicator
/>

SelectInput

<SelectInput
  label="Country"
  options={[
    { value: 'us', label: 'United States' },
    { value: 'uk', label: 'United Kingdom' },
  ]}
  placeholder="Select a country"
/>

Checkbox

<Checkbox label="I agree to the terms" required />

CheckboxGroup

<CheckboxGroup
  label="Interests"
  options={[
    { value: 'react', label: 'React' },
    { value: 'typescript', label: 'TypeScript' },
    { value: 'node', label: 'Node.js' },
  ]}
  value={['react']}
  onChange={(values) => console.log(values)}
/>

RadioGroup

<RadioGroup
  label="Plan"
  options={[
    { value: 'free', label: 'Free' },
    { value: 'pro', label: 'Pro' },
    { value: 'enterprise', label: 'Enterprise' },
  ]}
  value="free"
  onChange={(value) => console.log(value)}
/>

Toggle

<Toggle label="Enable notifications" defaultChecked />

DateInput

<DateInput label="Date of birth" min="1900-01-01" max="2024-12-31" />

TimeInput

<TimeInput label="Meeting time" />

FileInput

<FileInput label="Upload avatar" accept="image/*" />

RangeInput

<RangeInput label="Volume" min={0} max={100} defaultValue={50} showValue />

ColorInput

<ColorInput label="Brand color" defaultValue="#3b82f6" />

SearchInput

<SearchInput
  label="Search"
  placeholder="Search..."
  onSearch={(q) => console.log(q)}
/>

PhoneInput

<PhoneInput
  label="Phone number"
  defaultDialCode="+1"
/>

OTPInput

<OTPInput
  label="Verification code"
  length={6}
  onChange={(value) => console.log(value)}
/>

AutoComplete

<AutoComplete
  label="City"
  options={[
    { value: 'nyc', label: 'New York' },
    { value: 'la', label: 'Los Angeles' },
    { value: 'chi', label: 'Chicago' },
  ]}
  onSearch={(q) => console.log(q)}
/>

Theming

Zenput provides a comprehensive theming system with semantic colors, per-component tokens, density scaling, and theme composition utilities.

Basic Theming

Use ThemeProvider to customize design tokens:

import { ThemeProvider } from 'zenput';

<ThemeProvider
  theme={{
    primaryColor: '#6366f1',
    errorColor: '#dc2626',
    successColor: '#16a34a',
    warningColor: '#d97706',
    borderRadius: '8px',
    fontFamily: 'Inter, sans-serif',
  }}
>
  {/* your app */}
</ThemeProvider>

Advanced Theming

Theme Modes

Switch between light, dark, and high-contrast modes:

<ThemeProvider theme={{ mode: 'dark' }}>
  {/* your app */}
</ThemeProvider>

Available modes: 'light' (default), 'dark', 'highContrast', 'system'

System Mode (OS Preference)

Use mode="system" to automatically follow the OS prefers-color-scheme preference. The resolved mode updates live when the user changes their OS setting:

<ThemeProvider theme={{ mode: 'system' }}>
  {/* Automatically light or dark based on OS preference */}
</ThemeProvider>

Persistence with storageKey

Pass storageKey to persist the user's mode choice across page loads. The stored value takes precedence over the theme.mode prop on subsequent visits:

<ThemeProvider theme={{ mode: 'system' }} storageKey="zp-theme">
  {/* User's last chosen mode is remembered */}
</ThemeProvider>

Use storage="sessionStorage" to scope persistence to the current browser session:

<ThemeProvider theme={{ mode: 'light' }} storageKey="zp-theme" storage="sessionStorage">
  {/* ... */}
</ThemeProvider>

useColorMode() Hook

Read and control the color mode from any descendant component:

import { useColorMode } from 'zenput';

function ThemeToggle() {
  const { mode, resolvedMode, setMode, toggle } = useColorMode();

  return (
    <button onClick={toggle}>
      Current: {resolvedMode} (selected: {mode})
    </button>
  );
}

| Property | Type | Description | |----------|------|-------------| | mode | ColorMode | User-selected mode (may be 'system'). | | resolvedMode | ThemeMode | Actual applied mode ('light' \| 'dark' \| 'highContrast'). | | setMode(mode) | (mode: ColorMode) => void | Change the mode (persists if storageKey is set). | | toggle() | () => void | Toggle between 'light' and 'dark'. |

High-Contrast Auto-Detection

When detectHighContrast is enabled alongside mode="system", the provider automatically switches to 'highContrast' when the OS prefers-contrast: more media feature is active:

<ThemeProvider theme={{ mode: 'system' }} detectHighContrast>
  {/* ... */}
</ThemeProvider>

Anti-Flash Script (Next.js App Router)

Prevent a flash of the wrong colour scheme during server-side rendering by injecting an inline script into <head> before React hydrates. Use getColorModeScript to generate the script string:

// app/layout.tsx
import Script from 'next/script';
import { ThemeProvider, getColorModeScript } from 'zenput';

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        <Script
          id="zp-color-mode"
          strategy="beforeInteractive"
          dangerouslySetInnerHTML={{
            __html: getColorModeScript({ storageKey: 'zp-theme', defaultMode: 'system' }),
          }}
        />
      </head>
      <body>
        {/* storageKey must match the script's storageKey */}
        <ThemeProvider theme={{ mode: 'system' }} storageKey="zp-theme" as="main">
          {children}
        </ThemeProvider>
      </body>
    </html>
  );
}

The script sets data-zp-theme on <html> before the first paint so your CSS variables are already correct when the page renders. This eliminates the flash even when the user prefers dark mode or has a stored preference.

useReducedMotion() Hook

Detect the OS prefers-reduced-motion: reduce preference and disable animations accordingly. All built-in Zenput animations already honour this media feature via the --zp-duration-* CSS custom properties:

import { useReducedMotion } from 'zenput';

function AnimatedCard() {
  const reduced = useReducedMotion();
  return (
    <div
      style={{
        transition: reduced ? 'none' : 'transform var(--zp-duration-normal) var(--zp-easing-standard)',
      }}
    >
      {/* ... */}
    </div>
  );
}

Nested ThemeProvider

Nest ThemeProvider to apply a different theme to a specific section of your UI. The inner provider inherits the parent's resolved mode by default and merges CSS tokens — the child's values override the parent's:

<ThemeProvider theme={{ mode: 'dark' }}>
  {/* Dark mode throughout */}
  <main>
    <ThemeProvider
      theme={{
        components: {
          button: { borderRadius: '9999px' },
        },
      }}
    >
      {/* Still dark mode, but with pill-shaped buttons in this section */}
    </ThemeProvider>
  </main>
</ThemeProvider>

Custom CSS variables from parent providers are inherited and can be overridden:

<ThemeProvider theme={{ cssVars: { '--brand-accent': '#6366f1' } }}>
  <ThemeProvider theme={{ cssVars: { '--brand-accent': '#8b5cf6' } }}>
    {/* --brand-accent is '#8b5cf6' here */}
  </ThemeProvider>
</ThemeProvider>

Density Scaling

Control component sizing with density tokens:

<ThemeProvider theme={{ density: 'compact' }}>
  {/* your app */}
</ThemeProvider>

Available densities: 'compact', 'normal' (default), 'spacious'

Semantic Color Overrides

Override semantic colors while preserving the mode's defaults:

<ThemeProvider
  theme={{
    mode: 'light',
    semantic: {
      brand: '#6366f1',
      brandHover: '#4f46e5',
      danger: '#ef4444',
      success: '#10b981',
    },
  }}
>
  {/* your app */}
</ThemeProvider>

Per-Component Tokens

Customize individual component styles:

<ThemeProvider
  theme={{
    components: {
      button: {
        borderRadius: '9999px',      // Pill-shaped buttons
        primaryBg: '#8b5cf6',
        primaryBgHover: '#7c3aed',
      },
      input: {
        borderRadius: 'var(--zp-radius-xl)',
        borderColor: '#8b5cf6',
      },
      badge: {
        fontSize: 'var(--zp-font-size-sm)',
        borderRadius: 'var(--zp-radius-sm)',
      },
    },
  }}
>
  {/* your app */}
</ThemeProvider>

Available component tokens: button, input, badge, dialog, tooltip, dataTable

Theme Composition with extendTheme()

Compose themes by extending a base theme:

import { ThemeProvider, extendTheme } from 'zenput';

// Create a base brand theme
const brandTheme = {
  mode: 'light' as const,
  semantic: {
    brand: '#6366f1',
    brandHover: '#4f46e5',
  },
};

// Extend with additional customizations
const customTheme = extendTheme(brandTheme, {
  density: 'spacious',
  components: {
    button: {
      borderRadius: 'var(--zp-radius-lg)',
    },
  },
});

<ThemeProvider theme={customTheme}>
  {/* your app */}
</ThemeProvider>

Multiple Theme Extensions

Chain multiple theme presets:

const baseTheme = { mode: 'light' as const };
const densityPreset = { density: 'compact' as const };
const componentOverrides = {
  components: {
    button: { borderRadius: 'var(--zp-radius-full)' },
  },
};

const finalTheme = extendTheme(baseTheme, densityPreset, componentOverrides);

Custom CSS Variables

Add arbitrary CSS custom properties:

<ThemeProvider
  theme={{
    cssVars: {
      '--custom-accent': '#f59e0b',
      '--custom-highlight': '#fbbf24',
    },
  }}
>
  {/* your app */}
</ThemeProvider>

Token Browser

Explore all available design tokens interactively:

TokenBrowser uses the --zp-* CSS variables emitted by ThemeProvider, so render it inside a provider:

import { ThemeProvider, TokenBrowser } from 'zenput';

<ThemeProvider>
  <TokenBrowser defaultCategory="colors" />
</ThemeProvider>

Design Token Reference

The following CSS custom properties are emitted by ThemeProvider and available for advanced customisation.

Focus ring (--zp-focus-ring-*)

| Custom property | Default value | Notes | |-----------------|---------------|-------| | --zp-focus-ring-width | 2px | Outline width | | --zp-focus-ring-offset | 2px | Outline offset | | --zp-focus-ring-style | solid | Outline style | | --zp-focus-ring-color | var(--zp-color-focus-ring) | Tracks the semantic focus-ring color |

The global .zp-focus-ring:focus-visible utility class in src/styles/globals.css uses these tokens so all interactive elements stay in sync automatically.

Surface levels (--zp-color-surface-*)

| Custom property | Light | Dark | High-contrast | |-----------------|-------|------|---------------| | --zp-color-surface-0 | #ffffff | #0b0f17 | #000000 | | --zp-color-surface-1 | #ffffff | #121826 | #000000 | | --zp-color-surface-2 | #f9fafb | #1a2132 | #000000 | | --zp-color-surface-3 | #f3f4f6 | #222b3d | #000000 | | --zp-color-surface-4 | #e5e7eb | #2b3548 | #000000 |

Use these in Card, Dialog, Popover, and Menu surfaces to express depth.

Semantic state triplets

Each state (success, warning, danger, info, neutral) exposes a bg-subtle, bg-solid, and text-on-solid triplet. These are the canonical tokens for Toast, Alert, Banner, and Tag variants.

| Custom property pattern | Purpose | |-------------------------|---------| | --zp-color-{state}-bg-subtle | Light background wash | | --zp-color-{state}-bg-solid | Vivid filled background | | --zp-color-{state}-text-on-solid | Text / icon on the filled bg |

Neutral semantic state

| Custom property | Light | Dark | |-----------------|-------|------| | --zp-color-neutral | #6b7280 | #9ca3af | | --zp-color-neutral-subtle | #f3f4f6 | rgba(156, 163, 175, 0.16) | | --zp-color-neutral-text | #374151 | #d1d5db | | --zp-color-neutral-bg-subtle | #f3f4f6 | rgba(156, 163, 175, 0.16) | | --zp-color-neutral-bg-solid | #6b7280 | #9ca3af | | --zp-color-neutral-text-on-solid | #ffffff | #ffffff |

Border tokens

| Custom property | Purpose | |-----------------|---------| | --zp-color-border | Default border | | --zp-color-border-subtle | Subtle / muted border | | --zp-color-border-strong | Emphasized border | | --zp-color-border-inverse | White border on dark surfaces | | --zp-color-border-focus | Focus indicator border |

Overlay / z-index / elevation tokens

Z-index scale (--zp-z-*)

| Custom property | Value | Usage | |-----------------|-------|-------| | --zp-z-hide | -1 | Hidden layers | | --zp-z-base | 0 | Default stacking | | --zp-z-raised | 1 | Slightly raised elements | | --zp-z-docked | 10 | Docked/fixed bars | | --zp-z-dropdown | 1000 | Dropdown menus | | --zp-z-sticky | 1100 | Sticky headers/footers | | --zp-z-banner | 1200 | Banners/notifications | | --zp-z-overlay | 1300 | Generic overlays | | --zp-z-drawer | 1350 | Drawer / side-sheet | | --zp-z-modal | 1400 | Modal dialogs | | --zp-z-dialog | 1400 | Dialog (alias for modal) | | --zp-z-popover | 1500 | Popovers | | --zp-z-skip-nav | 1600 | Skip-navigation links | | --zp-z-toast | 1700 | Toast notifications | | --zp-z-tooltip | 1800 | Tooltips |

Elevation scale (--zp-elevation-*)

Each elevation level maps to a named shadow token:

| Custom property | Shadow token | Description | |-----------------|--------------|-------------| | --zp-elevation-0 | shadow.none | No shadow | | --zp-elevation-1 | shadow.xs | Extra-small shadow | | --zp-elevation-2 | shadow.sm | Small shadow | | --zp-elevation-3 | shadow.md | Medium shadow | | --zp-elevation-4 | shadow.lg | Large shadow | | --zp-elevation-5 | shadow.xl | Extra-large shadow |

Radius aliases

| Custom property | Value | Usage | |-----------------|-------|-------| | --zp-radius-pill | 9999px | Pill-shaped buttons and badges | | --zp-radius-card | 8px | Card / panel surfaces |

Motion (--zp-easing-*)

| Custom property | Curve | Usage | |-----------------|-------|-------| | --zp-easing-standard | cubic-bezier(0.4, 0, 0.2, 1) | Default transitions | | --zp-easing-emphasized | cubic-bezier(0.2, 0, 0, 1) | Emphasized motion | | --zp-easing-decelerate | cubic-bezier(0, 0, 0.2, 1) | Entrance animations | | --zp-easing-accelerate | cubic-bezier(0.4, 0, 1, 1) | Exit animations | | --zp-easing-bounce | cubic-bezier(0.34, 1.56, 0.64, 1) | Playful overshoot (popover open) |

Typography presets

Semantic typography presets are available as .zp-text-* CSS utility classes.

| Class | Use case | |-------|----------| | .zp-text-display-lg | Hero / splash headings | | .zp-text-display-md | Section splash headings | | .zp-text-heading-1.zp-text-heading-6 | Page / section headings | | .zp-text-body-lg | Large body copy | | .zp-text-body-md | Default body copy | | .zp-text-body-sm | Small / dense body copy | | .zp-text-caption | Labels beneath images/charts | | .zp-text-overline | ALL-CAPS category labels | | .zp-text-code | Inline or block code snippets |

All classes reference --zp-* CSS custom properties, so they automatically adapt to the active theme.

Overlay backdrop (--zp-overlay)

| Custom property | Light | Dark | High-contrast | |-----------------|-------|------|---------------| | --zp-overlay | rgba(17, 24, 39, 0.5) | rgba(0, 0, 0, 0.6) | rgba(0, 0, 0, 0.75) |

Props

Common props (all components inherit BaseInputProps)

| Prop | Type | Default | Description | |------|------|---------|-------------| | size | 'sm' \| 'md' \| 'lg' | 'md' | Visual size | | variant | 'outlined' \| 'filled' \| 'underlined' | 'outlined' | Visual variant | | validationState | 'default' \| 'error' \| 'success' \| 'warning' | 'default' | Validation state | | label | string | — | Label text | | helperText | string | — | Helper text below input | | errorMessage | string | — | Error message (shown when validationState='error') | | successMessage | string | — | Success message | | warningMessage | string | — | Warning message | | required | boolean | false | Mark field as required | | disabled | boolean | false | Disable the input | | readOnly | boolean | false | Make the input read-only | | prefixIcon | React.ReactNode | — | Icon/element before the input | | suffixIcon | React.ReactNode | — | Icon/element after the input | | fullWidth | boolean | false | Expand to full container width | | wrapperClassName | string | — | Class for the wrapper element | | inputClassName | string | — | Class for the input element |

Accessibility primitives

Zenput ships a set of first-class accessibility primitives in src/components/a11y/. All are exported from the top-level zenput package entry point.

<VisuallyHidden>

Visually hides content while keeping it accessible to screen readers (clip-path technique). The hiding styles always win over consumer-supplied style props.

import { VisuallyHidden } from 'zenput';

<button>
  <span aria-hidden="true">🔍</span>
  <VisuallyHidden>Search</VisuallyHidden>
</button>

Props:

| Prop | Type | Default | Description | |------|------|---------|-------------| | as | React.ElementType | 'span' | Rendered element type | | children | React.ReactNode | — | Content to hide visually |


<SkipLink>

Keyboard-only visible anchor that becomes visible when it receives focus. Always place it as the very first focusable element in the document so keyboard users can skip repetitive navigation.

import { SkipLink } from 'zenput';

// Place before <nav> in your layout
<SkipLink href="#main" />
<nav>…</nav>
<main id="main">…</main>

Props:

| Prop | Type | Default | Description | |------|------|---------|-------------| | href | string | '#main' | Target fragment id | | children | React.ReactNode | 'Skip to main content' | Link label |


<LiveRegion> + useAnnounce()

Mount one <LiveRegion> near your app root. Use useAnnounce() anywhere in the tree to push polite or assertive messages into an aria-live region.

Features: one mounted region per app, debounced re-announcement of identical messages (clears then re-sets so screen readers re-read).

import { LiveRegion, useAnnounce } from 'zenput';

// In your root layout:
<LiveRegion>
  <App />
</LiveRegion>

// Anywhere in the tree:
function SearchResults({ count }: { count: number }) {
  const announce = useAnnounce();

  useEffect(() => {
    announce(`${count} results found`);
  }, [count, announce]);

  return <ul>…</ul>;
}

LiveRegion props:

| Prop | Type | Default | Description | |------|------|---------|-------------| | children | React.ReactNode | — | Wrapped content |

useAnnounce() signature:

type AnnounceOptions = { politeness?: 'polite' | 'assertive' };
announce(message: string, options?: AnnounceOptions): void

<FocusScope>

Declarative wrapper around useFocusTrap. Renders a container element and manages focus trapping, auto-focus on activation, and focus restoration on deactivation. Used internally by <Dialog>, <Drawer>, and <Menu>.

import { FocusScope } from 'zenput';

<FocusScope trapped restoreFocus autoFocus>
  <div role="dialog" aria-label="Settings">
    <button>Save</button>
    <button>Cancel</button>
  </div>
</FocusScope>

Props:

| Prop | Type | Default | Description | |------|------|---------|-------------| | trapped | boolean | false | Activate the focus trap | | restoreFocus | boolean | true | Restore focus on deactivation | | autoFocus | boolean | true | Auto-focus first tabbable element on activation | | clickOutsideDeactivates | boolean | true | Allow clicks outside to move focus | | initialFocusRef | RefObject<HTMLElement> | — | Override initial focus target | | returnFocusRef | RefObject<HTMLElement> | — | Override focus-restoration target | | as | React.ElementType | 'div' | Rendered container element |


useRovingTabIndex()

Generic hook for keyboard-navigable lists and grids. Implements the WAI-ARIA roving-tabindex pattern: only the active item has tabIndex={0}; all others have tabIndex={-1}. Arrow keys, Home, and End move focus within the group.

import { useRovingTabIndex } from 'zenput';

const items = ['apple', 'banana', 'cherry'];

function FruitList() {
  const [selected, setSelected] = useState('apple');
  const containerRef = useRef<HTMLDivElement>(null);

  const { getTabIndex, onKeyDown } = useRovingTabIndex({
    items,
    activeItem: selected,
    onNavigate: setSelected,
    containerRef,           // enables automatic DOM focus on navigation
  });

  return (
    <div role="listbox" ref={containerRef} onKeyDown={onKeyDown}>
      {items.map((id) => (
        <div
          key={id}
          role="option"
          tabIndex={getTabIndex(id)}
          data-rti-value={id}       // required for DOM focus to work
          aria-selected={id === selected}
          onClick={() => setSelected(id)}
        >
          {id}
        </div>
      ))}
    </div>
  );
}

Options:

| Option | Type | Default | Description | |--------|------|---------|-------------| | items | string[] | — | Ordered item identifiers in DOM order | | activeItem | string | — | Currently active item | | orientation | 'horizontal' \| 'vertical' \| 'both' | 'horizontal' | Navigation axis | | onNavigate | (item: string) => void | — | Called when focus should move | | loop | boolean | true | Wrap from last to first and vice versa | | disabledItems | string[] | [] | Items skipped during navigation | | containerRef | RefObject<HTMLElement> | — | Container ref for automatic DOM focus |


useId() re-export

React 18+ ships useId() built-in. Zenput re-exports it so consumers don't need to reach for a third-party package:

import { useId } from 'zenput';

function Field({ label }: { label: string }) {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </>
  );
}

Imperative overlays

Zenput ships provider-based imperative APIs for Dialog, Drawer, and Popover. These are designed for "fire-and-forget" use cases where managing open state and JSX placement would be inconvenient — confirming navigation, prompting for input from a row-action callback, or surfacing errors from fetch().catch().

Anti-pattern guard — providers are for transient / imperative flows. The declarative <Dialog open={x}>…</Dialog> API remains the recommended primitive for dialogs whose content is part of the page layout. Both APIs ship side-by-side.

Setup

Wrap your application (or a subtree) with the providers once:

import { DialogProvider, DrawerProvider, PopoverProvider } from 'zenput';

<DialogProvider>
  <DrawerProvider>
    <PopoverProvider>
      <App />
    </PopoverProvider>
  </DrawerProvider>
</DialogProvider>

useConfirm

import { useConfirm } from 'zenput';

function DeleteButton() {
  const confirm = useConfirm();

  const handleDelete = async () => {
    const ok = await confirm({
      title: 'Delete project?',
      description: 'This cannot be undone.',
      confirmLabel: 'Delete',
      cancelLabel: 'Cancel',
      destructive: true,   // uses the danger button variant
      dismissible: true,   // Escape / backdrop resolves false (default)
    });
    if (ok) deleteProject();
  };

  return <button onClick={handleDelete}>Delete</button>;
}

usePrompt

import { usePrompt } from 'zenput';

function RenameButton({ file, renameFile }: Props) {
  const prompt = usePrompt();

  const handleRename = async () => {
    const newName = await prompt({
      title: 'Rename file',
      label: 'New name',
      defaultValue: file.name,
      validate: (v) => v.trim().length > 0 || 'Name is required',
    });
    if (newName) renameFile(newName);
  };

  return <button onClick={handleRename}>Rename</button>;
}

useAlert

import { useAlert } from 'zenput';

function SaveButton() {
  const alert = useAlert();

  // Works great inside async error handlers — no JSX needed at the call site.
  const handleSave = async () => {
    try {
      await fetch('/api/save');
    } catch (err) {
      await alert({
        title: 'Save failed',
        description: err instanceof Error ? err.message : 'Unknown error',
      });
    }
  };

  return <button onClick={handleSave}>Save</button>;
}

useDialog — generic content

import { useDialog } from 'zenput';

function MyButton() {
  const dialog = useDialog();

  const openForm = async () => {
    const handle = dialog.open<string>({
      size: 'md',
      content: ({ close }) => (
        <MyForm onSubmit={(v) => close(v)} onCancel={() => close()} />
      ),
    });
    const result = await handle.result; // string | null
    return result;
  };

  return <button onClick={openForm}>Open form</button>;
}

useDrawer

Same shape as useDialog but anchored to an edge of the viewport.

import { useDrawer, DrawerHeader, DrawerTitle, DrawerBody, DrawerFooter } from 'zenput';

function OpenDetailsButton() {
  const drawer = useDrawer();

  const handleOpen = () => {
    drawer.open({
      side: 'right',   // 'left' | 'right' | 'top' | 'bottom'
      size: 'md',
      content: ({ close }) => (
        <>
          <DrawerHeader><DrawerTitle>Details</DrawerTitle></DrawerHeader>
          <DrawerBody>…</DrawerBody>
          <DrawerFooter><button onClick={() => close()}>Done</button></DrawerFooter>
        </>
      ),
    });
  };

  return <button onClick={handleOpen}>Open details</button>;
}

usePopover

Anchor a popover to an element ref or (x, y) viewport coordinates.

import { useRef } from 'react';
import { usePopover } from 'zenput';

function PopoverDemo() {
  const popover = usePopover();
  const ref = useRef<HTMLButtonElement>(null);

  // Anchored to an element
  const openMenu = () => {
    popover.open({
      anchor: ref,
      side: 'bottom',
      content: ({ close }) => <Menu onSelect={(v) => close(v)} />,
    });
  };

  // Anchored to cursor (context menu)
  const handleContextMenu = (e: React.MouseEvent) => {
    e.preventDefault();
    popover.open({
      anchor: { x: e.clientX, y: e.clientY },
      content: ({ close }) => <Menu onSelect={(v) => close(v)} />,
    });
  };

  return (
    <button ref={ref} onClick={openMenu} onContextMenu={handleContextMenu}>
      Menu
    </button>
  );
}

Promise resolution

| Hook | Resolved value | Dismissed value | |------|---------------|-----------------| | useConfirm | true | false | | usePrompt | string | null | | useAlert | void | void | | useDialog | value passed to close(value) | null | | useDrawer | value passed to close(value) | null | | usePopover | value passed to close(value) | null |

All promises resolve (never reject). When the provider unmounts with open dialogs, remaining promises are resolved with their dismissed value — no unhandled rejection warnings.

Stack support

Opening a dialog (or confirm) from inside another dialog stacks them in DOM order — the most recently opened overlay is on top. Escape and backdrop dismissal target only the topmost overlay; if the topmost is dismissible: false, neither it nor any underlying overlay can be dismissed via Escape/backdrop until it is closed programmatically.

Development

# Install dependencies
npm install

# Run tests (Vitest)
npm test

# Watch tests / open Vitest UI
npm run test:watch
npm run test:ui

# Run tests with coverage (85% lines/statements/functions, 80% branches)
npm run test:coverage

# Build the library (Rollup → dist/)
npm run build

# Lint (ESLint 9 flat config + jsx-a11y)
npm run lint

# Type check
npm run type-check

# Bundle-size budget (size-limit)
npm run size
npm run size:why

# Storybook
npm run storybook         # dev server on :6006
npm run build-storybook   # static build → storybook-static/
npm run test:storybook    # Storybook a11y via @storybook/test-runner

Running CI locally

Both GitHub Actions and the Azure Pipeline delegate to the same npm scripts, so a local run that matches CI is:

npm ci
npm run lint
npm run type-check
npm run test:ci          # coverage + JUnit (reports/junit.xml)
npm run build
npm run build-storybook
npm run size

Azure self-hosted prerequisites

The Azure Pipeline (AzCICD.yml) targets a single self-hosted Linux agent in the pool Default and runs all CI steps sequentially on that one machine (lint → type-check → test → build → size → Storybook a11y). The agent needs git, Node 20.x (ideally managed via nvm), and npm 10+ pre-installed. Playwright browsers are cached under ~/.cache/ms-playwright and installed only when missing. SonarCloud analysis is gated on a SONAR_TOKEN pipeline variable and a service connection named SonarCloud.

Forms

Zenput ships an opt-in form integration via the zenput/forms subpath. It wraps react-hook-form + zod so that every Zenput input gets value, onChange, onBlur, name, ref, validationState, errorMessage, aria-invalid, and aria-describedby wired up automatically.

Installation

Install the peer dependencies alongside Zenput:

npm install zenput react-hook-form @hookform/resolvers zod

react-hook-form, @hookform/resolvers, and zod are peer-optional — the core bundle is unaffected if you don't use the zenput/forms entry point.

End-to-end example

import { z } from 'zod';
import { Form, useZenputForm } from 'zenput/forms';
import { TextInput, PasswordInput, Button } from 'zenput';

const loginSchema = z.object({
  email: z.string().email('Please enter a valid email address'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
});

type LoginValues = z.infer<typeof loginSchema>;

export function LoginForm() {
  const form = useZenputForm<LoginValues>({
    schema: loginSchema,
    defaultValues: { email: '', password: '' },
  });

  const onSubmit = async (values: LoginValues) => {
    await fetch('/api/login', { method: 'POST', body: JSON.stringify(values) });
  };

  return (
    <Form form={form} onSubmit={onSubmit}>
      {/* Error summary — focuses and lists all errors for screen readers */}
      <Form.ErrorSummary />

      <Form.Field<LoginValues> name="email">
        {(field) => (
          <TextInput
            label="Email"
            type="email"
            placeholder="[email protected]"
            validationState={field.props.validationState}
            errorMessage={field.props.errorMessage}
            value={field.props.value as string}
            onChange={(e) => field.props.onChange(e.target.value)}
            onBlur={field.props.onBlur}
            ref={field.props.ref as React.Ref<HTMLInputElement>}
            name={field.props.name}
            fullWidth
          />
        )}
      </Form.Field>

      <Form.Field<LoginValues> name="password">
        {(field) => (
          <PasswordInput
            label="Password"
            validationState={field.props.validationState}
            errorMessage={field.props.errorMessage}
            value={field.props.value as string}
            onChange={(e) => field.props.onChange(e.target.value)}
            onBlur={field.props.onBlur}
            ref={field.props.ref as React.Ref<HTMLInputElement>}
            name={field.props.name}
            fullWidth
          />
        )}
      </Form.Field>

      <Button type="submit" loading={form.formState.isSubmitting} fullWidth>
        Sign in
      </Button>
    </Form>
  );
}

API

useZenputForm(options)

| Option | Type | Default | Description | |---|---|---|---| | schema | ZodType | — | Zod schema for validation (optional). | | defaultValues | Partial<TFieldValues> | — | Initial form values. | | mode | 'onBlur' \| 'onChange' \| 'onSubmit' \| 'onTouched' \| 'all' | 'onBlur' | Validation trigger mode. |

Returns the standard react-hook-form UseFormReturn object — every RHF API is available.

<Form form={form} onSubmit={handler}>

| Prop | Type | Description | |---|---|---| | form | UseFormReturn | The form instance from useZenputForm. | | onSubmit | SubmitHandler | Called with validated values on success. | | onError | SubmitErrorHandler | Called with validation errors on failure (optional). |

<Form.Field name="fieldName">

Render-prop component. The child function receives { props, invalid, errorMessage }. Spread field.props onto any Zenput input:

<Form.Field name="email">
  {(field) => <TextInput {...field.props} label="Email" />}
</Form.Field>

<Form.Submit> / <Form.Reset>

Pre-wired <button type="submit"> and <button type="button"> (Reset wires to RHF's reset() to ensure controlled fields are cleared). Both are automatically disabled while the form is submitting.

<Form.ErrorSummary>

Renders a live region listing all current field errors. When errors first appear (e.g., after a failed submit), the container is focused automatically — critical for keyboard and screen-reader users.

Recipe: no form library (useFormField only)

If you don't want react-hook-form, use useFormField directly:

import { useFormField } from 'zenput';
import { TextInput } from 'zenput';
import { useState } from 'react';

function EmailField() {
  const [value, setValue] = useState('');
  const [error, setError] = useState('');

  const { inputId, inputAriaProps } = useFormField({
    id: 'email',
    label: 'Email',
    errorMessage: error,
    validationState: error ? 'error' : 'default',
    required: true,
  });

  return (
    <TextInput
      id={inputId}
      label="Email"
      value={value}
      onChange={(e) => { setValue(e.target.value); setError(''); }}
      validationState={error ? 'error' : 'default'}
      errorMessage={error}
      required
      {...inputAriaProps}
    />
  );
}

Next.js App Router

Zenput ships with 'use client' directives in its bundle so every component and hook is clearly marked as a Client Component boundary. This means you can import zenput components directly from Server Components — Next.js will automatically render them on the client.

Server-safe sub-path exports

| Import path | Contents | Safe in Server Component? | |---|---|---| | zenput | All components + hooks | ✅ (via 'use client' boundary) | | zenput/tokens | Design token objects, cssVar(), buildCssVariables() | ✅ Yes (no React) | | zenput/server | getColorModeScript() | ✅ Yes (no React) | | zenput/forms | Form, useZenputForm | ✅ (via 'use client' boundary) |

Minimal App Router setup

// app/layout.tsx  (Server Component)
import Script from 'next/script';
import { getColorModeScript } from 'zenput/server';
import type { Metadata } from 'next';

export const metadata: Metadata = { title: 'My App' };

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <head>
        {/*
         * Sets data-zp-theme on <html> before first paint so CSS variables
         * are scoped correctly before React hydrates, preventing theme flash.
         * The storageKey must match the one passed to <ThemeProvider>.
         */}
        <Script
          id="zp-color-mode"
          strategy="beforeInteractive"
          dangerouslySetInnerHTML={{
            __html: getColorModeScript({ storageKey: 'zp-theme', defaultMode: 'system' }),
          }}
        />
      </head>
      <body>{children}</body>
    </html>
  );
}
// app/page.tsx  (Server Component — token utilities run on the server)
import { cssVar, CSS_VAR_PREFIX } from 'zenput/tokens';
import MyForm from './MyForm';           // client component

export default function Page() {
  const brand = cssVar('color-brand');   // runs on server, no hooks needed
  return <MyForm />;
}
// app/MyForm.tsx  (Client Component)
'use client';
import { ThemeProvider, TextInput, Button } from 'zenput';

export default function MyForm() {
  return (
    // storageKey must match the one passed to getColorModeScript above
    <ThemeProvider theme={{ mode: 'system' }} storageKey="zp-theme">
      <TextInput label="Email" placeholder="[email protected]" />
      <Button>Submit</Button>
    </ThemeProvider>
  );
}

getColorModeScript options

The zenput/server entry re-exports getColorModeScript from the same module that the main zenput bundle exports — so you can import it from either path.

import { getColorModeScript } from 'zenput/server';
// or:
import { getColorModeScript } from 'zenput';

getColorModeScript({
  storageKey: 'zp-theme',        // required — must match ThemeProvider storageKey
  defaultMode: 'system',         // mode when no stored value ('system' respects OS)
  storage: 'localStorage',       // 'localStorage' (default) or 'sessionStorage'
  detectHighContrast: false,     // set true to honour prefers-contrast: more
});

transpilePackages (optional)

If you see module resolution errors, add zenput to transpilePackages in next.config.ts:

// next.config.ts
const nextConfig = {
  transpilePackages: ['zenput'],
};
export default nextConfig;

SSR Safety

All Zenput components are designed to render safely in server-side environments:

  • Portal — Defers document access until after mount using useSyncExternalStore with a server snapshot of false; renders null on the server. No browser globals accessed at module evaluation time.

  • useFocusTrap — All document access is inside useEffect, which only runs on the client. Safe to call during SSR.

  • ThemeProvider — SSR-safe by default. When mode='system' is set, matchMedia calls are deferred to useEffect and guarded by typeof window !== 'undefined'. Deterministic SSR output is achieved by rendering with the defaultMode until the client determines the OS preference.

  • useDisclosure — Uses useState and useRef from React 18+; no useId fallback or random values that could cause hydration mismatches.

  • Internal hooks (useClickOutside, useEscapeKey, useMenuKeyboardNav) — All event-listener registration is deferred to useEffect.


License

MIT © konarsubhojit