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

@astherix/ui

v0.41.0

Published

Tailwind-based React UI components for Next.js and Laravel + Inertia apps

Readme

@astherix/ui

Tailwind v4 + React components for Next.js and Laravel (React + Inertia) apps.

Layout

packages/ui/
  theme.css              Tokens (colors, font, radius), light/dark themes
  src/lib/cn.ts          Class merging helper
  src/components/activity/ ActivityIndicator, ProgressBar, ProgressRing, Skeleton, ActivitySteps, LoadingOverlay
  src/components/avatar/ Avatar, AvatarGroup, AvatarLabel, AvatarUpload
  src/components/button/ Button
  src/components/card/   Card, ChoiceCard
  src/components/editor/ Editor (entry: @astherix/ui/editor)
  src/components/input/  Field, Input, Textarea, OtpInput
  src/components/menu/   DropdownMenu, SplitButton
  src/components/modal/  Modal
  src/components/drawer/ Drawer
  src/components/pill/   Pill, PillGroup, PillOption
  src/components/choice/ Checkbox, CheckboxGroup, RadioGroup, Radio, Switch
  src/components/select/ Select
  src/components/sidebar/ SidebarProvider, Sidebar, SidebarNav, SidebarTrigger …
  src/components/navigation/ NavigationMenu
  src/components/breadcrumbs/ Breadcrumbs
  src/components/tooltip/ Tooltip
  src/components/placeholder/ Placeholder
  src/components/table/  DataTable
  src/components/tabs/   Tabs
  src/components/timeline/ Timeline, Roadmap
  src/components/upload/ FileDropzone, FileInput, FileUploadButton, useFileUploads, xhrUpload
  src/components/toast/  Toaster, toast
  src/components/typography/ Heading, Text, Lead, Link, Code, Kbd, Mark, Blockquote, List, Prose, Stat
docs/                    User guide
  index.html             Built guide — open it in any browser, works offline
  src/main.tsx           Navigation and page list
  src/pages/             One page per component (Getting started, Button, Modal…)
  src/components/Doc.tsx Shared building blocks: Section, Code, PageHeader

Develop

npm install
npm run build        # builds packages/ui/dist
npm run typecheck
npm run docs         # rebuilds docs/index.html

Adding a component page to the guide

  1. Create docs/src/pages/InputPage.tsx using Section blocks (demo + code example).
  2. Add it to the pages list in docs/src/main.tsx.
  3. Run npm run docs.

Use in an app

Until the package is published, link it locally (npm install ../ui-framework/packages/ui) or add it to the same workspace.

1. Stylesheet (app/globals.css in Next.js, resources/css/app.css in Laravel):

@import "tailwindcss";
@import "@astherix/ui/theme.css";
@source "../node_modules/@astherix/ui/dist";   /* adjust the path relative to this file */

2. Font — Schibsted Grotesk

Next.js (app/layout.tsx):

import { Schibsted_Grotesk } from "next/font/google";
const font = Schibsted_Grotesk({ subsets: ["latin"], variable: "--font-schibsted" });
// <html className={font.variable}>

Then in your stylesheet, after the theme import:

@theme { --font-sans: var(--font-schibsted), ui-sans-serif, system-ui, sans-serif; }

Laravel (resources/views/app.blade.php, inside <head>):

<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=schibsted-grotesk:400,500,600,700" rel="stylesheet">

