@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, PageHeaderDevelop
npm install
npm run build # builds packages/ui/dist
npm run typecheck
npm run docs # rebuilds docs/index.htmlAdding a component page to the guide
- Create
docs/src/pages/InputPage.tsxusingSectionblocks (demo + code example). - Add it to the
pageslist indocs/src/main.tsx. - 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" /> // circleChange 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.jsWhile 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,loadingLineChart/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 pointsDonutChart(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 usedata-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.
