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

@alphinex/ui

v2.7.0

Published

Primitive and composite component library (Button, Modal, Menu, Card, ...).

Downloads

132

Readme

@alphinex/ui

The platform's primitive component library — Button, Input, Select, Card, and friends. Every component is styled entirely off the --aui-* CSS variables that @alphinex/theme's ThemeProvider sets on the document root, so nothing here needs its own theming logic: mount a ThemeProvider above your tree and these components pick up color scheme, density, and RTL automatically. Variant styling is built with class-variance-authority (cva); each component with visual variants exports a matching *Variants function for anyone who needs the generated class string directly (e.g. to style a custom element consistently).

Button

The workhorse component. Supports variant (solid | outline | ghost | subtle), tone (neutral | danger | success), size (sm | md | lg), a built-in loading state, and leading/trailing icon slots. Pass asChild to render your own element (e.g. a router <Link>) styled as a button instead of a <button>, via the Slot mechanism described below:

import { Button } from "@alphinex/ui";
import { Icon } from "@alphinex/icons";

function DeleteAction({ pending, onDelete }: { pending: boolean; onDelete: () => void }) {
  return (
    <Button variant="solid" tone="danger" isLoading={pending} onClick={onDelete}>
      Delete account
    </Button>
  );
}

// Style a router link as a button, with no extra DOM wrapper:
<Button asChild variant="outline">
  <a href="/docs">Read the docs</a>
</Button>;

isLoading disables the button, sets aria-busy, and swaps in a spinner in place of leadingIcon (the trailingIcon, if any, is hidden while loading). buttonVariants is exported for className-level customization outside the component.

Input