3. Dark mode — add the dark class to <html> (next-themes with attribute="class", or the appearance handling in Laravel's starter kit).

Accordion

<Accordion type="single" collapsible defaultValue="refunds" variant="outline" linkToHash>
  <AccordionItem value="refunds" title="How do refunds work?">…</AccordionItem>
</Accordion>

<AccordionItem value="shipping" title="Delivery" status="complete" summary="Express · ₱350">…</AccordionItem>
<AccordionItem value="reminders" title="Reminders" action={<Switch … />}>…</AccordionItem>

Accordion: type (single multiple), value / defaultValue / onValueChange, collapsible, variant (separated outline flush), size, headingLevel, linkToHash. AccordionItem: value, title, description, summary (while closed), icon, meta, action (header controls), status (complete current error locked), disabled, keepMounted. AccordionToggleAll, useAccordion() → { open, toggle, expandAll, collapseAll }. Closed sections use hidden="until-found", so Ctrl/⌘+F finds and opens them (Chrome/Edge).

Activity indicators

<ActivityIndicator variant="spinner" | "dots" | "bars" | "pulse" | "orbit" label="Loading invoices" showLabel />
<ProgressBar label="Uploading" value={pct} showValue striped />
<ProgressRing value={72} size="xl" label="Storage used" />
<Skeleton shape="text" lines={3} />
<ActivitySteps steps={[{ label: "Build", status: "done" }, { label: "Deploy", status: "active" }]} />
<LoadingOverlay loading={refreshing} label="Refreshing">…</LoadingOverlay>

ActivityIndicator: variant, size (xs–xl), tone (primary neutral current success warning danger info), label, showLabel, labelPosition. ProgressBar: value (omit for indeterminate), max, label, showValue, valueText, size, tone, striped. ProgressRing: value, max, size (sm–xl), tone, label, valueText, center children. Skeleton: shape (rect circle text), lines. ActivitySteps: steps (label, description, status: done active pending error skipped, meta), orientation, size. LoadingOverlay: loading, label, variant, delay.

Avatar

<Avatar src={user.avatarUrl} name="Maria Santos" status="online" />
<Avatar name="Dan Torres" />                              {/* initials, color from the name */}
<Avatar shape="square" icon="bi bi-building" name="Northwind" />
<AvatarGroup max={5} onOverflowClick={openMembers}>{…}</AvatarGroup>
<AvatarLabel src={…} name="Maria Santos" description="Product designer" end={<Pill>Admin</Pill>} />
<AvatarUpload name={user.name} value={user.avatarUrl} onChange={setFile} inputName="avatar" />

Avatar: src, name, size (xs sm md lg xl 2xl), shape (circle square), status (online away busy offline), icon, badge, ring, decorative, fallback. AvatarGroup: size, shape, max, spacing, onOverflowClick. AvatarLabel: Avatar props

  • description, end. AvatarUpload: value, onChange, name, size, shape, maxSizeMB, accept, inputName, disabled. getInitials(name) is exported too.

Button

import { Button } from "@astherix/ui";

<Button>Save changes</Button>
<Button variant="secondary" leadingIcon={<DownloadIcon />}>Export CSV</Button>
<Button variant="danger" loading={isDeleting}>Delete project</Button>
<Button variant="ghost" iconOnly aria-label="More options"><MoreIcon /></Button>

// Links keep button styles with asChild
<Button asChild><Link href="/billing">Open billing</Link></Button>

| Prop | Values | Default | | -------------- | ----------------------------------------------- | ----------- | | variant | primary secondary ghost danger | primary | | size | sm md lg | md | | rounded | none sm md lg full | md | | raised | boolean — darker bottom edge + press effect | true | | shadow | none sm md lg xl | none | | iconOnly | boolean (needs aria-label) | false | | fullWidth | boolean | false | | loading | boolean — show the spinner on demand | false | | spinnerPlacement | center start end | center | | loadingIndicator | spinner orbit progress (both experimental) | spinner | | progressStyle | fill bar (with progress indicator) | fill | | progress | 0–100 real progress; omit for automatic | | | loadingLabel | label while loading, e.g. "Saving…" (start/end) | | | minLoadingTime | ms the spinner stays visible at minimum | 0 | | onClick | may return a Promise → spinner until it settles | | | leadingIcon | element or icon-font class string | | | trailingIcon | element or icon-font class string | | | icon | icon for iconOnly buttons (same options) | | | asChild | boolean — render the child (e.g. a Link) | false |

Corners

<Button rounded="sm">Slightly rounded</Button>
<Button rounded="full">Pill</Button>
<Button iconOnly rounded="full" icon="bi bi-plus-lg" aria-label="Add" />  // circle

Change the default for a whole app with --radius-control (and --radius-control-sm, --radius-control-lg) in your stylesheet.

Raised edge and shadows

<Button raised={false}>Flat</Button>
<Button shadow="lg">Raised with a large shadow</Button>
<Button raised={false} shadow="xl" iconOnly rounded="full" icon="bi bi-plus-lg" aria-label="Create" />

Shadow weights are theme tokens (--ui-shadow-sm … --ui-shadow-xl, and --ui-shadow-color for the tint), with separate values for dark mode.

Loading (Ladda-style)

Return a Promise from onClick and the spinner shows until the response arrives, whether the request succeeds or fails:

<Button onClick={() => fetch("/api/report", { method: "POST" })}>Generate report</Button>
<Button spinnerPlacement="start" loadingLabel="Saving…" onClick={() => axios.put("/profile", data)}>
  Save profile
</Button>

Or control it yourself with loading:

<Button type="submit" loading={form.processing}>Save</Button>          // Inertia useForm
<Button loading={isPending} onClick={() => startTransition(save)}>Save</Button> // Next.js

While loading, the button stays focusable, is marked aria-busy, and ignores further clicks (so forms can't be submitted twice).

Running light (experimental)

<Button loadingIndicator="orbit" loadingLabel="Saving…" onClick={save}>Save profile</Button>

A light runs around the button's edge while loading; the label stays visible. Tune it with --ui-orbit-color, --ui-orbit-width and --ui-orbit-speed. It needs CSS @property (current Chrome, Edge, Safari and Firefox); older browsers show a still light.

Progress (experimental)

<Button loadingIndicator="progress" onClick={generate}>Generate report</Button>
<Button loadingIndicator="progress" progressStyle="bar" loading={uploading} progress={pct}>
  Upload
</Button>

fill sweeps a translucent fill across the button; bar runs a thin bar along the bottom. Without progress it creeps toward ~92% and completes when loading ends. Tune with --ui-progress-fill, --ui-progress-bar, --ui-progress-height, --ui-progress-duration.

Icon fonts (Bootstrap Icons, Font Awesome, Glyphicons)

Pass the icon's class string and the Button renders <i class="…" aria-hidden="true">, sized to match the label:

<Button leadingIcon="bi bi-cloud-arrow-up">Upload files</Button>
<Button variant="secondary" trailingIcon="bi bi-chevron-down">Sort by date</Button>
<Button iconOnly icon="bi bi-gear" aria-label="Settings" />
<Button leadingIcon="glyphicon glyphicon-print">Print</Button>

Load the icon font's CSS once in each app, e.g. npm install bootstrap-icons, then import "bootstrap-icons/font/bootstrap-icons.css" in Next.js app/layout.tsx or Laravel resources/js/app.tsx.

type defaults to "button"; set type="submit" on form submit buttons.

Carousel

<Carousel aria-label="Photos" indicators="thumbnails">
  <CarouselSlide label="Sagada" thumbnail={<img … />}><img … /></CarouselSlide>
</Carousel>

<Carousel aria-label="Plans" effect="coverflow" slidesPerView={{ base: 1.3, md: 2.4 }} defaultIndex={1} />
<Carousel aria-label="Highlights" indicators="stories" autoplay={4000} loop />

Native scroll snapping (real swipe momentum, trackpads, no scroll-jacking) plus mouse dragging and keyboard. Carousel: slidesPerView (number or { base, sm, md, lg } by container width; fractions peek), gap, peek, align (start center), effect (coverflow), loop, autoplay (ms; pauses on hover/touch/focus, hidden tab or off-screen; off with reduced motion; pause button), indicators (dots counter stories thumbnails none), controls (overlay below none), defaultIndex, onSlideChange; ref → { next, prev, goTo, index }. CarouselSlide: label, thumbnail.

Checkbox, radio & switch

<Checkbox label="Attach PDF" description="Adds the invoice as an attachment." />
<Checkbox label="All clients" checked={all} indeterminate={some && !all} onCheckedChange={toggleAll} />
<CheckboxGroup name="methods[]" options={[{ value: "card", label: "Card" }, …]} defaultValue={["card"]} />
<RadioGroup label="Send" defaultValue="now"><Radio value="now" label="Now" /><Radio value="draft" label="Save as draft" /></RadioGroup>
<Switch label="Weekly summary" onCheckedChange={(on) => api.save(on)} />   {/* spinner; flips back on failure */}

Real inputs underneath (keyboard, screen readers and form posts work natively). Checkbox: label, description, indeterminate, onCheckedChange, invalid, size (sm md) + input props. CheckboxGroup: label, options, value / defaultValue / onValueChange, name, orientation. RadioGroup: label, value / defaultValue / onValueChange, name, orientation, size, disabled, invalid, required; Radio: value, label, description. Switch: color (primary secondary tertiary success warning danger info or any CSS color; tokens --ui-tone-secondary / --ui-tone-tertiary + -fg), colorForeground, variant (labelled — the word in the pill, mark — × / ✓ on the knob, liquid — blue floods from the knob), label, description, onCheckedChange (may return a Promise: spinner, flips back on failure), size, labelPosition, onLabel, offLabel.

Alert

A message that stays on the page. (For one that appears and leaves on its own, use Toast.)

<Alert tone="success" title="Invoice sent to Northwind Traders" />
<Alert tone="warning" variant="accent" title="Your card expires this month" dismissible onDismiss={hide}
  actions={<Button size="sm">Update card</Button>}>
  Update it before 30 September so invoices keep going out.
</Alert>
<Alert tone="danger" title="We couldn't save this invoice" items={Object.values(errors)} />
<Alert banner size="sm" tone="warning" title="Your trial ends in 3 days" />

tone (info success warning danger neutral), variant (soft outline accent solid), title, children, items (a bulleted summary — the shape Laravel's validation errors arrive in), actions, icon (your own, or false), dismissible + onDismiss, autoDismiss (ms; pauses on hover or focus), size, banner (edge to edge, square corners), live (auto announces danger and warning immediately, everything else politely; off stays quiet). Dismissal fades and collapses the space instead of snapping the page up.

Toast

<Toaster position="bottom-right" />          {/* once, near the root */}

toast("Draft saved");
toast.success("Invoice sent", { description: "…" });
toast.promise(api.send(id), { loading: "Sending…", success: "Sent", error: (e) => e.message });
toast("Invoice deleted", { action: { label: "Undo", onClick: restore } });

toast(title, options) and toast.success / error / warning / info / loading / promise / dismiss. Options: id (update in place), description, action, duration, icon, dismissible, onDismiss. Toaster: position, expand, visibleToasts, richColors, showTimer, hotkey. Stacks and spreads on hover, pauses while hovered/focused/hidden, swipe or Escape to dismiss.

File upload

<FileDropzone upload={xhrUpload("/api/receipts")} accept="image/*,.pdf" maxSize={10 * 1024 * 1024} maxFiles={6} />
<FileUploadButton upload={xhrUpload("/api/contracts")} accept=".pdf">Upload contract</FileUploadButton>
<FileInput name="resume" accept=".pdf" maxSize={5 * 1024 * 1024} />   {/* posts with the form */}

FileDropzone: upload, accept, maxSize, maxFiles, multiple, concurrency, onChange, onUploaded, title, hint, name, disabled, pasteable, size. FileUploadButton: Button props + upload, accept, maxSize, onUploaded, onRemove, completeLabel. FileInput: native file-input props + onFilesChange, buttonLabel, placeholder, size, rounded, invalid, maxSize, clearable. xhrUpload(url, { fieldName, method, headers, data, withCredentials, xsrfCookie }) gives real progress and reads Laravel's XSRF cookie and validation errors. useFileUploads(…) and FileList for custom layouts.

Field, Input and Textarea

import { Field, Input, Textarea } from "@astherix/ui";

<Field label="Email" description="We'll send receipts here." error={form.errors.email} required>
  <Input type="email" leadingIcon="bi bi-envelope" />
</Field>

<Field label="Message">
  <Textarea autoResize minRows={3} maxRows={8} showCount maxLength={500} />
</Field>

Field — label, description, error (marks the control invalid), required, optional, disabled, id. Wires the label, aria-describedby and aria-invalid for you. Custom controls can read it with useField().

Input — size (sm md lg, same heights as Button), rounded (none … full), leadingIcon / trailingIcon (element or icon-font class), prefix / suffix text, invalid, clearable + onClear, revealable (password show/hide, default on), frameClassName. All native input props work; ref goes to the <input>.

Textarea — size, rounded (none … lg), invalid, autoResize with minRows / maxRows, showCount (with maxLength shows "12 / 280"), resize (vertical none), frameClassName.

One-time code (OtpInput)

<Field label="Authentication code" error={errors.code}>
  <OtpInput value={code} onValueChange={setCode} onComplete={verify} />
</Field>

<OtpInput name="code" autoFocus />                       {/* posts with the form; a form reset clears it */}
<OtpInput length={8} allow="alphanumeric" groupSize={4} />
<OtpInput length={4} mask size="lg" />                  {/* a PIN */}

One real input behind the boxes, so SMS autofill (autocomplete="one-time-code"), pasting, password managers and form posts all work, and screen readers meet a single field. Props: length (6), value / defaultValue / onValueChange, onComplete, allow (digits alphanumeric or a RegExp per character), groupSize, mask, size (sm md lg), invalid, frameClassName + input props (name, autoFocus, disabled…). Reads a surrounding Field.

Pagination

<Pagination page={page} pageCount={12} onPageChange={setPage} />
<Pagination page={p.current_page} pageCount={p.last_page} total={p.total} pageSize={p.per_page}
  getHref={(n) => `${p.path}?page=${n}`} linkComponent={Link} showSummary />
<LoadMore loaded={items.length} total={total} onLoadMore={fetchNext} auto />

Pagination: page, pageCount or total + pageSize, onPageChange, getHref + linkComponent (real links), siblingCount, boundaryCount, showFirstLast, variant (numbers simple), responsive (fits its own width), showSummary, pageSizeOptions + onPageSizeChange, showJump, size, labels. Gaps jump five pages; the highlight slides. LoadMore: loaded, total, onLoadMore (Promise → progress), hasMore, auto (infinite scroll), label. getPageItems(page, count, siblings, boundaries) is exported too.

Pills

<Pill tone="success" dot>Paid</Pill>
<Pill tone="danger" appearance="solid">Overdue</Pill>
<Pill size="lg" onRemove={() => removeTag(tag)}>{tag}</Pill>
<Pill asChild tone="primary"><Link href="/topics/laravel">Laravel</Link></Pill>

<PillGroup value={category} onValueChange={setCategory} aria-label="Category">
  <PillOption value="all" count={5}>All</PillOption>
  <PillOption value="design">Design</PillOption>
</PillGroup>
<PillGroup type="multiple" value={tags} onValueChange={setTags}>…</PillGroup>

Pill: tone (neutral primary success warning danger info), appearance (soft solid outline), size (sm md lg), icon, dot (true or "pulse"), count, onRemove, removeLabel, asChild. PillGroup: type (single multiple), value / defaultValue / onValueChange, allowDeselect, size, tone, showCheck, disabled. PillOption: value, icon, count, disabled. Status colors are theme tokens: --ui-success, --ui-warning, --ui-info (+ -fg).

Autocomplete

A text field that suggests as you type — the value is free text (use Select when it must be from a list).

<Autocomplete value={city} onChange={setCity} suggestions={cities} />
<Autocomplete loadSuggestions={(q, { signal }) => api.searchClients(q, signal)} minLength={2} />
<Autocomplete type="search" recentKey="recent-searches" onSubmit={runSearch} suggestions={items} />

Props (plus Input's: size, rounded, icons, clearable, …): value / defaultValue / onChange (string), suggestions (array of strings or { value, label, description, icon, group }, or a function of the query), loadSuggestions, minLength, debounce, maxSuggestions, inlineComplete (faint completion; Tab/→ accepts), onSelectSuggestion, onSubmit, recentKey, maxRecent, emptyMessage, filter.

Slider

<Slider value={fee} onChange={setFee} max={25} formatValue={(v) => `${v}%`} />
<Slider value={[5000, 60000]} onChange={setRange} max={100000} step={1000} minDistance={5000} />
<Slider min={1} max={10} marks color="secondary" onValueCommit={save} />

Press the thumb and it becomes a frosted glass lens with a glass value bubble above the finger; drag past an end and it stretches, then springs back; crossing marks gives a haptic tick on supporting phones. Props: value / defaultValue (number, or [low, high] for a range), onChange, onValueCommit (on release), min, max, step, marks (true, values, or { value, label }), formatValue, showValue (active always never), color (Switch palette or any CSS color), size (sm md lg), disabled, minDistance, haptics, name, thumbLabels.

Signature

const pad = useRef<SignaturePadHandle>(null);

<SignaturePad ref={pad} name="signature" allowTyped onChange={setSignature} />

pad.current.toDataURL({ trim: true, ink: "#111827", background: "#ffffff" })   // PNG
pad.current.toSVG({ trim: true, ink: "#111827" })                              // vector, for PDFs
pad.current.clear();  pad.current.undo();  pad.current.isEmpty();  pad.current.fromDataURL(png);

Ink thickens and thins with speed (and stylus pressure); strokes are kept as points, so it redraws crisply on resize and undo works stroke by stroke. Props: onChange (PNG data URL or null), onBegin, onEnd, penColor, penWidth [min, max], height, background, guide, guideLabel, toolbar, actions, readOnly, defaultValue, name (posts with the form), allowTyped (a Type tab in a handwriting font — the accessible alternative), typedFont. Export ink matters: the pen follows the theme, so force a dark color for people signing in dark mode.

Select

A searchable dropdown in the spirit of Select2.

<Field label="Country">
  <Select options={countries} value={country} onChange={setCountry} />
</Field>

<Select multiple options={skills} value={skills} onChange={setSkills} maxSelected={5} clearable />
<Select loadOptions={(q) => api.searchRepos(q)} minSearchLength={2} />
<Select multiple creatable options={labels} onCreateOption={(text) => api.createLabel(text)} />

Options: { value, label, description?, icon?, group?, disabled?, keywords? }.

Props: options, value / defaultValue / onChange (string | null, or string[] with multiple), multiple, maxSelected, searchable (default true), searchPlaceholder, clearable, placeholder, loadOptions + minSearchLength + searchDelay, creatable + onCreateOption, renderOption, noOptionsMessage, closeOnSelect, onOpenChange, name (hidden inputs for form posts), size, rounded, invalid, disabled, required.

Keyboard: Enter/Space/↓ open, type to search, ↑ ↓ Home End move, Enter picks, Backspace removes the last chip, Escape closes (before a surrounding Modal), Tab closes and moves on.

Card

<Card variant="outline" | "elevated" | "filled" | "ghost" padding="md">
  <CardMedia src={cover} ratio="16/9" />
  <CardHeader title="Website refresh" description="Due 30 September" action={<Button …/>} />
  <CardContent>…</CardContent>
  <CardFooter divided justify="between">…</CardFooter>
</Card>

// Whole card clickable; other buttons inside still work
<Card interactive><CardHeader><CardTitle><CardLink href="/posts/1">Title</CardLink></CardTitle></CardHeader></Card>

// Choices with real radios / checkboxes
<ChoiceCardGroup value={plan} onValueChange={setPlan} name="plan">
  <ChoiceCard value="team" title="Team" description="…" meta="₱990 / month" />
</ChoiceCardGroup>

Card: variant, tone (default primary danger), orientation (vertical horizontal, wrap text in CardBody), interactive, padding (none sm md lg), asChild. CardHeader: title, description, action, icon, titleLevel. CardFooter: divided, justify. CardMedia: src, alt, ratio, inset. ChoiceCardGroup: type, value / defaultValue / onValueChange, name, columns, disabled, invalid, required. ChoiceCard: value, title, description, icon, meta, badge, disabled. Token: --radius-card.

Dashboard widgets

Dependency-free SVG widgets for KPI dashboards — one look, one palette (--ui-chart-1…6), responsive, keyboard- and screen-reader-friendly.

<DashboardGrid columns={4}>
  <KpiCard label="Revenue" value="₱318,400" change={8.2} sparkline={monthly} />
  <WidgetCard data-span="3" title="Revenue" action={<PeriodPicker />} loading={isLoading}>
    <AreaChart data={rows} index="month" series={[{ key: "invoiced" }, { key: "collected" }]}
      referenceLine={{ value: 280000, label: "Target" }} aria-label="Revenue by month" />
  </WidgetCard>
</DashboardGrid>
  • KpiCard: label, value, change, changeLabel, invertTrend, icon, sparkline, sparklineType, progress, href, loading
  • LineChart / AreaChart / BarChart: data, index, series (key, label, color), height, formatValue, formatIndex, showLegend, showGrid, referenceLine; line: curve, dots; bar: stacked. Crosshair tooltip, legend toggles, ← → to read points
  • DonutChart (data, size, thickness, centerLabel, legend), Gauge (value, max, target, bands), BarList (items, limit), CalendarHeatmap (data, weeks), Sparkline (data, type)
  • WidgetCard: title, description, action, value, loading, empty, error, onRetry, footer, live; DashboardGrid: columns, animate, children use data-span="2|3|4|full"; LiveIndicator: updatedAt, paused, reconnecting

Animate on first load (optional): <DashboardGrid animate> — widgets rise in one after another, KPI numbers count up (pass value as a number with formatValue), charts draw in, donuts and gauges sweep. Per-widget animate overrides it. Skipped with reduced motion.

Live data: just pass new data. Numbers roll and glow (green good / red bad), lines and bars morph, donut and gauge sweep, BarList rows slide into their new order (transition={false} on a chart turns it off). Works with polling, Laravel Echo (Reverb/Pusher) or Server-Sent Events.

Dropdown menu

<DropdownMenu>
  <DropdownMenuTrigger asChild><Button trailingIcon="bi bi-chevron-down">Actions</Button></DropdownMenuTrigger>
  <DropdownMenuContent align="start">
    <DropdownMenuItem icon="bi bi-pencil" shortcut="⌘E" onSelect={edit}>Edit</DropdownMenuItem>
    <DropdownMenuItem asChild><Link href="/settings">Settings</Link></DropdownMenuItem>
    <DropdownMenuSeparator />
    <DropdownMenuItem destructive onSelect={remove}>Delete</DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>

<SplitButton onClick={save} menu={<DropdownMenuItem onSelect={saveDraft}>Save as draft</DropdownMenuItem>}>Save</SplitButton>

DropdownMenu: open / defaultOpen / onOpenChange. DropdownMenuContent: align (start end), side (bottom top, flips automatically), matchTriggerWidth. DropdownMenuItem: onSelect (call event.preventDefault() to keep the menu open), icon, shortcut, description, destructive, disabled, inset, textValue, asChild. Also DropdownMenuCheckboxItem (checked, onCheckedChange), DropdownMenuRadioGroup + DropdownMenuRadioItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup. SplitButton: Button props + menu, menuLabel, align.

Editor (rich text)

A basic WYSIWYG editor built on Tiptap. It has its own entry point so apps that don't use it don't load it:

import { Editor } from "@astherix/ui/editor";

<Field label="Release notes">
  <Editor value={html} onChange={setHtml} />
</Field>

<Editor toolbar={["bold", "italic", "link", "|", "bulletList"]} maxLength={280} />
<Editor name="body" />            {/* posts the HTML with the form */}
<Editor readOnly value={post.body} />

Props: value / defaultValue / onChange (HTML; empty editor gives ""), placeholder, toolbar (tools: paragraph heading2 heading3 bold italic underline strike code link bulletList orderedList blockquote horizontalRule undo redo, "|" for a divider), maxLength, showCount, minHeight, maxHeight, rounded, invalid, disabled, readOnly, name, autoFocus, onReady(editor) for the Tiptap instance.

Show saved HTML with the same styles: <div className="ui-prose" dangerouslySetInnerHTML={…} />. Always sanitize HTML on the server (e.g. stevebauman/purify in Laravel).

IconButton

The icon itself is the button. Press: the icon squishes and a ripple spreads; toggles swap to a filled icon, pop and burst.

<IconButton icon="bi bi-heart" pressedIcon="bi bi-heart-fill" tone="danger" label="Like"
  pressed={liked} onPressedChange={setLiked} />
<IconButton icon="bi bi-bell" label="Notifications" badge={3} />
<IconButton icon="bi bi-download" label="Download INV-1047" onClick={download} />   {/* Promise → spinner */}

Props: icon, label (required; spoken and tooltip), variant (plain soft solid), tone (Switch palette or any CSS color), size (xs–xl; small ones get a 44px tap area on touch), shape (circle square), pressed / defaultPressed / onPressedChange, pressedIcon, burst, badge (number or dot), loading, href, tooltip, disabled.

Tooltip

<Tooltip content="Archive">
  <Button variant="ghost" iconOnly icon="bi bi-archive" aria-label="Archive" />
</Tooltip>
<Tooltip side="bottom" content="Save draft ⌘S">…</Tooltip>
<Tooltip content={client.name} disabled={!isTruncated}>…</Tooltip>

Shows on hover (after delay, 400ms) and keyboard focus, never on touch; hides on Escape, blur and pointer leave (the tooltip itself is hoverable). Moving between tooltips shows the next at once. Shown with the Popover API, so it sits above modals and drawers and escapes overflow: hidden — no provider or z-index needed. Props: content, side (top right bottom left; flips), align (start center end), delay, disabled, as (description — read after the element's name, default; label — the tooltip is the name), className. The child must take a ref and focus/pointer handlers; disabled buttons get no events, so wrap them in a focusable span.

Modal

import { Modal, ModalTrigger, ModalContent, ModalHeader, ModalTitle,
  ModalDescription, ModalBody, ModalFooter, ModalClose, useModal } from "@astherix/ui";

// Open with a button
<Modal>
  <ModalTrigger asChild><Button>Rename project</Button></ModalTrigger>
  <ModalContent size="sm">
    <ModalHeader>
      <ModalTitle>Rename project</ModalTitle>
      <ModalDescription>The new name shows up for everyone.</ModalDescription>
    </ModalHeader>
    <ModalBody>…</ModalBody>
    <ModalFooter>
      <ModalClose asChild><Button variant="ghost">Cancel</Button></ModalClose>
      <Button onClick={save}>Save name</Button>
    </ModalFooter>
  </ModalContent>
</Modal>

// Open from code
const session = useModal();          // { isOpen, open, close, toggle, modalProps }
<Modal {...session.modalProps}>…</Modal>
session.open();

| Component / prop | Values | Default | | -------------------------------- | ---------------------------------------- | ------- | | Modal open / onOpenChange | controlled state | | | Modal defaultOpen | boolean | false | | ModalContent size | sm md lg xl full | md | | ModalContent backdrop | default dark blur solid | default | | ModalContent dismissible | Escape, backdrop and × close it | true | | ModalContent showCloseButton | boolean | true | | ModalContent closeLabel | label for × | "Close" |

Built on the native <dialog>: focus moves in on open (honours autoFocus) and returns to the opener on close, Tab stays inside, the page behind is inert and doesn't scroll. Theme tokens: --radius-modal, --ui-shadow-modal, --ui-backdrop, --ui-backdrop-dark, --ui-backdrop-blur, --ui-backdrop-blur-radius, --ui-backdrop-solid.

Drawer

const menu = useDrawer();                 // { isOpen, open, close, toggle, drawerProps }

<Drawer {...menu.drawerProps}>
  <DrawerTrigger asChild><Button iconOnly icon="bi bi-list" aria-label="Open menu" /></DrawerTrigger>
  <DrawerContent side="left" size="sm">
    <DrawerHeader>
      <DrawerTitle>Northwind Billing</DrawerTitle>
      <DrawerDescription>…</DrawerDescription>
    </DrawerHeader>
    <DrawerBody>…</DrawerBody>
    <DrawerFooter><DrawerClose asChild><Button>Done</Button></DrawerClose></DrawerFooter>
  </DrawerContent>
</Drawer>

A Modal that slides in from an edge — the same native <dialog>, focus handling, scroll lock, Escape and backdrop clicks. DrawerContent: side (left right top bottom; default right), size (sm md lg — width for left/right, height for top/bottom), dismissible, backdrop, showCloseButton, closeLabel. DrawerFooter sits at the bottom. Reduced motion fades instead of sliding.

Table (DataTable)

const columns: DataTableColumn<Invoice>[] = [
  { key: "no", header: "Invoice", sortable: true, primary: true },
  { key: "client", header: "Client", sortable: true, cell: (r) => r.client },
  { key: "issued", header: "Issued", hideOnMobile: true },
  { key: "amount", header: "Amount", sortable: true, align: "end", cell: (r) => peso(r.amount) },
];

<DataTable caption="Invoices" data={rows} columns={columns} rowKey="id"
  searchable selectable bulkActions={(rows, clear) => …} rowActions={(row) => <RowMenu row={row} />} />

// Server-side (e.g. a Laravel paginator)
<DataTable manual data={page.data} total={page.total} loading={loading} onQueryChange={fetchPage} … />

Columns: key, header, accessor, cell, sortable, sortFn, align, width, primary (card title on mobile), hideOnMobile, searchable, className. Table: caption (required, accessible name), showCaption, searchable, searchPlaceholder, pageSize (0 = all), pageSizeOptions, defaultSort, selectable, selected / onSelectedChange, bulkActions, rowActions, onRowClick, toolbar, loading, refreshIndicator (border bar shimmer none), onRefresh (adds a refresh button; runs until its Promise settles), status (true for automatic messages, your own text, or (state) => message), statusTone, error (shown in the status line with Retry), lastUpdated, loadingOverlay (true or a message), overlayPosition (top center), overlayIndicator, onCancelLoading, emptyState, density (comfortable compact), striped, mobile (cards scroll), maxHeight, manual, total, onQueryChange. Narrow containers (under 40rem) switch to cards via a container query, so it adapts inside sidebars and cards as well as on phones.

Sidebar

<SidebarProvider side="left" linkComponent={Link} persistKey="sidebar">
  <Sidebar>
    <SidebarHeader><Logo /></SidebarHeader>
    <SidebarContent>
      <SidebarGroup label="Workspace">
        <SidebarNav items={nav} activeHref={pathname} />
      </SidebarGroup>
    </SidebarContent>
    <SidebarFooter><AccountMenu /></SidebarFooter>
  </Sidebar>
  <SidebarInset>
    <header><SidebarTrigger /></header>
    …
  </SidebarInset>
</SidebarProvider>

Nav items: { label, href?, icon?, badge?, children?, defaultOpen?, disabled?, onSelect?, external?, id? } — children nest to any depth. Wide: full sidebar ↔ icon rail (submenus open as a flyout); narrow: off-canvas drawer. Chosen by the layout's own width. SidebarProvider: side, defaultCollapsed, collapsed / onCollapsedChange, drawerBelow (768), railBelow (1024), persistKey, shortcut ("b" → Ctrl/⌘+B), linkComponent, onNavigate, contained. Sidebar: width, railWidth. SidebarNav: items, activeHref, matchNested. SidebarGroup: label, action. useSidebar() → { mode, collapsed, drawerOpen, toggle, setCollapsed, setDrawerOpen, side }.

Navigation menu

<NavigationMenu linkComponent={Link} activeHref={url} items={[
  { label: "Dashboard", href: "/dashboard", icon: <LayoutGrid /> },
  { label: "Reports", children: [
    { label: "Sales", href: "/reports/sales", description: "Revenue by month and client" },
    { label: "Aging", href: "/reports/aging", description: "Who owes what, and for how long" },
  ] },
  { label: "Docs", href: "https://example.com/docs", external: true },
]} />

// Phones: the same items in a Drawer
<NavigationMenu orientation="vertical" items={items} activeHref={url} linkComponent={Link} onNavigate={menu.close} />

Items: { label, href?, icon?, description?, badge?, children?, external?, id? } — children open a panel of links (one level). Props: items, activeHref, matchNested (default true; the most specific match wins), orientation (horizontal vertical), indicator (pill underline — give the menu className="h-full" in the header so the line sits on its edge), linkComponent, onNavigate, aria-label (default "Main"). Disclosure pattern: links stay in the Tab order; Enter/Space/↓ open a panel, ← → Home End move between top-level items, Escape closes; hover opens panels for mouse users.

Breadcrumbs

<Breadcrumbs linkComponent={Link} items={[
  { label: "Clients", href: "/clients" },
  { label: "Northwind Traders", href: "/clients/7" },
  { label: "Invoices" },                      // the current page: no href
]} />

The last item is marked aria-current="page". Past maxItems (default 4; 0 never folds) the middle folds into "…", which opens in place. Long labels truncate with the full text as a tooltip. Props: items (label, href, icon), maxItems, separator (chevron slash or an element), size (sm md), linkComponent, aria-label (default "Breadcrumb").

Tabs

<Tabs defaultValue="overview" orientation="horizontal" variant="line">
  <TabList aria-label="Project">
    <Tab value="overview">Overview</Tab>
    <Tab value="billing" disabled>Billing</Tab>
  </TabList>
  <TabPanel value="overview">…</TabPanel>
</Tabs>

// Editable: drag or Alt+arrows to move, double-click/F2 to rename, + to add, ×/Delete to close
<TabList onReorder={(ids) => …} onRename={(id, name) => …} onAdd={() => newId} onClose={(id) => …} renameOnAdd>

Tabs: value / defaultValue / onValueChange, orientation (horizontal vertical), variant (line enclosed pills), size (sm md), activation (automatic manual). TabList: onReorder, onRename, onAdd (return the new value to select it), addLabel, renameOnAdd, onClose, closeLabel. Tab: value, label, disabled, icon, badge, renamable, closable. TabPanel: value, keepMounted.

Timeline

<Timeline aria-label="Invoice activity" variant="feed" | "progress" layout="left" | "alternate">
  <TimelineGroup date={new Date()}>                               {/* "Today" */}
    <TimelineItem icon="bi bi-check-lg" tone="success" title="Payment received" time={at} />
    <TimelineItem avatar={<Avatar … />} title="Maria commented" time={at} card>…</TimelineItem>
    <TimelineCollapse>{quietItems}</TimelineCollapse>
  </TimelineGroup>
</Timeline>

<Roadmap aria-label="Roadmap" items={[{ label: "Q3 2026", title: "Mobile app", status: "current" }]} />

Timeline: layout, variant, size (sm md), animated (the thread draws itself on scroll where supported). TimelineGroup: label or date. TimelineItem: title, time (shown as "2 hours ago") or timeLabel, icon, avatar, tone, status (past current upcoming), meta, card, last. TimelineCollapse: label, defaultOpen. Roadmap: items (label, title, description, status done/current/upcoming, footer), itemWidth. Helpers: formatTimelineTime, formatTimelineDay.

Typography

<Heading level={1} size="display">Get paid faster</Heading>
<Lead>Create, send and track invoices from one place.</Lead>
<Text size="sm" tone="muted" numeric>₱48,200.00</Text>
<Link href="https://laravel.com/docs">Laravel docs</Link>   {/* external: ↗, new tab */}
<Code>APP_ENV</Code> <Kbd keys={["⌘", "K"]} /> <Mark>match</Mark>
<Blockquote author="Maria Santos" source="Owner">…</Blockquote>
<List variant="check"><ListItem>Payment links</ListItem></List>
<Prose html={sanitizedHtml} />
<StatGroup divided><Stat label="Revenue" value="₱1.24M" change="12.5%" trend="up" /></StatGroup>

Heading: level (tag), size (display h1–h6), tone, weight, align, truncate, lineClamp. Text: as, size (xl lg md sm xs), tone (default muted subtle primary success warning danger info inherit), weight, align, numeric, truncate, lineClamp. Link: variant (inline subtle standalone), external, asChild. Prose: size, html. Stat: label, value, change, trend, invertTrend, description, size. Set --font-heading for a separate heading font.

Layout (Stack, Grid, Container…)

Small pieces for arranging things. Every size prop takes a plain value or an object keyed by breakpoint, so a layout changes with the screen without you writing media queries.

<Stack direction={{ base: "column", md: "row" }} gap={3} justify="between" align={{ md: "center" }}>…</Stack>
<Stack direction="row" gap={2} wrap divider>…</Stack>

<Grid columns={{ base: 1, sm: 2, lg: 4 }} gap={4}>…</Grid>
<Grid minChildWidth="16rem" gap={4}>…</Grid>            {/* reacts to its own width */}
<GridItem span={{ base: 1, md: 3 }} rowSpan={2}>…</GridItem>

<Container size="xl" padding={{ base: 4, md: 8 }}>…</Container>
<Center minHeight="60vh">…</Center>
<Stack direction="row"><Logo /><Spacer /><Account /></Stack>
<Divider dashed spacing={4} />   <Divider>or</Divider>
<AspectRatio ratio="16/9"><img src={cover} alt="" /></AspectRatio>

Stack: direction, gap, align, justify, wrap, divider, as. Grid: columns or minChildWidth, gap, align, as. GridItem: span (never exceeds the column count, so nothing overflows on a phone), rowSpan. Container: size (sm md lg xl 2xl prose full, or any length), padding, align. gap and padding count in spacing steps, so they follow the theme's density. Breakpoints follow the window, like Tailwind's own — for a layout that reacts to the space it's actually in, use minChildWidth or wrap.

Placeholder

<Placeholder ratio="16/9" />
<Placeholder className="h-80" label="Revenue chart" />
<Placeholder pattern="dots" border="solid" className="text-primary" />

A patterned box that holds a layout slot before its content exists (wireframes, empty dashboard widgets). For content that is loading, use Skeleton. Props: pattern (stripes dots grid none), label, ratio, border (dashed solid none). Drawn with CSS gradients in the text color, so it follows the theme; decorative (hidden from screen readers) unless it has a label.

Hero

The top of a landing page, in separate pieces so it can be quiet or loud.

<Hero background="aurora" size="lg">
  <HeroEyebrow href="/changelog" icon="bi bi-stars">New · Recurring invoices</HeroEyebrow>
  <HeroTitle gradient>Get paid without chasing anyone</HeroTitle>
  <HeroSubtitle>Send an invoice in thirty seconds and let the reminders go out on their own.</HeroSubtitle>
  <HeroActions>
    <Button size="lg">Start free</Button>
    <Button size="lg" variant="secondary">See a demo</Button>
  </HeroActions>
  <HeroStats stats={[{ value: "12,400", label: "invoices sent" }, { value: "₱48M", label: "collected" }]} />
  <HeroLogos logos={["Northwind", "Blue Harbor", "Luzon Freight"]} marquee />
</Hero>

<Hero background="image" image="/img/warehouse.jpg" overlay={0.6} size="screen" align="start"
  media={<HeroMedia frame url="app.example.com/invoices" tilt float><img src={shot} alt="" /></HeroMedia>}>
  …
</Hero>

Hero: background (none aurora grid dots gradient image), image, overlay, dark, align, size (sm md lg screen — screen uses 100svh), media (two columns from medium screens), colors, as. HeroTitle: as (h1 default), gradient, size, colors. HeroEyebrow: href (makes it a link), icon. HeroStats: stats. HeroLogos: logos, label, marquee, speed (pauses on hover). HeroMedia: frame (browser chrome), url, tilt, float. Aurora, float and the marquee all stop under prefers-reduced-motion.

Theme config (ui.theme.json)

Describe your brand once; every component follows.

{
  "$schema": "./node_modules/@astherix/ui/ui.theme.schema.json",
  "colors": { "primary": "#047857", "secondary": "#0e7490", "tertiary": "#b45309" },
  "fonts": { "sans": "\"DM Sans\", ui-sans-serif, system-ui, sans-serif", "googleFonts": ["DM Sans:[email protected]"] },
  "radius": "sm",
  "density": "compact",
  "shadows": "subtle"
}

Settings: colors (primary secondary tertiary success warning danger info as a hex or { light, dark, foreground, darkForeground }; background surface foreground muted border), fonts (sans heading mono googleFonts), radius (none sm md lg or exact values), density (compact comfortable spacious or a multiplier), shadows (none subtle default strong), baseFontSize. Hover, pressed-edge, readable text and dark-mode shades are derived from each color.

Build time (recommended):

npx jm-ui init                                   # starter ui.theme.json
npx jm-ui theme ui.theme.json --out resources/css/ui-theme.css [--watch]
@import "tailwindcss";
@import "@astherix/ui/theme.css";
@import "./ui-theme.css";

Runtime: <ThemeProvider theme={json} selector?> (e.g. a theme per customer). From code: import { createThemeCss, validateTheme, defineTheme } from "@astherix/ui/theme".

Theming

Components only use semantic variables (--ui-primary, --ui-border, …). Override them in your app to re-brand without rebuilding:

:root { --ui-primary: #6d28d9; --ui-primary-hover: #5b21b6; --ui-primary-edge: #3b0764; }

Animated components

Skeleton

<SkeletonSwap loading={isLoading} skeleton={<SkeletonCard media footer />} label="Loading the invoice">
  <Invoice … />
</SkeletonSwap>

<SkeletonList rows={5} />   <SkeletonTable rows={5} columns={4} />   <SkeletonChart variant="bars" />
<SkeletonText lines={3} />  <SkeletonAvatar size="lg" />  <SkeletonButton />  <SkeletonImage ratio="4/3" />
<Skeleton width={140} height={28} radius="999px" animation="pulse" delay={120} />

Skeleton: shape (rect circle text pill line), lines, lastLineWidth, width, height, radius, animation (shimmer pulse none), delay (stagger). Presets ripple down stacks of rows. SkeletonSwap: loading, skeleton, label, delay (180ms before showing — quick loads never flash one), minDuration (500ms minimum once shown), fade; useDelayedLoading(loading, { delay, minDuration }) for your own markup. Decorative shapes are hidden from screen readers; reduced motion turns the shimmer into a slow fade.

SpotlightText

Text lit by a moving spotlight — the lit part glows, the rest sits in shadow.

<SpotlightText as="h1" className="text-5xl font-semibold">Get paid faster.</SpotlightText>
<SpotlightText mode="sweep" tint="linear-gradient(90deg, #fde68a, #f59e0b)">★ Most popular</SpotlightText>
<SpotlightText mode="reveal" radius={90}>Psst — use code EARLYBIRD.</SpotlightText>

mode (follow — follows the pointer or finger, drifts when idle; sweep — a beam passes every few seconds; reveal — dark until lit), speed (slow normal fast or ms per pass; fast suits status messages like "Syncing…"), as, tint (color or gradient; default the text's own color), radius, dim, glow, smoothing, duration / pause (exact sweep pace). Real, selectable text for screen readers; pauses off-screen; fully lit and still with reduced motion.