@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’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).