A styled <input> with optional leadingIcon/trailingIcon slots (only rendered as a wrapping <div> when at least one icon is supplied — otherwise it's a bare <input>) and a size prop (sm | md | lg, note this shadows the native size attribute, which is omitted from InputProps). containerClassName targets the icon wrapper specifically:

import { Input } from "@alphinex/ui";
import { Icon } from "@alphinex/icons";

<Input
  size="md"
  placeholder="Search…"
  leadingIcon={<Icon name="search" size="sm" />}
  aria-label="Search"
/>;

inputVariants is exported for the base input class string.

Select

A native <select> styled to match the rest of the platform — fully accessible and keyboard-operable with no custom ARIA. Takes an options: SelectOption[] array ({ label, value, disabled? }) and an optional placeholder rendered as a disabled first option. (There's no headless combobox/multi-select here by design — see ADR-0001 for when that'd change.)

import { Select, type SelectOption } from "@alphinex/ui";

const countries: SelectOption[] = [
  { label: "United States", value: "US" },
  { label: "United Kingdom", value: "UK" },
  { label: "Pakistan", value: "PK" },
];

<Select options={countries} placeholder="Choose a country" size="md" />;

Tooltip

Shows content near a single trigger children element on hover/focus, after delayMs (default 200ms; hiding is immediate). Positioning is direction-aware — it reads dir from @alphinex/theme's useTheme(), so placement="start"/"end" flip correctly under RTL — and it renders through a portal with role="tooltip", dismissible via Escape:

import { Tooltip, Button } from "@alphinex/ui";

<Tooltip content="Copy to clipboard" placement="top">
  <Button variant="ghost" size="sm">
    Copy
  </Button>
</Tooltip>;

children must be a single element — Tooltip merges its trigger props (onMouseEnter, onFocus, aria-describedby, ...) onto it via Slot, so it needs something to merge onto.

Slot

The polymorphic primitive behind every component's asChild prop (Radix-style). Instead of rendering its own wrapper element, Slot clones its single child and merges props, className, style, and ref onto it:

import { Slot } from "@alphinex/ui";

function Link({
  asChild,
  ...props
}: { asChild?: boolean } & React.AnchorHTMLAttributes<HTMLAnchorElement>) {
  const Comp = asChild ? Slot : "a";
  return <Comp {...props} />;
}

Slot requires exactly one valid React element as children; passing zero or multiple children renders nothing (and throws via Children.only for the multiple-children case).

Simple primitives

| Component | Purpose | | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Badge | Small status/count pill. variant (solid \| subtle \| outline), tone (neutral \| accent \| danger \| success \| warning), size (sm \| md). | | Card | Compound container: Card, Card.Header, Card.Body, Card.Footer, each independently optional. variant: outlined \| elevated. | | Avatar | Image with initials fallback (auto-derived from name) if src is missing or fails to load. size (sm \| md \| lg), shape (circle \| square). | | Alert | Inline banner. tone (info \| success \| warning \| danger, defaults info) picks the icon and role (alert for danger/warning, status otherwise). Optional title and onDismiss. | | Skeleton | Loading placeholder block. variant for shape, plus width/height. Renders aria-hidden. | | Label | Styled <label> with an optional required asterisk indicator. | | Checkbox | Styled native <input type="checkbox"> — controlled/uncontrolled like any checkbox input. | | Switch | Toggle button (role="switch"), controlled via checked/onCheckedChange or uncontrolled via defaultChecked (built on @alphinex/hooks' useControllableState). | | Textarea | Styled native <textarea>, defaults to rows={3}. |

import { Badge, Card, Avatar, Alert, Skeleton, Label, Checkbox, Switch, Textarea } from "@alphinex/ui";

<Badge variant="subtle" tone="success">Active</Badge>

<Card variant="elevated">
  <Card.Header>Team members</Card.Header>
  <Card.Body>...</Card.Body>
</Card>

<Avatar name="Ada Lovelace" src="/avatars/ada.jpg" size="sm" />

<Alert tone="warning" title="Unsaved changes" onDismiss={() => setShown(false)}>
  You have changes that haven't been saved yet.
</Alert>

<Skeleton variant="text" width="60%" height={16} />

<Label htmlFor="email" required>Email</Label>

<Checkbox id="agree" checked={agreed} onChange={(e) => setAgreed(e.target.checked)} />

<Switch checked={enabled} onCheckedChange={setEnabled} />

<Textarea rows={5} placeholder="Add a comment…" />

CommandPalette

A ⌘K-style modal overlay: search input, keyboard-navigable filtered results (arrow keys, Enter to select, Escape to close, hand-rolled focus trap). Filters items client-side against label and optional keywords. Supply query/onQueryChange to observe or drive the query text externally (e.g. to power an async fetch that updates items reactively) — CommandPalette still does its own client-side filtering over whatever items you pass in either way:

import { CommandPalette, type CommandPaletteItem } from "@alphinex/ui";
import { useState } from "react";

function AppCommandPalette() {
  const [isOpen, setIsOpen] = useState(false);

  const items: CommandPaletteItem[] = [
    { id: "new-invoice", label: "New invoice", onSelect: () => navigate("/invoices/new") },
    {
      id: "settings",
      label: "Settings",
      keywords: ["preferences"],
      onSelect: () => navigate("/settings"),
    },
  ];

  return (
    <CommandPalette
      isOpen={isOpen}
      onClose={() => setIsOpen(false)}
      items={items}
      placeholder="Type a command or search…"
    />
  );
}

Stepper

A horizontal step indicator. activeStep is a 0-based index; steps before it render as complete (with a check icon), the active one as current, the rest as upcoming. Pass onStepChange to make completed/upcoming steps clickable to jump to:

import { Stepper, type Step } from "@alphinex/ui";

const steps: Step[] = [
  { id: "account", label: "Account" },
  { id: "billing", label: "Billing" },
  { id: "confirm", label: "Confirm" },
];

<Stepper steps={steps} activeStep={1} onStepChange={setActiveStep} />;

stepCircleVariants is exported alongside for className-level customization of the step circle.

Dialog

A centered modal overlay: focus trap, Escape-to-close, backdrop-click-to-close, body-scroll lock, and focus-restore on close — hand-rolled, see ADR-0017. Fully controlled — you own isOpen:

import { Button, Dialog } from "@alphinex/ui";
import { useState } from "react";

function DeleteInvoiceDialog() {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <>
      <Button variant="outline" tone="danger" onClick={() => setIsOpen(true)}>
        Delete invoice
      </Button>
      <Dialog
        isOpen={isOpen}
        onClose={() => setIsOpen(false)}
        title="Delete invoice"
        footer={
          <div className="flex justify-end gap-2">
            <Button variant="ghost" onClick={() => setIsOpen(false)}>
              Cancel
            </Button>
            <Button tone="danger" onClick={handleDelete}>
              Delete
            </Button>
          </div>
        }
      >
        This can&rsquo;t be undone.
      </Dialog>
    </>
  );
}

size ("sm" | "md" | "lg", default "md") controls max-width. Pass initialFocusRef to focus a specific element (e.g. the danger action) instead of the dialog container on open. Set hideCloseButton when footer already supplies a Cancel/close action.

Drawer

An off-canvas panel — same focus-trap/Escape/backdrop/focus-restore behavior as Dialog (they share useOverlayBehavior internally), sliding in from a logical edge instead of centering:

import { Drawer } from "@alphinex/ui";

<Drawer isOpen={isOpen} onClose={() => setIsOpen(false)} title="Filters" side="end" width="24rem">
  <FilterForm />
</Drawer>;

side is "start" or "end" (default "start") — these flip automatically under dir="rtl" (see @alphinex/theme's ThemeProvider), the same logical-property convention <Shell>'s own off-canvas sidebar uses. width accepts any CSS width value (default "20rem").

Menu

A click-triggered dropdown of actions with true roving-tabindex keyboard navigation (ArrowUp/ArrowDown moves real focus, wrapping; Escape closes and returns focus to the trigger). See ADR-0018:

import { Menu } from "@alphinex/ui";

<Menu
  trigger={<Button variant="outline">Actions</Button>}
  items={[
    { id: "rename", label: "Rename", onSelect: () => rename(row.id) },
    { id: "duplicate", label: "Duplicate", onSelect: () => duplicate(row.id) },
    { id: "delete", label: "Delete", disabled: !canDelete, onSelect: () => remove(row.id) },
  ]}
/>;

trigger is rendered via Slot — pass a single element, most commonly a Button. placement (same TooltipPlacement type Tooltip uses, default "bottom") and controlled isOpen/ onOpenChange are both available, same shape as Popover below.

Popover

A click-triggered floating panel for arbitrary interactive content — dismisses on outside click or Escape, reuses Tooltip's positioning math:

import { Popover } from "@alphinex/ui";

<Popover trigger={<Button variant="outline">Filters</Button>}>
  <FilterForm />
</Popover>;

Fully controllable — pass isOpen/onOpenChange to drive it from outside instead of letting it manage its own open state.

Tabs

Horizontal tabs with roving-tabindex keyboard nav — ArrowLeft/ArrowRight move focus and activate ("automatic activation"); Home/End jump to the first/last enabled tab:

import { Tabs } from "@alphinex/ui";

<Tabs
  items={[
    { id: "overview", label: "Overview", content: <OverviewPanel /> },
    { id: "activity", label: "Activity", content: <ActivityPanel /> },
    { id: "settings", label: "Settings", content: <SettingsPanel />, disabled: !canEditSettings },
  ]}
  defaultValue="overview"
  onChange={(id) => trackTabView(id)}
/>;

Fully controllable via value/onChange, uncontrolled by default via defaultValue.

Pagination

Standalone page-number pagination — generalized out of @alphinex/table's DataGridPagination for use outside a data grid (a search-results list, a paginated card grid):

import { Pagination } from "@alphinex/ui";

<Pagination page={page} pageCount={totalPages} onPageChange={setPage} />;

siblingCount (default 1) controls how many page numbers show either side of the current page before collapsing into an ellipsis. The windowing logic itself is also exported as a pure function, getPaginationRange(page, pageCount, siblingCount?), if you need the same page-number list for a custom pagination UI.

Toast

An app-wide toast queue. Mount ToastProvider once near the root, then call useToast() anywhere beneath it — see ADR-0019:

import { ToastProvider, useToast } from "@alphinex/ui";

function Root() {
  return (
    <ToastProvider>
      <SaveButton />
    </ToastProvider>
  );
}

function SaveButton() {
  const { toast } = useToast();
  return (
    <Button
      onClick={async () => {
        await save();
        toast({ title: "Saved", description: "Invoice updated." });
      }}
    >
      Save
    </Button>
  );
}

toast({ title, description?, tone?, durationMs? }) returns the toast's id (pass it to dismiss(id) to close it programmatically). tone is "neutral" | "success" | "danger" | "warning" (default "neutral") — "danger" renders with role="alert", the rest with role="status". durationMs (default 5000) auto-dismisses the toast; pass 0 to disable auto-dismiss. ToastProvider's limit prop (default 5) drops the oldest toast once exceeded.

ConfirmDialog

A Dialog composite for "are you sure?" confirmations — onConfirm may be async, and both buttons stay disabled with a loading state on Confirm until it resolves:

import { ConfirmDialog } from "@alphinex/ui";

<ConfirmDialog
  isOpen={isConfirmOpen}
  onClose={() => setIsConfirmOpen(false)}
  title="Delete invoice"
  description="This can't be undone."
  tone="danger"
  confirmLabel="Delete"
  onConfirm={async () => {
    await deleteInvoice(id);
    setIsConfirmOpen(false);
  }}
/>;

Breadcrumb

A trail of ancestor links ending in the current (non-link) page:

import { Breadcrumb } from "@alphinex/ui";

<Breadcrumb
  items={[
    { id: "invoices", label: "Invoices", href: "/invoices" },
    { id: "current", label: "INV-1042" },
  ]}
/>;

The last item renders as plain text (aria-current="page") regardless of whether it has an href — omit href on it.

PageHeader

Title + description + actions + an optional Breadcrumb, composed together:

import { PageHeader } from "@alphinex/ui";

<PageHeader
  title="INV-1042"
  breadcrumb={[
    { id: "invoices", label: "Invoices", href: "/invoices" },
    { id: "current", label: "INV-1042" },
  ]}
  actions={<Button size="sm">Download</Button>}
/>;

Progress

A determinate progress indicator, "bar" (default) or "ring":

import { Progress } from "@alphinex/ui";

<Progress value={65} max={100} label="Storage used" showValue />
<Progress value={40} variant="ring" size="lg" label="Sync progress" />

label is required — a progress indicator has no visible text label of its own, so it needs an accessible name. showValue renders the rounded percentage alongside the indicator.

EmptyState

A "nothing here yet" placeholder for an empty list/grid/search-result state:

import { EmptyState } from "@alphinex/ui";

<EmptyState
  title="No invoices yet"
  description="Invoices you create will show up here."
  action={<Button size="sm">Create invoice</Button>}
/>;

TreeView

A hierarchical, expandable tree list (file-browser/nav-tree style) with true roving-tabindex keyboard navigation — Up/Down move focus between visible rows, Right expands/moves into a node, Left collapses/moves to the parent, Home/End jump to the first/last visible row. See ADR-0021:

import { TreeView, type TreeNode } from "@alphinex/ui";

const items: TreeNode[] = [
  {
    id: "docs",
    label: "Docs",
    children: [
      { id: "getting-started", label: "Getting Started" },
      { id: "guides", label: "Guides", children: [{ id: "guide-1", label: "Guide 1" }] },
    ],
  },
  { id: "about", label: "About" },
];

<TreeView items={items} selectedId={selectedId} onSelect={setSelectedId} />;

Expansion is fully controllable via expandedIds/onExpandedChange, uncontrolled by default via defaultExpandedIds. If you want a typed, permission-aware nav sidebar instead of a generic tree, see @alphinex/navigation's NavTree, built on this component.

RadioGroup

A single-choice group on the WAI-ARIA radiogroup pattern. Arrow keys move focus and select (the pattern's required "selection follows focus"), skipping disabled options and wrapping at both ends; Home/End jump to the first/last enabled option:

import { RadioGroup } from "@alphinex/ui";

<RadioGroup
  aria-label="Payment method"
  value={method}
  onChange={setMethod}
  options={[
    { value: "card", label: "Card" },
    { value: "bank", label: "Bank transfer", description: "2–3 business days" },
    { value: "crypto", label: "Crypto", disabled: true },
  ]}
/>;

Pass name to have the group emit a hidden input so it participates in native form submission, and orientation="horizontal" to lay it out in a row.

NumberInput

A numeric field built on the spinbutton pattern rather than <input type="number">, whose native behavior varies across engines (scroll-wheel mutation, silent rejection of intermediate text, locale-dependent parsing). ArrowUp/Down step, PageUp/Down step by 10×, Home/End jump to min/max:

import { NumberInput } from "@alphinex/ui";

<NumberInput
  aria-label="Seats"
  value={seats}
  onChange={setSeats}
  min={1}
  max={50}
  step={1}
  suffix="seats"
/>;

A cleared field reports undefined, not 0, so "no value" stays distinguishable from zero. precision defaults to the decimals implied by step, and stepping is drift-free — 0.1 + 0.1 + 0.1 gives 0.3, not 0.30000000000000004. hideControls drops the buttons while keeping keyboard stepping.

Slider / RangeSlider

Single- and two-thumb range inputs on the slider pattern. Arrow keys step, PageUp/Down step by 10×, Home/End jump to the bounds — and the arrow axis mirrors automatically under dir="rtl":

import { RangeSlider, Slider } from "@alphinex/ui";

<Slider aria-label="Volume" value={volume} onChange={setVolume} showValue />

<RangeSlider
  aria-label="Budget"
  value={budget}
  onChange={setBudget}
  marks={[
    { value: 0, label: "$0" },
    { value: 100, label: "$100k" },
  ]}
/>

Dragging one RangeSlider thumb past the other swaps them rather than inverting the range. onChangeEnd fires once when a gesture completes — use it for expensive commits (a network write) while onChange drives the live UI. The windowing math is also exported as pure functions (snapToStep, valueToPercent, percentToValue, orderRange) if you need a custom slider UI on the same behavior.

Accordion

Collapsible sections. Each header is a button inside a heading, controlling a region labelled by it; Up/Down move between headers (wrapping, skipping disabled), Home/End jump to the ends:

import { Accordion } from "@alphinex/ui";

<Accordion
  type="single"
  collapsible
  defaultValue={["shipping"]}
  items={[
    { id: "shipping", title: "Shipping", content: <p>Ships in 2 business days.</p> },
    { id: "returns", title: "Returns", content: <p>Free returns for 30 days.</p> },
  ]}
/>;

type="multiple" allows several panels open at once. variant is "separated" (default, each section in its own bordered card) or "contained" (one bordered stack with dividers).

TagInput

A free-text multi-value field for emails, labels, or keywords. Enter or comma commits the pending text; Backspace on an empty field removes the last tag:

import { TagInput } from "@alphinex/ui";

<TagInput
  aria-label="Recipients"
  value={recipients}
  onChange={setRecipients}
  placeholder="Add an address…"
  max={10}
  validate={(tag) => (tag.includes("@") ? null : "Enter a valid email address.")}
/>;

Duplicates are rejected by default (allowDuplicates opts out), and any rejection — duplicate, max reached, or a validate message — is announced via role="alert" and marks the field aria-invalid. delimiters customizes which keys commit a tag.

OtpInput

A segmented one-time-code field. Typing auto-advances, Backspace on an empty slot steps back and clears the previous one, and a paste anywhere distributes across the remaining slots — which is how the code actually arrives for most users:

import { OtpInput } from "@alphinex/ui";

<OtpInput length={6} value={code} onChange={setCode} onComplete={(code) => verify(code)} />;

type is "numeric" (default) or "alphanumeric" and filters both typed and pasted characters. mask renders filled slots as dots. onComplete fires exactly once per completed value.

Rating

A star rating built on the slider pattern rather than a radio group — a slider models a continuous value natively, which is what allowHalf needs, and reads as one value ("2.5 of 5") to a screen reader instead of ten separate options:

import { Rating } from "@alphinex/ui";

<Rating aria-label="Quality" value={score} onChange={setScore} allowHalf />

<Rating value={4} readOnly />

readOnly renders a labelled img with no controls, for displaying a score you can't change. The mouse hit-targets are deliberately aria-hidden spans, not buttons — nesting interactive controls inside a slider is an ARIA violation, and the slider already carries the keyboard model.

ColorPicker

A hex color picker: saturation/brightness area, hue slider, hex entry, and preset swatches. Fully hand-rolled — it's HSV↔RGB arithmetic plus pointer math, with no color library:

import { ColorPicker } from "@alphinex/ui";

<ColorPicker value={brand} onChange={setBrand} swatches={["#ef4444", "#3b82f6", "#22c55e"]} />;

Hex entry accepts 3- or 6-digit values with or without the hash, and the round trip is lossless — the internal HSV state keeps sub-degree hue precision, so typing #00ff7f gives back #00ff7f rather than the #00ff80 that integer-rounded hue would produce. swatchesOnly reduces it to swatches plus a hex field. The color math is exported too (hexToRgb, rgbToHsv, hsvToHex, relativeLuminance, readableTextColor) — readableTextColor is useful for picking foreground text over an arbitrary user-chosen background.

Combobox / MultiSelect / Autocomplete

The searchable-selection family, built on one shared state machine (useComboboxList). All three follow the WAI-ARIA combobox pattern's virtual focus model: DOM focus stays in the text input and the active option is tracked via aria-activedescendant, rather than the roving-tabindex approach Menu/TreeView use — a combobox has to keep receiving keystrokes while you arrow through the list.

import { Combobox, MultiSelect, Autocomplete, type ComboboxOption } from "@alphinex/ui";

const countries: ComboboxOption[] = [
  { value: "se", label: "Sweden", group: "Europe", keywords: ["scandinavia"] },
  { value: "jp", label: "Japan", group: "Asia" },
  { value: "aq", label: "Antarctica", disabled: true },
];

// Single-select. Requires a selection; unmatched typing is discarded on close.
<Combobox aria-label="Country" options={countries} value={country} onChange={setCountry} clearable />

// Multi-select. The list stays open so several can be picked in a row.
<MultiSelect aria-label="Visited" options={countries} value={visited} onChange={setVisited} max={5} />

// Free text with suggestions. The typed text *is* the value; suggestions are a shortcut.
<Autocomplete aria-label="City" options={countries} value={city} onChange={setCity} />

Shared behavior: Arrow keys move the active option (skipping disabled, wrapping), Home/End jump to the ends, Enter commits, Escape closes. Opening seeds the first option as active, so typing a filter and pressing Enter picks the top match. Enter is only swallowed when it actually commits something — a combobox inside a <form> won't silently block submission.

Options support group (rendered as headings, ordered by first appearance so re-ranking can't scramble them), keywords (matched but not displayed), and description. Pass filter to replace the built-in substring matcher, or query/onQueryChange + isLoading to drive an async fetch. The pure matching logic is exported too — filterOptions, matchesQuery, groupOptions, nextEnabledIndex.

Choosing between them: Select for a short static list (it's a native <select>, no JS keyboard model at all); Combobox when the list is long enough to need filtering; Autocomplete when free text is a valid answer.

Cascader

A column-per-level drill-down for hierarchical values — region → country → city, or a category tree:

import { Cascader, type CascaderOption } from "@alphinex/ui";

const regions: CascaderOption[] = [
  {
    value: "asia",
    label: "Asia",
    children: [{ value: "jp", label: "Japan", children: [{ value: "tokyo", label: "Tokyo" }] }],
  },
];

<Cascader aria-label="Location" options={regions} value={path} onChange={setPath} />;

The value is the path array, root-first (["asia", "jp", "tokyo"]). Only a complete path — one ending on a leaf — commits, unless you pass allowPartial. Picking an earlier level drops the deeper segments, and abandoning a half-made selection (Escape, outside click) restores the previous value rather than leaving a partial one. formatPath, columnsForPath, and isCompletePath are exported for building your own trail display.

TreeSelect

A select whose options are a hierarchy, shown all at once in a TreeView panel:

import { TreeSelect, type TreeNode } from "@alphinex/ui";

<TreeSelect
  aria-label="Category"
  items={categories}
  value={categoryId}
  onChange={setCategoryId}
  defaultExpandedIds={["docs"]}
  clearable
/>;

Set allowBranchSelection={false} to require a leaf — a branch click then only expands. Versus Cascader: use TreeSelect when the shape of the tree is itself informative and the user benefits from seeing it; use Cascader for deep-but-narrow data where one level at a time reads better.

Transfer

A dual listbox for moving items between an available set and a chosen set — the pattern for assigning permissions, columns, or team members, where a plain multi-select stops scaling:

import { Transfer } from "@alphinex/ui";

<Transfer
  aria-label="Permissions"
  options={permissions}
  value={granted}
  onChange={setGranted}
  titles={["Available", "Granted"]}
  searchable
/>;

The value is the array of chosen values, ordered by the original options order rather than the order things were checked. Each pane gets a tri-state select-all (an empty pane reads unchecked, not vacuously checked), and disabled options can never be moved — including via select-all.

VirtualizedList

Renders only the rows near the viewport, so tens of thousands of items stay responsive:

import { VirtualizedList } from "@alphinex/ui";

<VirtualizedList
  aria-label="Log lines"
  items={rows}
  itemHeight={32}
  height={400}
  getKey={(row) => row.id}
  renderItem={(row) => <LogLine row={row} />}
/>;

Fixed row height only. Variable-height virtualization needs per-row measurement and offset bookkeeping that's genuinely easy to get wrong, which is why @alphinex/table and @alphinex/calendar use @tanstack/react-virtual for it — keeping that dependency out of @alphinex/ui matters because every app depends on this package. The windowing math is exported as computeVirtualWindow and scrollOffsetForIndex if you need it directly.

See documentation/ARCHITECTURE.md for the full package contract, dependency rules, and roadmap placement.

FileUpload

A drop zone plus file picker, with type/size/count validation built in.

import { FileUpload } from "@alphinex/ui";

<FileUpload
  aria-label="Attachments"
  multiple
  accept=".pdf,.png"
  maxSize={5_000_000}
  maxFiles={5}
  value={files}
  onChange={setFiles}
  onReject={(rejections) => console.warn(rejections)}
/>;

The focusable control is the <input type="file"> itself, hidden inside a <label> that wraps the whole zone — so clicking anywhere opens the dialog, and so does focusing it and pressing Enter. Drag-and-drop is layered on top, never the only way in.

Validation matters even though the input carries accept: the browser applies accept to the file dialog only, and a drag-and-drop bypasses it entirely. Rejections are reported for every failing file, not just the first, and are announced politely.

The pure validators are exported separately for server-side or pre-flight checks:

import { validateFiles, matchesAccept, fileExtension } from "@alphinex/ui";

const { accepted, rejected } = validateFiles(picked, {
  accept: "image/*,.pdf",
  maxSize: 5_000_000,
  maxFiles: 5,
  existing: alreadyPicked,
});

matchesAccept treats extension and MIME matching as peers rather than one as a fallback, because operating systems routinely report an empty type — for anything unrecognised on Windows, and for .csv on several platforms.

For upload queues, a file browser, a gallery or an image cropper, see @alphinex/files.

Display primitives

Sprint 39 added the everyday read-only components. None of them own state beyond what they're told; all of them are theme-token-only.

Table

A plain semantic <table> — deliberately not a data grid.

import { Table } from "@alphinex/ui";

<Table
  caption="Recent invoices"
  rows={invoices}
  getRowKey={(row) => row.id}
  striped
  columns={[
    { key: "invoice", header: "Invoice" },
    { key: "amount", header: "Amount", align: "end", render: (row) => `$${row.amount}` },
  ]}
/>;

No sorting, filtering, pagination, selection or virtualization — those belong to @alphinex/table, which is built on TanStack Table and costs a much larger dependency. Most tables in an app are a static list of rows that needs to look right and be readable; reaching for the DataGrid for that is the mistake this component exists to prevent.

The caption is the table's accessible name and is offered before aria-label. Use hideCaption to keep it for screen readers without painting it.

List, Descriptions, Timeline

<List items={people} onItemClick={select} header="Team" />
<Descriptions columns={2} items={[{ label: "Client", children: "Acme" }]} />
<Timeline items={events} aria-label="Invoice activity" />

List rows become real <button>s when onItemClick is given, so they focus, activate on Enter and Space, and announce themselves without any keyboard code. trailing actions stay outside that button, since a button inside a button is invalid.

Descriptions renders a real <dl>, not a table — these are term/definition pairs, which is what associates a label with its value for a screen reader. Items can span multiple columns, and the last item in a row is widened to close a trailing gap.

Timeline was generalised out of @alphinex/business, where AuditTimeline and PaymentTimeline had been sharing it privately.

Statistic

<Statistic label="Revenue" value={128400} prefix="$" delta={12.5} deltaSuffix="%" />
<Statistic label="Churn" value={2.1} suffix="%" delta={-0.4} invertTrend />

invertTrend matters more than it looks: churn, latency, cost and error rate all improve by going down, and colouring that red is actively misleading. A zero delta reads as flat rather than up, so an unchanged figure isn't painted green.

Result and Spin

<Result status="404" title="Page not found" actions={<Button>Go home</Button>} />
<Spin spinning={isLoading}>{content}</Spin>

Result is for the outcome of something that just happened; EmptyState is for a container with nothing in it yet. Blocking statuses (error, 403, 500) announce assertively, the rest politely.

Spin is the indeterminate counterpart to Progress — reach for Progress when you have a real percentage. Wrapping content keeps it mounted and dims it rather than swapping it out, so scroll position and focus survive the load. Falls back to a pulse under prefers-reduced-motion.

Segmented

<Segmented
  aria-label="Range"
  value={range}
  onChange={setRange}
  options={[
    { value: "day", label: "Day" },
    { value: "week", label: "Week" },
  ]}
/>

It is a radiogroup — same role, same "arrow keys move focus and select" model, shared with RadioGroup through useRovingSelection. Modelling it as a tablist is the tempting mistake: tabs switch between visible panels, this sets a value, and a screen-reader user would be sent looking for panel content that doesn't exist.

Image

<Image src={url} alt="A team photo" fallbackSrc="/placeholder.png" preview />

alt is required. Pass alt="" explicitly for a decorative image — that makes silence a deliberate choice rather than the most common accessibility oversight in an image component.

Handles the loading placeholder, the error fallback (without looping if the fallback fails too), and a full-size preview dialog that closes on Escape and restores focus.

Divider, Space, Flex, Grid

<Divider>OR</Divider>
<Space split="·"><span>Terms</span><span>Privacy</span></Space>
<Flex justify="between" gap="md">{children}</Flex>
<Grid columns={1} sm={2} lg={4} gap="md">{cards}</Grid>

Grid takes breakpoints as props because the alternative is every consumer writing the same grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 string and getting the mobile-first order subtly wrong somewhere. It defaults to a single column, so the responsive story starts from the narrow case.

Space interleaves split separators as siblings rather than wrapping each child, which keeps one flex context and so keeps the gaps even. Conditional children can't leave a dangling separator.

These are intentionally thin. Anything past their handful of props is better written as className directly — the escape hatch stays first-class (§1.6).