Skip to content

UI Components

GitHub Actions edited this page Sep 18, 2026 · 2 revisions

Components

A tour of every component by group, with the props you reach for first and the behaviour that comes with it. Each heading links to the generated reference page with the complete prop table, CSS hooks, data-* attributes, and a Usage section: one or two real call sites, each with the HTML it renders — the markup a plain page writes. All snippets are TypeScript against @tundralibs/rapid/ui's html; the markup they produce is what a plain HTML page writes by hand.

Common to every component: attrs spreads extra attributes onto the root (a caller's class is merged, not dropped); ids are stable and derived from what you pass (never counters), so aria-controls, data-toggle and history push survive a swap; nothing emits an inline style. For whole pages built from these — a sign-in page, a dashboard, an invoices table with bulk actions, an upload with progress — in both rAPId and plain HTML, see Recipes.


Forms

Input({ id: "email", name: "email", type: "email", placeholder: "you@acme.com", required: true });
Input({ value: "Locked", disabled: true });
Input({ value: "bad", invalid: true }); // aria-invalid + danger border
Input({ type: "date", name: "due" }); // renders the DatePicker
Input({ size: "sm" | "md" | "lg" });

FloatingInput({ id, name, label }) is the floating-label variant. InputIcon({ icon, control, end? }) puts an icon in the field; InputGroup({ start, end, control }) adds prefix/suffix addons — text, or a Select (give the inner control extraClass: "input-group__control").

Textarea({ id: "bio", name: "bio", rows: 4, placeholder: "A short bio…" });
Select({
  id: "plan",
  name: "plan",
  value: "team",
  placeholder: "Pick a plan",
  options: [{ value: "free", label: "Free" }, { value: "team", label: "Team" }, {
    value: "x",
    label: "Sold out",
    disabled: true,
  }],
});

Looks and behaves like the combobox (field, caret, list, arrow keys, type-ahead) minus free typing. A native <select> carries the name and is what submits — and what a no-JS page shows. Give it an id or name.

Combobox({
  id: "reviewer",
  name: "reviewer",
  label: "Assign reviewer",
  options: [{ value: "gh", label: "Grace Hopper", group: "Team" }, {
    value: "ag",
    label: "Ada Grant",
    meta: "Contractor",
  }],
  selected: "gh",
  action: "/reviewers",
  hint: "Type to filter",
});
Combobox({ id: "tags", name: "tags", multi: true, selected: ["a", "b"], options });

Free text + a listbox. With action, every keystroke asks the server for a new list (?q=) and your route returns ComboboxList({ id, options, query, selected }); without it the rendered options filter client-side. The value travels in a hidden input (single) or one hidden input per token (multi; Backspace on an empty field pops the last). Keyboard: open on focus, arrows, Enter, Escape (only claimed while open).

DatePicker({ id: "due", name: "due", start: "2026-09-14", min: "2026-09-01" }); // client mode
DatePicker({
  id: "period",
  name: "period",
  range: true,
  start,
  end,
  inline: true, // server mode
  buildMonthHref: (y, m) => `/report?month=${y}-${m + 1}`,
  buildDayHref: (iso) => `/report?day=${iso}`,
  presets: [{ label: "Last 7d", href: "/report?preset=7d" }],
});

Month and year navigation, single or range, min/max, presets, align: "end" to hug a right edge, inline to stay in flow (an inline picker is page content and ignores outside clicks). Server mode makes month/day/preset links rAPId swaps that re-render the whole control; the route returns DatePicker(...) again. Hidden inputs name / name_end carry ISO dates.

Otp({ id: "code", name: "code", length: 6, groups: 2, label: "SMS code", autoSubmit: true });
Otp({ id: "code", name: "code", value: "48213", length: 5, error: "That code has expired." });

Cells auto-advance, a paste or SMS autofill spreads across them, Backspace walks back, otp:complete fires when full (autoSubmit calls form.requestSubmit()). One autocomplete="one-time-code" input submits; without JS it is the control.

Switch({ id: "deploy", name: "deploy", label: "Deploy on merge", hint: "Pushes to main ship.", checked: true });
Checkbox({ name: "opt", label: "Option A", checked: true });
Radio({ name: "size", value: "m", label: "Medium" });
ChoiceGroup({ inline: true, items: [Checkbox({ label: "A" }), Checkbox({ label: "B" })] });
Segmented({
  id: "view",
  name: "view",
  legend: "View",
  value: "board",
  options: [{ value: "board", label: "Board" }, { value: "grid", icon: Icon("dashboard"), ariaLabel: "Grid" }],
  size: "sm",
  block: true,
});
Slider({
  id: "workers",
  name: "workers",
  label: "Concurrency",
  min: 1,
  max: 32,
  value: 12,
  unit: "workers",
  unitOne: "worker",
});
Slider({ id: "tier", name: "tier", label: "Size", min: 0, max: 3, value: 1, scale: ["S", "M", "L", "XL"] });

Segmented is a real radiogroup (it submits; a thumb glides to the checked option). To submit on change through rAPId, wrap it in a <form data-action data-target method="get">. Slider keeps a live <output> and paints its track from a custom property set by the script.

Dropzone({
  id: "files",
  name: "files",
  accept: ".pdf,.png",
  multiple: true,
  hint: "Up to 10 MB each",
  files: [{ name: "q3.pdf", kind: "pdf", size: "2.4 MB", progress: 64 }, {
    name: "logo.png",
    size: "184 KB",
    removeHref: "/uploads/logo/delete",
  }, { name: "a.zip", error: "Unsupported" }],
});

A native <input type=file> covers the area. Upload rows are server state (progress is a native <progress>; removeHref renders a POST form that swaps the row out). Put the dropzone in a Form with data-action / data-target="#<dropzone id>" / data-swap="outer" and the upload is a swap: dropzone.js renders a pending row per picked file on submit, fills its bar from rAPId's rapid:progress, and the reply (the Dropzone again, with your rows) replaces them. See the upload recipe.

Editor({ id: "notes", name: "notes", mode: "markdown", value, previewAction: "/preview" });
Editor({ id: "bio", name: "bio", mode: "html", value: "<p>Trusted, <strong>sanitised</strong> HTML</p>" });

Markdown: a textarea with a toolbar (bold, italic, strikethrough, heading, quote, code, link, image, lists, rule, clear — every command toggles; Ctrl/Cmd+B/I/K) and a Preview view the server renders: previewAction receives text and answers an HTML fragment. HTML: a WYSIWYG surface (same toolbar plus underline; block formats toggle) mirrored into the textarea that submits. Sanitise on the server; the initial HTML value is trusted.

Form({
  id: "signup",
  action: "/signup",
  error: formError,
  attrs: { "data-action": "/signup", "data-target": "#signup", "data-swap": "outer" },
  content: html`${
    FormGrid({
      fields: [
        FormField({
          id: "name",
          label: "Name",
          required: true,
          span: 6,
          error: fields.name,
          control: (a11y) =>
            Input({ id: "name", name: "name", invalid: a11y.invalid, attrs: { "aria-describedby": a11y.describedBy } }),
        }),
        FormField({
          id: "plan",
          label: "Plan",
          help: "Change any time.",
          span: 6,
          control: Select({ id: "plan", name: "plan", options }),
        }),
      ],
    })
  }${FormActions({ content: Button({ label: "Create", type: "submit" }) })}`,
});

Form({ error }) renders rAPId's RapidFormError as a banner (FormErrorAlert), FormField({ error }) wires aria-describedby and aria-invalid through the control callback, FormGrid is a 12-column field grid, FormActions the button row.


Data

DataTable<Invoice>({
  id: "invoices",
  title: "Invoices",
  selectable: true,
  selected: ["INV-2"],
  maxHeight: "md",
  sort: { key: "id", dir: "desc" },
  buildSortHref: (key, dir) => `/invoices?sort=${key}&dir=${dir}`,
  columns: [
    { key: "id", label: "Invoice", pinned: true, mono: true, sortable: true },
    { key: "client", label: "Client", sortable: true },
    { key: "status", label: "Status", render: (r) => Badge({ label: r.status, dot: true }) },
    { key: "total", label: "Total", numeric: true },
  ],
  rows,
  rowKey: (r) => r.id,
  bulkAction: "/invoices/bulk",
  bulkActions: html`${
    Button({ label: "Archive", size: "sm", type: "submit", attrs: { name: "op", value: "archive" } })
  }${Button({ label: "Clear", size: "sm", attrs: { "data-bulk-clear": "" } })}`,
  rowActions: (r) =>
    RowActions({
      id: `row-${r.id}`,
      label: `Actions for ${r.id}`,
      items: [
        { label: "View", href: `/invoices/${r.id}` },
        { label: "Duplicate", attrs: { "data-action": `/invoices/${r.id}/duplicate`, "data-method": "post" } },
        { label: "Delete", danger: true, attrs: { "data-action": `/invoices/${r.id}/delete`, "data-method": "post" } },
      ],
    }),
  toolbar: Input({ type: "search", size: "sm", placeholder: "Filter" }),
  footer: Pagination({ page: 2, totalPages: 8, buildHref: (p) => `/invoices?page=${p}`, target: "#invoices" }),
  emptyMessage: "No invoices yet.",
});

The one table: sticky header, pinned/mono/numeric columns, selection with a bulk bar (selectName, default selected), row actions, height caps, empty (any Html, e.g. an Empty) or emptyMessage. Sort links are swaps that replace the table and push history — make them point at the page route (why). Add data-filter-scope around it and [data-table-search] / [data-table-filter] controls for client-side filtering with no backend.

The bulk bar overlays the header row while rows are selected (sticky, inside the scroll box, the select-all cell kept above it), so starting or clearing a selection never moves the rows. The trade-off is deliberate: while a selection exists the header band is the action bar, so sort links are covered until it is cleared (Clear, or untick select-all); a selection made before a sort, page or back/forward swap is restored afterwards. The selection column is sticky at the start, a pinned column sits right after it.

Bulk actions post the selection. With bulkAction set, the bulk bar and the rows sit in a <form method="post"> that is a rAPId swap replacing the table (outer into #<id>); the toolbar and footer stay outside it, so a search box never submits it. Make each bulk button a submit that names its operation (type: "submit", attrs: { name: "op", value: "archive" }) — the runtime posts the submitter too, so the handler receives op plus one selected entry per checked row, and answers with the re-rendered table (or a redirect without JavaScript, PRG). A selection survives sort and page swaps and back/forward: the script remembers it per table id and re-applies it after a GET swap; a POST reply renders whatever selected the server passes (usually nothing).

Pagination({
  page: 5,
  totalPages: 12,
  buildHref: (p) => withQuery("/list", view.query, { page: p }),
  target: "#list",
  siblingCount: 1,
});

Disabled prev/next are spans, not dead links. With target the links are swaps (outer, pushed).

Badge({ label: "Live", variant: "success", dot: true });
Badge({ label: "v2.1.0", variant: "code" });
Chip({ label: "Design", removable: true });
Chip({ label: "Read-only", static: true });
Avatar({ initials: "GH", size: "sm" });
Avatar({ src, alt: "Grace" });
AvatarGroup({ avatars: [a, b, c] });
Stat({ label: "Revenue", value: "$48.2k", icon: Icon("coin"), tone: "success", trend: { label: "+12.4%", up: true } });
Timeline({
  items: [{ title: "Created", meta: "09:12", status: "done" }, { title: "Awaiting payment", status: "current" }, {
    title: "Reconciled",
  }],
});
Empty({
  icon: "invoice",
  title: "No invoices yet",
  text: "Import a CSV to start.",
  actions: Button({ label: "Import", size: "sm" }),
});
Empty({ variant: "inline", tone: "error", icon: "warning", title: "Couldn't load", code: `502 · ${requestId}` });
Skeleton({ variant: "title", width: "half" });
SkeletonTable({ rows: 3 });
SkeletonCard();
Progress({ value: 60 });
Spinner({ label: "Loading rows" });

Skeletons pair with data-load regions: render them as the placeholder, the lazy GET swaps the real content in. Widths are a step scale, never inline.


Cards & structure

Card({
  variant: "elevated",
  title: "Elevated",
  subtitle: "With a footer",
  body: html`<p></p>`,
  footer: Button({ label: "Action", size: "sm" }),
});
Card({ href: "/projects/1", media: { src, alt: "", ratio: "wide" }, title: "Linked", body });
Card({
  as: "button",
  interactive: true,
  selected: true,
  avatar: Avatar({ initials: "GH" }),
  title: "Grace",
  actions: kebab,
  body,
});
Card({ variant: "danger", title: "Delete workspace", body, footer: Button({ label: "Delete", variant: "danger" }) });

Variants outlined / elevated / flat / danger, vertical or horizontal, as a div, a or button, with media (four ratios, lazy by default, an overlay caption), header avatar/actions, split footer. The parts (CardMedia, CardHeader, CardBody, CardFooter) are exported for custom compositions.

Grid({ items: [GridCol({ span: 8, content }), GridCol({ span: 4, content: aside })] }); // 12 columns, 1 below md
PageHeader({
  title: "Projects",
  subtitle: "All of them.",
  breadcrumb: Breadcrumb({ items }),
  actions: Button({ label: "New" }),
});
Toolbar({ start: html`${searchInput}${statusSelect}`, end: html`${exportButton}${newButton}` });
Tabs({
  id: "settings",
  active: "billing",
  items: [{ id: "general", label: "General", content }, { id: "billing", label: "Billing", content }],
});
Collapsible({ id: "faq-1", title: "What is this?", content, defaultOpen: true });
Accordion({ id: "faq", items: [{ title: "One", content }, { title: "Two", content }] });
Wizard({
  steps: [{ label: "Details", status: "done" }, { label: "Payment", status: "active" }, { label: "Review" }],
  content,
});

Tabs have roving tabindex, Home/End, and a #tab-<id> deep link that activates the tab on load (blurring it, so a sidebar link does not paint a focus ring).


Navigation

Navbar({
  id: "top",
  brand: "Acme",
  links: [{ href: "/", label: "Home", active: true }],
  actions: Button({ label: "Sign in", size: "sm" }),
});
Sidebar({
  id: "nav",
  brand,
  collapsible: true,
  items: [
    { label: "Overview", href: "/", icon: Icon("dashboard"), active: true },
    {
      label: "Settings",
      icon: Icon("settings"),
      expanded: true,
      children: [{ label: "General", href: "/settings#tab-general" }, {
        label: "Billing",
        href: "/settings#tab-billing",
      }],
    },
  ],
});
Menu({ id: "m", items });
Breadcrumb({ items: [{ label: "Home", href: "/" }, { label: "Settings", href: "/settings" }, { label: "Profile" }] });

The navbar collapses to a toggle on phones. The sidebar is the drawer / collapsible rail the SidebarLayout manages; SidebarToggle({ targetId }) is the button it renders. Menus nest to any depth; the script marks the link matching the full URL (path + hash) current and opens its parents.

Command({
  id: "palette",
  items: [{ label: "New invoice", group: "Actions", icon: "invoice", shortcut: "⌘N" }, {
    label: "INV-2048",
    group: "Recent",
    href: "/invoices/2048",
    badge: "IN",
  }],
  action: "/commands",
});
Button({ label: "Search…", attrs: { "data-command-open": "#palette" } });

A floating palette (⌘K or any [data-command-open] opens it; overlay click or Escape closes; focus returns) or inline: true for an in-page one. With action the server answers ?q= with CommandList({ id, items, query }).


Actions

Button({ label: "Save", type: "submit" });
Button({ label: "Cancel", variant: "ghost" }); // primary | secondary | outline | subtle | ghost | accent | danger
Button({ label: "Small", size: "sm" }); // sm | md | lg
Button({ label: "Saving", loading: true });
Button({ label: "Docs", href: "/docs" });
Button({ label: "Wide", block: true });
Button({ iconOnly: true, iconStart: Icon("search"), attrs: { "aria-label": "Search" } });
ButtonGroup({ buttons: [Button({ label: "Day", variant: "outline" }), Button({ label: "Week", variant: "outline" })] });

Any button can be a swap: attrs: { "data-action": "/fragments/toast", "data-target": "#toast-region", "data-swap": "append" }.

Dropdown({ id: "opts", trigger: "Options", align: "end", content: Menu({ items }) }); // a text trigger is a button with a caret
ButtonGroup({
  buttons: [
    Button({ label: "Download" }),
    Dropdown({ id: "dl", trigger: Icon("chevronDown"), triggerClass: "btn btn--primary", content: Menu({ items }) }),
  ],
});
Popover({ id: "gh", trigger: PopoverTrigger({ controls: "gh", label: "Grace Hopper" }), content, align: "start" });
Popover({
  id: "profile",
  loadFrom: "/fragments/profile",
  trigger: PopoverTrigger({ controls: "profile", label: "Profile" }),
});
Tooltip({
  id: "tip",
  trigger: Button({ label: "Hover", attrs: { "aria-describedby": "tip" } }),
  content: "Helpful context",
});

Dropdowns close on pick, Escape and outside click, and switch to fixed positioning inside a scrolling table. Popovers restore focus and only claim Escape when open; loadFrom fetches content lazily. Tooltips are CSS-only.


Feedback

Alert({ variant: "info", title: "Scheduled maintenance", body: "Saturday 02:00–04:00 UTC.", dismissible: true });
Alert({ variant: "danger", title: "Validation failed", fields: { email: "Enter a valid email" } }); // what FormErrorAlert renders
Toast({ variant: "success", body: "Invoice sent", meta: "INV-2048", autoDismissMs: 4000 });
Toast({
  variant: "ink",
  body: "3 rows deleted",
  action: { label: "Undo", attrs: { "data-action": "/undo", "data-target": "#rows" } },
});
ToastRegion({ max: 3 }); // the core template renders one per page as #toast-region
Button({ label: "Delete", attrs: { "data-modal-open": "#confirm" } });
Modal({
  id: "confirm",
  title: "Delete workspace?",
  body,
  footer: html`${Button({ label: "Cancel", variant: "ghost" })}${Button({ label: "Delete", variant: "danger" })}`,
});

Toasts append into the region (data-swap="append" from the server, or [data-toast-open="#template"] client-side) and auto-dismiss. Modals are native <dialog>s: centred, backdrop click closes, Escape closes, focus returns.


Charts

See ChartsChart({ type, series, … }) for all 28 ApexCharts types on the library's tokens.

Clone this wiki locally