Building blocks for app screens
These components and props were added together so an application can drop an in-house kit and import everything from @jcsoftdev/ui. All of them are additive, zero-dependency, take their default texts from UiStringsProvider (en/es), lay out with container queries (never viewport queries) and follow the touch density preset. Each one also has a generated page under Components with the full props table.
Every example below is type-checked against the built package (pnpm check:docs-examples).
| Component | Props worth knowing |
|---|---|
Spinner |
size (xs, sm, md, lg or a pixel number), label (defaults to the localized “Loading”; label={false} makes it decorative). Reduced motion leaves a static ring. |
Kbd |
A styled <kbd>; compose several for a chord. |
IconButton |
Required label (becomes aria-label), variant, size (sm, md, lg), loading (busy like Button: keeps focus, ignores clicks), asChild. A Button with a square box that grows with touch density. |
Chip |
New size="sm" (compact metrics). A tag is <Chip size="sm" square>. |
DueChip |
date, today (reference day, pass it for deterministic SSR), soonDays (3), done, state (forces overdue, soon, ok or done whatever the date says), locale. Overdue and soon states add a status glyph and a screen-reader suffix, so color is never the only signal. |
Seal |
title, icon, tone (accent, success, warning, danger, neutral, institutional, or any tonePalettes tone by name), label (names the seal as a group). Children are the lines. |
export function Atoms() { return ( <> <Spinner size="sm" label="Saving" /> <p> Press <Kbd>Ctrl</Kbd> + <Kbd>K</Kbd> </p> <IconButton label="Close" variant="ghost" size="sm"> <span aria-hidden>x</span> </IconButton> <Chip size="sm" square> Draft </Chip> <DueChip date="2026-10-20" today="2026-10-17" /> <Seal title="Certified" tone="success" label="Certificate seal"> <p>No. 0042</p> </Seal> </> );}Date strings (YYYY-MM-DD, naive YYYY-MM-DDTHH:mm) are read as the written day and never shift with the viewer’s time zone. An instant with Z or an offset lands on the viewer’s local day.
Overlays
Section titled “Overlays”| Component | Props worth knowing |
|---|---|
Tooltip |
content, shortcut (rendered as Kbd), side, align, openDelay (300 ms), closeDelay (100 ms), open/defaultOpen/onOpenChange, disabled. |
AlertDialog |
trigger, title, description, tone (default, danger), confirmLabel, cancelLabel, onConfirm, onCancel, initialFocus (cancel by default), confirmDisabled, cancelDisabled, open/onOpenChange. |
AlertDialog parts |
AlertDialog.Root, .Trigger, .Content, .Header, .Title, .Description, .Body, .Footer, .Cancel, .Action. Same alertdialog: no close button, outside presses ignored, focus on Cancel, Escape closes. Cancel and Action close after their onClick unless it calls preventDefault(). Per-action disabled is the button’s own prop. |
toast |
New tones toast.info, toast.warning, toast.danger (same as toast.error). |
export function Overlays({ onDelete }: { onDelete: () => Promise<void> }) { return ( <> <Tooltip content="Search" shortcut="Ctrl K"> <Button>Search</Button> </Tooltip> <AlertDialog trigger={<Button variant="danger">Delete</Button>} title="Delete this order?" description="This cannot be undone." tone="danger" onConfirm={async () => { await onDelete(); }} /> <Button onClick={() => toast.warning("Check the amounts")}>Warn</Button> </> );}Accessibility: Tooltip has role="tooltip", never takes focus, links to the trigger with aria-describedby only while open, opens on keyboard focus at once and closes with Escape; touch never opens it by hover. AlertDialog has role="alertdialog", no close button, ignores outside presses and lands focus on Cancel. onConfirm may return nothing, { ok: true }, { ok: false, error } or throw: while pending the confirm button is busy, Cancel and Escape are blocked and a second press is ignored; a failure keeps the dialog open with a role="alert" message.
Toasts: success, info, warning and error toasts are tinted with their tone and carry a status glyph. Only error is an alert; the others are status messages. In tests, reset the global toast store between cases with the testing entry, which is kept off the main overlays entry so production bundles never carry it.
import { resetToasts } from "@jcsoftdev/ui/overlays/testing";
export function afterEachTest() { resetToasts();}Forms and dates
Section titled “Forms and dates”| Component | Props worth knowing |
|---|---|
SelectMenu |
Custom listbox select: options (option or { label, options } group), value/defaultValue/onValueChange, placeholder, name (hidden input), required, invalid, size. Use the native Select when the system picker is wanted. |
Combobox |
options, value/onValueChange(value, option), onSearchChange (debounce is yours), filter (true, false for server-side, or a function), maxVisible (50), loading, emptyText, clearable, valueLabel, name. |
DateCalendar |
Standalone date grid: value, onSelect, min, max, isDateDisabled, locale, weekStartsOn. |
DatePicker |
Typed masked input plus calendar popover. ISO YYYY-MM-DD values, min, max, isDateDisabled, clearable, name, invalid, size. |
DateTimePicker |
Date plus time, value YYYY-MM-DDTHH:mm (no zone, "" while incomplete), step in minutes, size for both halves, classNames down to the inner date parts. |
ChipGroup |
Toggle chips: options (value, label, count, icon, disabled), type (multiple default, or single with allowDeselect), name. |
export function Forms() { const [city, setCity] = useState("lima"); return ( <> <Field label="City"> <SelectMenu value={city} onValueChange={setCity} options={[ { value: "lima", label: "Lima" }, { value: "cusco", label: "Cusco", disabled: true }, ]} /> </Field> <Field label="Student"> <Combobox options={[{ value: "1", label: "Ana Quispe" }]} onSearchChange={(query) => void query} /> </Field> <Field label="Due date"> <DatePicker min="2026-01-01" /> </Field> <Field label="Starts"> <DateTimePicker step={15} /> </Field> <ChipGroup options={[ { value: "open", label: "Open", count: 4 }, { value: "closed", label: "Closed" }, ]} defaultValue={["open"]} /> <DateCalendar value="2026-10-17" onSelect={(date) => void date} /> </> );}Accessibility: SelectMenu and Combobox follow the WAI-ARIA combobox and listbox patterns (focus stays on the trigger or input, aria-activedescendant, arrows, Home/End, typeahead, Escape). Disabled options are skipped and options are 44px tall under touch density. Inside Field the listbox is named from the label. The calendar grid uses a roving tabindex with arrows, Home/End, PageUp/PageDown and Shift+Page for years; out-of-range dates stay focusable with aria-disabled. Weeks start on Monday for es-PE and en-GB, and on Sunday for US-style locales; override with weekStartsOn. ChipGroup is a named group of aria-pressed toggle buttons with roving focus; a check glyph marks selected chips.
Control sizes
Section titled “Control sizes”Every control has the same height at the same size: 30px at sm (--control-height-sm) and 38px
at md (--control-height-md, the default). That holds for Button, IconButton, Input, Select,
SelectMenu, Combobox, DatePicker, DateTimePicker (both halves) and TimeInput, so a toolbar or
a table row lines up without height overrides. Under data-density="touch" both grow to the touch
sizes.
export function Toolbar() { return ( <div className="flex items-center gap-2"> <Input size="sm" aria-label="Search" placeholder="Search" /> <Select size="sm" aria-label="Status"> <option>Open</option> <option>Closed</option> </Select> <DatePicker size="sm" aria-label="From" /> <TimeInput size="sm" aria-label="At" /> <Button size="sm">Apply</Button> </div> );}Form layout
Section titled “Form layout”A typical create or edit form is Form > FormSection > FormGrid > Field, closed by a FormFooter.
| Component | Props worth knowing |
|---|---|
FormSection |
title, description. The title column sits beside the fields from 640px of container width and stacks above them in a narrower one (a drawer, a split pane). |
FormGrid |
columns 1 to 3 (default 2): the most columns it reaches, 2 from 620px and 3 from 900px of container width. FormGridItem span (1, 2, 3 or "full") never adds a column the grid does not have. |
Fieldset |
legend, description; a native fieldset, so disabled reaches every control inside. |
Field |
required adds a visible * and aria-required on the control (native validation stays off); disabled disables the control and mutes the label. |
FormFooter |
Sticky by default (sticky={false} to opt out). onCancel shows Cancel, submitLabel, submitDisabled, dirty shows “Unsaved changes” in a polite status region, busy keeps the submit focusable but inert and disables Cancel. |
export function CreateStudent({ saving, onCancel }: { saving: boolean; onCancel: () => void }) { return ( <Form> <FormSection title="Student" description="Shown on reports and certificates."> <FormGrid columns={2}> <Field label="First name" required> <Input /> </Field> <Field label="Last name" required> <Input /> </Field> <FormGridItem span="full"> <Field label="Notes" optional> <Textarea /> </Field> </FormGridItem> </FormGrid> </FormSection> <FormSection title="Guardian"> <Fieldset legend="Primary contact" description="Receives every notice."> <Field label="Email" required> <Input type="email" /> </Field> </Fieldset> </FormSection> <FormFooter dirty busy={saving} onCancel={onCancel} submitLabel="Create student" /> </Form> );}Navigation
Section titled “Navigation”| Component | Props worth knowing |
|---|---|
Wizard |
steps (id, label, content, canAdvance), value/defaultValue/onValueChange(stepId), onBeforeNext, onComplete, backLabel/nextLabel/finishLabel. Gates may be async; a throw keeps the step and shows an alert. |
Stepper |
steps (id, label, status), currentStepId. status is done (check), current, error (cross) or pending; done and error also speak their state. Without status the step is current or pending from currentStepId. |
TabsNav / TabsNavLink |
A link strip for sub-navigation: required aria-label on the strip, value marks the current item (aria-current="page"), count, renderLink or asChild for client routers. No tab roles or panels; use Tabs for in-page panels. |
export function Enrollment() { return ( <Wizard steps={[ { id: "who", label: "Student", content: <p>Pick a student</p> }, { id: "plan", label: "Plan", content: <p>Choose a plan</p>, canAdvance: async () => true, }, ]} onComplete={async () => {}} /> );}
export function SectionNav() { return ( <TabsNav aria-label="Account" value="profile"> <TabsNavLink value="profile" href="/profile"> Profile </TabsNavLink> <TabsNavLink value="billing" href="/billing" count={2}> Billing </TabsNavLink> </TabsNav> );}The wizard heading takes focus on every step change, Enter in a single-line input advances, and the footer stacks under 420px of the wizard’s own width.
Containers
Section titled “Containers”| Component | Props worth knowing |
|---|---|
Banner |
appearance="outline": page surface where only the border carries the tone (default soft). |
Section |
title, count, description, actions, divider (default off), padded, as, titleAs. The heading level steps down per titled nesting; a section with no title takes no level. Parts Section.Header, .Title, .Count, .Description, .Actions, .Body replace the props when a part is a direct child. |
Kpi |
label, value, suffix, description, tone, dot, icon, trend, plus trailing (interactive content at the end of the label row) and card (border and radius). Parts Kpi.Label, .Value (mono, suffix, trend), .Description. |
Timeline |
items or Timeline.Item children (both: items first). status per item: done, current, error, pending; tone still wins for the marker colour. |
Table |
flush (true, "start", "end"), stickyHeader (needs maxHeight), maxHeight, selection (selectedKeys, defaultSelectedKeys, onSelectedKeysChange, rowKey, TableSelectCell, TableSelectAllCell), TableActionsCell (menu). |
Note |
tone (neutral, info, success, warning, danger), divider. |
Aside / AsideHeader |
Complementary region named by the localized “Related information” (or aria-label). |
DescriptionList |
New layout (auto, inline, stacked), columns (1 to 3 pairs per row from 28rem), valueAlign (end default, start). DescriptionListItem has term, children as the value and a dash placeholder for null, undefined, false and "" (0 is a value). |
ListDetail |
Master-detail: value/onValueChange (never null), onBack, required aria-label, parts ListDetailList (header), ListDetailHeader (count), ListDetailItem, ListDetailTitle, ListDetailMeta, ListDetailDetail, ListDetailDetailHeader, ListDetailEmpty, plus useListDetail(). Layout: listWidth (rail, number = px), ratio (1:1 default, 1:2, 1:3), surface (flat default, card), stickyList, stickyOffset. One pane under 641px of its own width, with focus moved to the detail and back. |
Feed / FeedEvent |
Activity log: icon, time (ISO instant, calendar date or Date), relative, now, timeZone, timeLabel, meta. |
Shortcuts / ShortcutsItem |
Link or button tiles: href, asChild, icon, count, kbd. |
export function Containers() { return ( <> <Banner tone="warning" appearance="outline" title="Heads up"> Grades close on Friday. </Banner> <Section title="Guardians" count={2} actions={<Button size="sm">Add</Button>} divider> <DescriptionList layout="stacked" columns={2} valueAlign="start"> <DescriptionListItem term="Name">Rosa Quispe</DescriptionListItem> <DescriptionListItem term="Phone">{null}</DescriptionListItem> </DescriptionList> <Note tone="info">Contacts are verified yearly.</Note> </Section> <Aside> <AsideHeader title="Related" /> </Aside> <Feed> <FeedEvent time="2026-10-17T09:30:00Z" relative> Invoice sent </FeedEvent> </Feed> <Shortcuts> <ShortcutsItem href="/grades" kbd="G"> Grades </ShortcutsItem> </Shortcuts> </> );}
export function Inbox() { return ( <ListDetail aria-label="Messages" defaultValue={null}> <ListDetailList> <ListDetailItem value="m1"> <ListDetailTitle>Welcome</ListDetailTitle> </ListDetailItem> </ListDetailList> <ListDetailDetail> <ListDetailDetailHeader title="Welcome" /> <p>Body</p> </ListDetailDetail> </ListDetail> );}None of Section, Note, Aside, DescriptionList or Feed pads its inline edges; the caller owns the gutter.
List and detail as a rail
Section titled “List and detail as a rail”By default ListDetail splits the width evenly and draws only a divider. For a catalogue, give the list a rail with listWidth (320px is a good start) or a proportion with ratio, and set surface="card" for raised panes. stickyList keeps the list column in view and scrolls it on its own below an optional stickyOffset (the height of a sticky top bar). Put search, filter chips and the result count in a ListDetailHeader passed as the list’s header: it stays at the top while the items scroll, and its count is announced politely. ListDetailEmpty fills the detail pane until an item is chosen and renders nothing afterwards. The list column is now a wrapper around the named ul, so styling that targeted the ul through ListDetailList keeps working, while a selector for the list’s parent no longer reaches the grid.
export function StudentRail({ rows }: { rows: { id: string; name: string }[] }) { return ( <ListDetail aria-label="Students" listWidth={320} surface="card" stickyList> <ListDetailList header={ <ListDetailHeader count={rows.length}> <ChipGroup type="single" aria-label="Status" defaultValue="all" options={[ { value: "all", label: "All" }, { value: "active", label: "Active" }, ]} /> </ListDetailHeader> } > {rows.map((row) => ( <ListDetailItem key={row.id} value={row.id}> <ListDetailTitle>{row.name}</ListDetailTitle> </ListDetailItem> ))} </ListDetailList> <ListDetailDetail> <ListDetailEmpty title="No student selected" description="Choose one from the list." /> </ListDetailDetail> </ListDetail> );}Page header with navigation
Section titled “Page header with navigation”PageHeader has slots for the rows around the title. back takes a PageHeaderBack (a real link; href, or asChild with your router’s Link; the label defaults to the localized “Back”), shown before breadcrumbs. meta is a wrapping row of status chips and short facts under the title block. tabs takes a TabsNav or Tabs list across the full width, closing the header with its divider. sticky keeps the whole header, tabs included, at the top of its scroll container on an opaque page background; stickyOffset (px or any CSS length) leaves room for a sticky bar above it.
export function ApplicantHeader() { return ( <PageHeader sticky back={<PageHeaderBack href="/applicants" />} meta={ <> <Chip tone="warning">In review</Chip> <span>Grade 3 admission</span> </> } tabs={ <TabsNav aria-label="Applicant sections" value="overview"> <TabsNavLink value="overview" href="#overview"> Overview </TabsNavLink> <TabsNavLink value="documents" href="#documents" count={4}> Documents </TabsNavLink> </TabsNav> } > <PageHeaderTitle>Ana Torres</PageHeaderTitle> <PageHeaderActions> <Button>Approve</Button> </PageHeaderActions> </PageHeader> );}Page with a side rail
Section titled “Page with a side rail”Page takes an aside (usually an Aside) and lays the page out as a content column plus a rail, 20rem wide by default (asideWidth: pixels or any CSS length). The rail has a divider and the page background; asideSticky keeps it in view and scrolls it on its own beside the content. Page is the page container for the whole split and the rail does not change the width it measures, so a Cols or SettingsLayout in the content reacts the same with or without a rail, and an app that owns the page gutter on the content column gets the same gutter either way. Once the page is 1000px wide or less the rail falls under the content with a top divider. With an aside, className and style go to the content column. Page adds no outer padding or scrolling: the shell owns both.
export function Applicant() { return ( <Page aside={ <Aside className="px-4"> <AsideHeader title="Related" /> <p>Notes and shortcuts.</p> </Aside> } > <PageHeader> <PageHeaderTitle>Ana Torres</PageHeaderTitle> </PageHeader> <Section title="Guardians">Rosa Quispe</Section> </Page> );}Record header
Section titled “Record header”DetailHeader is the header of a record screen (an applicant, a person, a student): avatar, the name as a heading (headingLevel, default 2, the level under the page’s h1), status beside it, a subtitle, actions and a facts row, usually a Facts list. The status and the buttons wrap under the name when the header’s own width is 480px or less. It heads the record, not the page: keep the page title in PageHeader. Avatar only has two sizes, so pass className="size-14" for a larger picture.
export function ApplicantRecord() { return ( <DetailHeader name="Ana Torres" subtitle="Applicant 2041 · Grade 3" avatar={<Avatar name="Ana Torres" size="lg" className="size-14" />} status={<Chip tone="warning">In review</Chip>} actions={<Button>Approve</Button>} facts={ <Facts> <div> <dt>Guardian</dt> <dd>Rosa Quispe</dd> </div> <div> <dt>Applied</dt> <dd>12 Oct 2026</dd> </div> </Facts> } /> );}Tables, parts and statuses
Section titled “Tables, parts and statuses”Table can sit flush with the container edge (flush), keep its header in view (stickyHeader with a maxHeight, which makes the wrapper focusable for keyboard scrolling) and select rows. Selection is on as soon as one of selectedKeys, defaultSelectedKeys or onSelectedKeysChange is set: rows with a rowKey report aria-selected, the select-all box is checked, mixed or unchecked from the keys on screen, and checkboxes are named from the localized selectRow and selectAll strings. TableActionsCell wraps its inline actions under 48rem of the table’s own container; pass menu (your overflow DropdownMenu) to swap them for it instead. Give an actions header column hidden text so it has a name.
export function Students({ rows }: { rows: { id: string; name: string }[] }) { const [selected, setSelected] = useState<string[]>([]); return ( <Table aria-label="Students" flush stickyHeader maxHeight="20rem" selectedKeys={selected} onSelectedKeysChange={setSelected} > <TableHead> <TableRow> <TableSelectAllCell rowKeys={rows.map((row) => row.id)} /> <TableHeaderCell>Name</TableHeaderCell> <TableHeaderCell> <span className="sr-only">Actions</span> </TableHeaderCell> </TableRow> </TableHead> <TableBody> {rows.map((row) => ( <TableRow key={row.id} rowKey={row.id}> <TableSelectCell rowKey={row.id} label={row.name} /> <TableCell>{row.name}</TableCell> <TableActionsCell menu={ <Button size="sm" variant="ghost" aria-label="Row actions"> ... </Button> } > <Button size="sm">Charge</Button> <Button size="sm" variant="ghost"> Open file </Button> </TableActionsCell> </TableRow> ))} </TableBody> </Table> );}Section, Kpi, Timeline and Stepper accept parts or statuses next to their props. A Section with no title (a layout wrapper) takes no heading level, so the titled sections inside it keep the level they would have had. Kpi trailing and Kpi.Label trailing stay exposed to assistive tech, unlike icon.
export function Parts() { return ( <> <Section divider> <Section.Header> <Section.Title>Guardians</Section.Title> <Section.Count>2</Section.Count> <Section.Actions> <Button size="sm">Add</Button> </Section.Actions> </Section.Header> <Section.Body> <Note>Contacts are verified yearly.</Note> </Section.Body> </Section> <Kpi card tone="success" dot> <Kpi.Label trailing={ <Button size="sm" variant="ghost"> Details </Button> } > Collected </Kpi.Label> <Kpi.Value mono suffix="/ 1,200"> 840 </Kpi.Value> <Kpi.Description>3 pending</Kpi.Description> </Kpi> <Timeline aria-label="Admission"> <Timeline.Item title="Applied" status="done" /> <Timeline.Item title="Interview" status="current" /> <Timeline.Item title="Decision" status="pending" /> </Timeline> <Stepper aria-label="Steps" currentStepId="docs" steps={[ { id: "data", label: "Data", status: "done" }, { id: "docs", label: "Documents" }, { id: "pay", label: "Payment", status: "error" }, ]} /> </> );}Custom menus and compound alert dialogs
Section titled “Custom menus and compound alert dialogs”DropdownMenuContent takes bare: only the layering and size limits stay, so you draw the surface yourself. Items are the same DropdownMenuItem (use asChild for links), DropdownMenuAnchor positions the menu against a larger element than the trigger, and dropdownMenuItemClasses gives a hand-made item the default look. AlertDialog.Root composes the same alertdialog from parts; use the props API when the confirm is async and needs a pending state.
export function UserMenu() { return ( <DropdownMenu modal={false}> <DropdownMenuAnchor asChild> <div className="flex items-center gap-2 p-2"> <span>Ana Torres</span> <DropdownMenuTrigger asChild> <button type="button" aria-label="Account menu"> ... </button> </DropdownMenuTrigger> </div> </DropdownMenuAnchor> <DropdownMenuContent bare side="top" align="start" className="w-64 rounded-lg border border-line bg-surface" > <div className="px-3 py-2.5">ana@school.test</div> <DropdownMenuItem asChild> <a href="/profile" className={dropdownMenuItemClasses}> Profile </a> </DropdownMenuItem> <DropdownMenuItem>Sign out</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> );}
export function RemoveStudent({ onRemove }: { onRemove: () => void }) { return ( <AlertDialog.Root> <AlertDialog.Trigger asChild> <Button variant="danger">Remove</Button> </AlertDialog.Trigger> <AlertDialog.Content> <AlertDialog.Header> <AlertDialog.Title>Remove this student?</AlertDialog.Title> <AlertDialog.Description>Their grades are archived.</AlertDialog.Description> </AlertDialog.Header> <AlertDialog.Footer> <AlertDialog.Cancel /> <AlertDialog.Action tone="danger" onClick={onRemove}> Remove </AlertDialog.Action> </AlertDialog.Footer> </AlertDialog.Content> </AlertDialog.Root> );}States
Section titled “States”Skeleton has the variants list, kpis, key-value and bar with a count (default 4, max 12); each renders one status region and hides its bars from assistive technology. EmptyState takes an icon node or one of "inbox", "search" and "lock", and size="sm" renders the inline one-row form for a list, a table cell or a card body (a muted sentence with an optional action). ErrorState accepts an actions node rendered in the same wrapping row as the retry button. RadioGroup radios are now named from their labels and described by their descriptions.
export function States({ onRetry }: { onRetry: () => void }) { return ( <> <Skeleton variant="list" count={5} /> <Skeleton variant="kpis" count={3} /> <EmptyState icon="search" title="No results" description="Try another term." /> <EmptyState size="sm" icon="inbox" title="No pending payments" /> <ErrorState onRetry={onRetry} actions={<Button variant="ghost">Contact support</Button>} /> </> );}Behavior changes to know about
Section titled “Behavior changes to know about”ErrorStaterenders the retry button inside a wrapperdivtogether withactions. Selectors that targeted the button as a direct child need updating.- Success and error toasts are tinted with their tone instead of the inverted neutral look.
defaultis unchanged. CheckboxandSwitchdraw the 44px touch target as an invisible::afterof the control, so under touch density their layout box stays 40px (it was 44px). The target can overlap a neighbour by 2px per side.- A
Sectionwithout a title no longer consumes a heading level or tightens nested spacing. ListDetailcallsonBackfor the back action and never emitsnullthroughonValueChange.Sectiondividerdefaults to off. Kits whose section header always drew a hairline needdivider.