UI widgets
The renderer has a widget layer between raw primitives (usePrimitives()) and
fully declarative screens. Use it for app-specific screens. The styleguide
sample is the visual reference and its E2E suite covers the catalog.
If you are choosing a visual pattern, open the Styleguide catalog first. It shows the same building blocks in custom composition, generated forms, and a declarative dashboard before you write a new screen.
App repositories also run UI guards. They reject common replacements for the
widget kit, including hand-built *Card/*Table/*Badge components, raw
palette classes, inline styles, and hardcoded UI strings.
Prerequisites
Section titled “Prerequisites”- Your app renders through
@cosmicdrift/kumiko-renderer/web(any app scaffolded bykumiko createdoes). - You’ve read Features and composition so
r.screenand the feature body are familiar.
The code
Section titled “The code”Import the widgets your screen needs from @cosmicdrift/kumiko-renderer/web:
import { StatCard, MiniStat, Sparkline, // KPIs SectionCard, CollapsibleSection, // sections StatusBadge, // status pill with dot (tone: ok|warn|bad|critical|muted|accent) TimeseriesChart, StatusBarChart, // SVG charts, no chart dependency QueryTable, // query-backed table with loading/error/empty DetailList, ProgressBar, ModeSwitch, // key-value list, bar, segmented control Drawer, // slide-in panel (side: left|right|top|bottom) FloatingPanel, // non-modal window: drag, resize, remembered geometry InfinityList, // cursor-paginated scroll list, loads on IntersectionObserver ResizablePanelGroup, ResizablePanel, ResizableHandle, // draggable split (list | reading pane) EmptyState, LoadingState, ErrorState, // states useDraft, // form state: draft + patch + field(name) NumberField, MoneyField, PercentField, // number fields (unit lives in the label) SelectField, DateField, TextField, // select / date / text BooleanField, TextareaField, RangeField, FileField, AiTextField, AiTextArea, // AI-augmented text (ghost-text, correct/translate/rewrite) ResultPanel, ResultTable, ComparisonTable, // live result panel + tables} from "@cosmicdrift/kumiko-renderer/web";The widget kit is part of the package’s public barrel. Do not import from
renderer/src/web/widgets directly.
For server data, use useQuery from @cosmicdrift/kumiko-renderer and set
live: true when the screen should refetch after SSE invalidation. Use
useMutation for writes and useDisclosure for open/close state.
Pick by concern
Section titled “Pick by concern”| You need | Reach for |
|---|---|
| KPI tiles with icon, delta, trend | StatCard, MiniStat, Sparkline |
| A titled section with an action slot | SectionCard |
| Status pills (ok / warn / bad / critical / muted), each with a 6 px dot | StatusBadge + the --color-status-* theme tokens |
| A time series or an uptime bar | TimeseriesChart (gaps render as dips via value: null), StatusBarChart |
| A table driven by a query | QueryTable, or go fully declarative with the projectionList screen type |
| A whole KPI page | the dashboard screen type below |
| A side panel next to a list (detail view, mail reader, edit form) | Drawer, wraps the Sheet primitive with header/body/footer slots. On a declarative screen, use a kind: "drawer" action instead (see below) |
| A tool window that stays open while the user works on the page behind it (assistant, notes, preview) | FloatingPanel, non-modal, draggable and resizable, see below |
| A scroll list that loads more on demand (inbox, activity feed) | InfinityList, cursor pagination, no pager UI |
| A draggable split between two panes (mail list + reading pane) | ResizablePanelGroup / ResizablePanel / ResizableHandle, vendored shadcn, real drag handle (not the browser’s native resize corner) |
| Empty / loading / error surfaces | EmptyState, LoadingState, ErrorState |
| A calculator form (number fields → live result) | useDraft + the field widgets + ResultPanel, see below |
| A single labelled input | NumberField / MoneyField / PercentField / SelectField / DateField / TextField / BooleanField / TextareaField / RangeField / FileField |
| A text field with AI ghost-text/correct/translate/rewrite | AiTextField / AiTextArea, drop-in for TextField/TextareaField; expects the app to serve the ai-text:query:run query itself and degrades gracefully when it does not |
| A computed result list or table | ResultPanel (rows + footer action slot), ResultTable, ComparisonTable (transposed metric × variant) |
| Syntax-highlighted JSON inspector (payloads, logs, debug) | usePrimitives().JsonView (DefaultJsonView, handles circular structures and BigInt) |
Floating panels
Section titled “Floating panels”FloatingPanel is a window above the page that does not block it: no
overlay, no focus trap, aria-modal="false". The page behind it stays
usable, so the panel can stay open while the user navigates.
<FloatingPanel title={t("notes:panel:title")} headerActions={ <Button variant="ghost" title={t("notes:panel:close")} onClick={close}> {t("notes:panel:close")} </Button> } storageKey="notes:panel"> <NotesList /></FloatingPanel>The header is the move handle. A pointer-down on a button, link or input
inside headerActions does not start a drag. A grip button in the header
moves the panel with the arrow keys (16 px per press, 40 px with Shift), and
each of the four edges is a focusable separator that resizes the same way.
The corners resize with the pointer only.
Without a defaultGeometry the panel opens at 400 × 560 px in the bottom
right corner, 24 px from the edges. minWidthPx and minHeightPx default to
280 and 200, the maximum is the viewport minus the margin. When the browser
window shrinks, the panel is resized and moved back into view. With a
storageKey the geometry goes to localStorage after every move or resize
and is restored on the next mount. If storage is blocked or holds something
unreadable, the panel uses the default geometry.
Below 768 px, the breakpoint useIsNarrowViewport also reports, the panel
renders as a full-screen sheet with the same title and header actions.
For icon-only header actions, Button takes title for the native tooltip
and pressed for aria-pressed on toggle buttons.
Calculator forms
Section titled “Calculator forms”For a calculator, use useDraft(defaults) with a pure calculation function and
ResultPanel. This keeps the screen focused on composition instead of custom
<Field><Input> and <dl>/<table> markup. useDraft(defaults)
holds the form state and hands each field its wiring via field(name)
({ id, name, value, onChange }); the field widgets wrap the matching
usePrimitives() input kind; ResultPanel renders the empty state, a
DetailList of rows and an optional footer action:
const { draft, field } = useDraft(DEFAULTS);const mix = calcFinancingMix(draft); // pure, testable, lives in lib/// …<MoneyField label={t("kfw.field.sum")} required {...field("sum")} /><PercentField label={t("kfw.field.interest")} {...field("interest")} /><ResultPanel title={t("kfw.result")} empty={mix === null} rows={[ { label: t("kfw.total"), value: euro(mix.totalSum) }, { label: t("kfw.rate"), value: euro(mix.startRate), emphasize: true },]}> <ResultTable columns={…} rows={mix.tranches} rowKey={(t) => t.key} /></ResultPanel>Use ComparisonTable for side-by-side scenarios or offers. The live examples
are in the styleguide catalog:
FinancingCalculatorDemo, FormFieldsDemo, and ComparisonDemo.
Declarative dashboards
Section titled “Declarative dashboards”The dashboard screen type renders stat, chart, feed, progress, and list panels
from schema. Labels are i18n keys, the boot validator checks their coverage,
and the panel queries return the records consumed by the widgets:
// Widgets-Feature (server). Zwei Screens:// widgets — custom Katalog-Screen (alle Widgets mit statischen Daten)// widgets-dashboard — deklarativer dashboard-Screen (stat/chart/list-Panels// aus Demo-Queries) — der Schema-getriebene Gegenpart.
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";import { z } from "zod";import { demoMoney, demoMonthYear, demoPercent, demoText } from "./demo-locale";import { WIDGETS_I18N } from "./i18n";
// Statische Demo-Zeitreihe (48 Punkte à 30 Minuten) — kein Date-API,// das Fenster ist relativ zu 0 definiert.const RESPONSE_POINTS = Array.from({ length: 48 }, (_, i) => ({ atMs: i * 30 * 60 * 1000, value: i === 20 ? null : 120 + Math.round(80 * Math.abs(Math.sin(i / 5))),}));
const LOAN_START_MS = Date.UTC(2020, 0, 1);const LOAN_TODAY_MS = Date.UTC(2026, 5, 15);const LOAN_MONTHS = 240;const LOAN_PRINCIPAL_MINOR = 25_000_000;const LOAN_MONTHLY_RATE = 0.035 / 12;
const loanMonthMs = (monthIndex: number): number => Date.UTC(2020, monthIndex, 1);
// Annuity loan over 20 years: remaining principal falls, cumulative interest// grows. "Today" is a fixed date, not the clock, so the window and the// screenshots stay stable.const growth = (1 + LOAN_MONTHLY_RATE) ** LOAN_MONTHS;const annuityMinor = (LOAN_PRINCIPAL_MINOR * LOAN_MONTHLY_RATE * growth) / (growth - 1);// Month index -> extra repayment, which shortens the term below LOAN_MONTHS.const EXTRA_REPAYMENTS_MINOR: ReadonlyMap<number, number> = new Map([ [84, 3_000_000], [150, 2_000_000],]);let balanceMinor: number = LOAN_PRINCIPAL_MINOR;let interestPaidMinor = 0;let payoffMonth: number | undefined;const LOAN_REMAINING_POINTS = [{ atMs: loanMonthMs(0), value: LOAN_PRINCIPAL_MINOR }];const LOAN_INTEREST_POINTS = [{ atMs: loanMonthMs(0), value: 0 }];for (let i = 1; i <= LOAN_MONTHS; i++) { const interest = balanceMinor * LOAN_MONTHLY_RATE; const extraRepayment = EXTRA_REPAYMENTS_MINOR.get(i) ?? 0; balanceMinor = Math.max(0, balanceMinor - (annuityMinor - interest) - extraRepayment); if (balanceMinor === 0) payoffMonth ??= i; interestPaidMinor += interest; LOAN_REMAINING_POINTS.push({ atMs: loanMonthMs(i), value: Math.round(balanceMinor) }); LOAN_INTEREST_POINTS.push({ atMs: loanMonthMs(i), value: Math.round(interestPaidMinor) });}const LOAN_SERIES = [ { key: "remaining", label: "widgets:dashboard:loan-remaining", points: LOAN_REMAINING_POINTS }, { key: "interest", label: "widgets:dashboard:loan-interest", points: LOAN_INTEREST_POINTS },];const LOAN_MARKERS = [ ...[...EXTRA_REPAYMENTS_MINOR.keys()].map((monthIndex, i) => ({ atMs: loanMonthMs(monthIndex), label: { i18nKey: `widgets:dashboard:marker-extra-${i + 1}` }, kind: "extra", })), { atMs: loanMonthMs(payoffMonth ?? LOAN_MONTHS), label: { i18nKey: "widgets:dashboard:marker-payoff" }, kind: "payoff", },];
const SENDERS = ["William Smith", "Alice Smith", "Bob Johnson", "Emily Davis"] as const;const SUBJECTS = ["Meeting Tomorrow", "Re: Project Update", "Weekend Plans", "Re: Budget"] as const;
// Static demo inbox (18 messages) for the InfinityList demo — enough// rows to load several pages beyond pageSize=6.const INBOX_MESSAGES = Array.from({ length: 18 }, (_, i) => ({ id: `m${i + 1}`, // Cast is sound: `i % SENDERS.length` is always in [0, SENDERS.length) — // noUncheckedIndexedAccess can't see that from a computed index, unlike // the removed `as string` this replaces (which had no such guarantee). sender: SENDERS[i % SENDERS.length] as (typeof SENDERS)[number], subject: SUBJECTS[i % SUBJECTS.length] as (typeof SUBJECTS)[number], snippet: "Hi team, just a reminder about our meeting tomorrow at 10 AM.", // % 4 statt % 3: bleibt an der sender/subject-Rotation ausgerichtet, sonst // ist irgendwann jede Kombination mal unread und der Filter zeigt visuell // keinen Unterschied. unread: i % 4 === 0,}));
function openAmount(amount: number, locale: string): string { return demoText("widgets:dashboard:demo-amount-open", locale).replace( "{amount}", demoMoney(amount, "EUR", locale), );}
export const widgetsFeature = defineFeature("widgets", (r) => { r.screen({ id: "widgets", type: "custom", renderer: { react: { __component: "widgets" } } }); r.screen({ id: "widgets-forms", type: "custom", renderer: { react: { __component: "widgets-forms" } }, });
r.screen({ id: "widgets-dashboard", type: "dashboard", filter: { id: "region", label: "widgets:dashboard:filter-region", kind: "select", options: [ { value: "eu", label: "widgets:dashboard:filter-region-eu" }, { value: "us", label: "widgets:dashboard:filter-region-us" }, ], }, panels: [ { kind: "stat", id: "portfolio", label: "widgets:dashboard:portfolio", query: "widgets:query:metrics:portfolio-stat", valueField: "value", subField: "sub", toneField: "tone", deltaField: "delta", deltaDirectionField: "deltaDirection", deltaToneField: "deltaTone", icon: { react: { __component: "widgets-dashboard-kpi-icon" } }, accentColor: "var(--color-primary)", }, { kind: "stat-group", id: "net-worth", label: "widgets:dashboard:net-worth", stats: [ { kind: "stat", id: "net-worth-assets", label: "widgets:dashboard:net-worth-assets", query: "widgets:query:metrics:net-worth-assets", valueField: "value", }, { kind: "stat", id: "net-worth-debts", label: "widgets:dashboard:net-worth-debts", query: "widgets:query:metrics:net-worth-debts", valueField: "value", }, ], }, { kind: "chart", id: "response-times", label: "widgets:dashboard:response-times", chart: "timeseries", query: "widgets:query:metrics:response-times", }, { kind: "chart", id: "loan", label: "widgets:dashboard:loan", chart: "stacked-area", query: "widgets:query:metrics:loan", valueFormat: { kind: "currency", currency: "EUR", fractionDigits: 0 }, brush: true, legendTotals: false, initialWindow: "from-today", markerLegend: "legend", markerLabels: true, markerKinds: { extra: { tone: "active", label: "widgets:dashboard:kind-extra" }, payoff: { tone: "positive", label: "widgets:dashboard:kind-payoff" }, }, ranges: { default: "max", options: [ { value: "y1", label: "widgets:dashboard:range-y1", months: 12 }, { value: "y3", label: "widgets:dashboard:range-y3", months: 36 }, { value: "y5", label: "widgets:dashboard:range-y5", months: 60 }, { value: "max", label: "widgets:dashboard:range-all" }, ], }, }, { kind: "list", id: "latest", label: "widgets:dashboard:latest", query: "widgets:query:metrics:latest-items", columns: [ { field: "name", label: "widgets:dashboard:col-name" }, { field: "status", label: "widgets:dashboard:col-status" }, ], }, { kind: "feed", id: "upcoming", label: "widgets:dashboard:upcoming", query: "widgets:query:metrics:upcoming-events", }, { kind: "progress-list", id: "goal-progress", label: "widgets:dashboard:goal-progress", query: "widgets:query:metrics:goal-progress", }, { kind: "text", id: "dashboard-help", label: "widgets:dashboard:help-title", blocks: [ { kind: "heading", text: "widgets:dashboard:help-heading" }, { kind: "paragraph", text: "widgets:dashboard:help-body" }, { kind: "list", items: ["widgets:dashboard:help-item-1", "widgets:dashboard:help-item-2"], }, { kind: "code", code: "kumiko check --explain" }, { kind: "hint", text: "widgets:dashboard:help-hint" }, ], }, { kind: "custom", id: "filter-echo", component: { react: { __component: "widgets-dashboard-filter-echo" } }, }, ], });
r.queryHandler({ name: "metrics:portfolio-stat", schema: z.object({ region: z.string().optional() }), handler: async ({ payload: { region } }, ctx) => ({ value: region === "us" ? demoMoney(38120, "USD", ctx.locale) : demoMoney(region === "eu" ? 54630 : 92753, "EUR", ctx.locale), sub: demoText("widgets:catalog:portfolio-sub", ctx.locale), tone: "positive", delta: demoPercent(0.12, ctx.locale), deltaDirection: "up", deltaTone: "positive", }), access: { openToAll: { reason: "demo dashboard widget: returns static canned data with no per-user or " + "tenant scoping; any signed-in user may query it", }, }, }); r.queryHandler({ name: "metrics:net-worth-assets", schema: z.object({ region: z.string().optional() }), handler: async (_query, ctx) => ({ value: demoMoney(120000, "EUR", ctx.locale) }), access: { openToAll: { reason: "demo dashboard widget: returns static canned data with no per-user or " + "tenant scoping; any signed-in user may query it", }, }, }); r.queryHandler({ name: "metrics:net-worth-debts", schema: z.object({ region: z.string().optional() }), handler: async (_query, ctx) => ({ value: demoMoney(65370, "EUR", ctx.locale) }), access: { openToAll: { reason: "demo dashboard widget: returns static canned data with no per-user or " + "tenant scoping; any signed-in user may query it", }, }, }); r.queryHandler({ name: "metrics:response-times", schema: z.object({}), handler: async () => ({ points: RESPONSE_POINTS, windowStartMs: 0, windowEndMs: 24 * 60 * 60 * 1000, }), access: { openToAll: { reason: "demo dashboard widget: returns static canned data with no per-user or " + "tenant scoping; any signed-in user may query it", }, }, }); r.queryHandler({ name: "metrics:loan", schema: z.object({}), handler: async () => ({ series: LOAN_SERIES, markers: LOAN_MARKERS, windowStartMs: LOAN_START_MS, windowEndMs: loanMonthMs(LOAN_MONTHS), todayMs: LOAN_TODAY_MS, }), access: { openToAll: { reason: "demo dashboard widget: returns static canned data with no per-user or " + "tenant scoping; any signed-in user may query it", }, }, }); r.queryHandler({ name: "metrics:latest-items", schema: z.object({}), handler: async (_query, ctx) => ({ rows: [ { id: "i1", name: demoText("widgets:dashboard:demo-incident-timeout", ctx.locale), status: "resolved", }, { id: "i2", name: demoText("widgets:dashboard:demo-incident-certificate", ctx.locale), status: "done", }, ], nextCursor: null, }), access: { openToAll: { reason: "demo dashboard widget: returns static canned data with no per-user or " + "tenant scoping; any signed-in user may query it", }, }, }); r.queryHandler({ name: "metrics:inbox-messages", schema: z.object({ cursor: z.coerce.number().int().min(0).optional(), limit: z.number().int().min(1).max(100).optional(), unreadOnly: z.boolean().optional(), search: z.string().optional(), }), handler: async ({ payload: { cursor, limit, unreadOnly, search } }) => { const term = search?.trim().toLowerCase() ?? ""; const filtered = INBOX_MESSAGES.filter( (m) => (unreadOnly !== true || m.unread) && (term === "" || m.sender.toLowerCase().includes(term) || m.subject.toLowerCase().includes(term)), ); const start = cursor ?? 0; const pageSize = limit ?? 6; const rows = filtered.slice(start, start + pageSize); const nextCursor = start + pageSize < filtered.length ? String(start + pageSize) : null; return { rows, nextCursor }; }, access: { openToAll: { reason: "demo dashboard widget: returns static canned data with no per-user or " + "tenant scoping; any signed-in user may query it", }, }, }); r.queryHandler({ name: "metrics:upcoming-events", schema: z.object({}), handler: async (_query, ctx) => ({ rows: [ { primary: demoText("widgets:dashboard:demo-event-rate-adjustment", ctx.locale), trailing: demoMonthYear(2026, 8, ctx.locale), }, { primary: demoText("widgets:dashboard:demo-event-savings-contract", ctx.locale), trailing: demoMonthYear(2026, 10, ctx.locale), }, ], }), access: { openToAll: { reason: "demo dashboard widget: returns static canned data with no per-user or " + "tenant scoping; any signed-in user may query it", }, }, }); r.queryHandler({ name: "metrics:goal-progress", schema: z.object({}), handler: async (_query, ctx) => ({ rows: [ { label: demoText("widgets:dashboard:demo-goal-mortgage", ctx.locale), value: openAmount(42000, ctx.locale), fraction: 0.71, }, { label: demoText("widgets:dashboard:demo-goal-car-loan", ctx.locale), value: openAmount(3200, ctx.locale), fraction: 0.92, }, { label: { i18nKey: "widgets:dashboard:demo-cap-users" }, used: 10, limit: 10 }, { label: { i18nKey: "widgets:dashboard:demo-cap-projects" }, used: 4, limit: null }, ], }), access: { openToAll: { reason: "demo dashboard widget: returns static canned data with no per-user or " + "tenant scoping; any signed-in user may query it", }, }, });
r.translations({ keys: WIDGETS_I18N });
r.nav({ id: "widgets", label: "widgets:nav.widgets", parent: "gallery:nav:styleguide", screen: "widgets:screen:widgets", icon: "layout-grid", order: 20, }); r.nav({ id: "widgets-forms", label: "widgets:nav.widgetsForms", parent: "gallery:nav:styleguide", screen: "widgets:screen:widgets-forms", icon: "clipboard-list", order: 21, }); r.nav({ id: "widgets-dashboard", label: "widgets:nav.widgetsDashboard", parent: "gallery:nav:styleguide", screen: "widgets:screen:widgets-dashboard", icon: "gauge", order: 22, });});Open /widgets-dashboard beside the custom catalog to compare the two paths.
The dashboard has no page component of its own.
Every screen type accepts visibleWhen: { query, field, eq }. The renderer
runs the query and shows the screen only when field equals eq, also when
someone opens the URL directly. Otherwise it renders the screen named in
fallback, or a standard notice. The gate only affects the UI, so the handlers
still enforce access. Top-level dashboard panels (stat, stat-group, chart,
list, feed, progress list) take the same visibleWhen. Their gate query also
receives the screen filter and the time range, and the panel stays hidden
while the gate loads. A stat inside a stat group cannot have its own gate, so
gate the group instead. A stat group can add a subtitle under its label,
and stat groups, charts, lists, feeds and progress lists take span: "half"
or span: "full":
{ kind: "stat-group", id: "billing-kpis", label: "shop:dashboard.billing", subtitle: "shop:dashboard.billingHint", span: "half", visibleWhen: { query: "shop:query:plan-status", field: "hasBilling", eq: true }, stats: [/* … */],}The field list per screen and panel type is in
packages/types/src/screen.ts.
See also r.screen and
Metrics and dashboard data.
Drawer actions and fixed height
Section titled “Drawer actions and fixed height”A row action, related list row action or toolbar action with kind: "drawer"
opens an actionForm of the same feature in a 480 px drawer on the right. The
list or detail behind it stays mounted and refreshes after a successful submit.
params prefills the form from the clicked row:
rowActions: [ { kind: "drawer", id: "adjust-rent", label: "tenancy.actions.adjust-rent", screen: "adjust-rent", // actionForm id params: { pick: ["id"] }, },],Closing the drawer with unsaved input asks before it discards anything. Long
forms stay pages: use a navigate action and an entityEdit screen.
entityList, projectionList, projectionDetail, entityEdit and
actionForm fill the shell height by default. The body scrolls inside, the
toolbar stays on top and the pager or form footer stays pinned at the bottom.
Set fillHeight: false on the screen to get page scrolling back. Custom screens
and direct DataTable or Form usage are not affected.
The framework reference has the details: screen layout (dimensions, drawer actions, layout props) and theming (surfaces, status tones, fonts).
Escape hatches
Section titled “Escape hatches”Use a type: "custom" screen when a declarative screen cannot express the
interaction. Add an inline allowlist tag with a reason:
// kumiko-lint-ignore app-feature-structure interactive calculator (browser engine), no declarative fitr.screen({ id: "credit-calculator", type: "custom", ... });The same tag convention (// kumiko-lint-ignore <guard-slug> <reason>) covers
justified inline styles (runtime values like a chart width from data) and
domain components whose names collide with the primitive naming rules.
Common gotchas
Section titled “Common gotchas”- Use theme tokens for colors. Use
bg-primaryortext-status-ok, and set brand colors withdefineAppTheme(see Make it yours). Raw palette classes failkumiko-guard-ui. - Use
t("…")keys for user-visible text. A hardcoded string fails the same guard, in a custom screen as well as in a declarative one. - Put units in the label. Use
t("…Summe (€)"), not a separate € or % element next to the field. - Keep calculations in a pure, tested function in
lib/. Do not put the calculation inside the.tsxcomponent.
Live example
Section titled “Live example”The styleguide sample renders every widget on one page, it is both the visual reference and the e2e surface for the kit:

cd samples/apps/styleguide && bun dev # → http://localhost:4180/widgetsThe catalog page composes the widgets directly in a custom screen:
// Visual catalog of the widget kit — every section shows one widget with// static data. Also serves as the e2e render surface (content.spec).
import { useLocale, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";import { AiTextArea, AiTextField, BooleanField, ChoiceCards, CollapsibleSection, ComparisonTable, DateField, DetailList, Drawer, EmptyState, FloatingPanel, InfinityList, MiniStat, ModeSwitch, MoneyField, PercentField, PlanCard, PlanGrid, ProgressBar, RangeField, ResizableHandle, ResizablePanel, ResizablePanelGroup, ResultPanel, ResultTable, SectionCard, SelectField, StatCard, StatusBadge, StatusBarChart, TextareaField, TextField, TimeseriesChart, useDraft,} from "@cosmicdrift/kumiko-renderer/web";import { Wallet } from "lucide-react";import { type ReactNode, useEffect, useMemo, useState } from "react";import { euro, percent } from "../lib/format";import { InboxReaderExamples } from "./InboxReaderExamples";import { VariantsDemo, VariantsRoundTwoDemo } from "./VariantsDemo";
const RESPONSE_TIMES = Array.from({ length: 48 }, (_, i) => ({ atMs: i * 30 * 60 * 1000, value: i === 20 ? null : 120 + Math.round(80 * Math.abs(Math.sin(i / 5))),}));
export function Widgets(): ReactNode { const t = useTranslation(); const locale = useLocale().locale(); const [mode, setMode] = useState<"annuity" | "fixed">("annuity"); const [provider, setProvider] = useState<"direct" | "reseller">("direct"); const [drawerOpen, setDrawerOpen] = useState(false); const [belowHeaderDrawerOpen, setBelowHeaderDrawerOpen] = useState(false); const [floatingPanelOpen, setFloatingPanelOpen] = useState(false); const { Button } = usePrimitives();
const uptime = useMemo( () => Array.from({ length: 90 }, (_, i) => ({ key: `day-${i}`, level: i === 30 ? 0.25 : i % 17 === 0 ? 0.75 : 1, tone: i === 30 ? ("critical" as const) : i % 17 === 0 ? ("warn" as const) : ("ok" as const), label: t("widgets:catalog:uptime-day", { n: i + 1 }), })), [t], );
return ( <div className="flex flex-col gap-6 p-6" data-testid="widgets-page"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <StatCard icon={<Wallet className="size-4" aria-hidden="true" />} label={t("widgets:catalog:portfolio")} value={euro(92753, locale)} sub={t("widgets:catalog:portfolio-sub")} delta={{ value: percent(2.1, locale), direction: "up", tone: "positive" }} spark={[3, 5, 4, 7, 6, 9, 11, 10]} /> <StatCard label={t("widgets:catalog:remaining-debt")} value={euro(184000, locale)} tone="warn" trend={t("widgets:catalog:remaining-debt-trend")} /> <MiniStat label={t("widgets:catalog:interest-rate")} value={percent(3.1, locale)} /> <MiniStat label={t("widgets:catalog:rate")} value={euro(890, locale)} tone="positive" emphasize /> </div>
<SectionCard title={t("widgets:catalog:uptime")} subtitle={t("widgets:catalog:uptime-subtitle")} action={<StatusBadge tone="ok">{t("widgets:catalog:operational")}</StatusBadge>} > <StatusBarChart ariaLabel={t("widgets:catalog:uptime-aria")} entries={uptime} startLabel={t("widgets:catalog:uptime-start")} endLabel={t("widgets:catalog:uptime-end")} /> </SectionCard>
<SectionCard title={t("widgets:catalog:response-time")} subtitle={t("widgets:catalog:response-time-subtitle")} > <TimeseriesChart points={RESPONSE_TIMES} windowStartMs={0} windowEndMs={24 * 60 * 60 * 1000} ariaLabel={t("widgets:catalog:response-time-aria")} axisLabels={{ start: t("widgets:catalog:24h-ago"), mid: t("widgets:catalog:12h-ago"), end: t("widgets:catalog:now"), }} /> <div className="mt-4"> <TimeseriesChart points={RESPONSE_TIMES} windowStartMs={0} windowEndMs={24 * 60 * 60 * 1000} ariaLabel={t("widgets:catalog:response-time-p95-aria")} referenceLines={[ { value: 180, label: t("widgets:catalog:response-time-p95"), tone: "warn" }, ]} /> </div> </SectionCard>
<SectionCard title={t("widgets:catalog:status-tones")} action={<ProgressBar value={0.65} className="w-40" />} > <div className="flex flex-wrap gap-2"> <StatusBadge tone="ok">{t("widgets:catalog:status-operational")}</StatusBadge> <StatusBadge tone="warn">{t("widgets:catalog:status-degraded")}</StatusBadge> <StatusBadge tone="bad">{t("widgets:catalog:status-partial-outage")}</StatusBadge> <StatusBadge tone="critical">{t("widgets:catalog:status-major-outage")}</StatusBadge> <StatusBadge tone="muted">{t("widgets:catalog:status-maintenance")}</StatusBadge> </div> </SectionCard>
<div className="flex h-12 w-64 items-stretch" data-testid="progress-stretch-host"> <ProgressBar value={0.5} testId="progress-stretch-bar" /> </div>
<SectionCard title={t("widgets:catalog:repayment-model")} action={ <ModeSwitch value={mode} onChange={setMode} options={[ { value: "annuity", label: t("widgets:catalog:mode-annuity") }, { value: "fixed", label: t("widgets:catalog:mode-fixed") }, ]} /> } > <DetailList rows={[ { label: t("widgets:catalog:model"), value: mode === "annuity" ? t("widgets:catalog:mode-annuity") : t("widgets:catalog:mode-fixed"), }, { label: t("widgets:catalog:nominal-rate"), value: percent(3.1, locale) }, { label: t("widgets:catalog:status"), value: <StatusBadge tone="ok">{t("widgets:catalog:active")}</StatusBadge>, }, ]} /> </SectionCard>
<SectionCard title={t("widgets:catalog:choice-cards")}> <ChoiceCards value={provider} onChange={setProvider} ariaLabel={t("widgets:catalog:choice-cards")} options={[ { value: "direct", title: t("widgets:catalog:choice-direct"), description: t("widgets:catalog:choice-direct-description"), aside: "9,00 €", }, { value: "reseller", title: t("widgets:catalog:choice-reseller"), description: t("widgets:catalog:choice-reseller-description"), aside: "9,90 €", }, ]} /> </SectionCard>
<CollapsibleSection title={t("widgets:catalog:advanced-settings")}> <EmptyState title={t("widgets:catalog:no-extra-repayments-title")} description={t("widgets:catalog:no-extra-repayments-description")} /> </CollapsibleSection>
<SectionCard title={t("widgets:catalog:drawer")} action={<Button onClick={() => setDrawerOpen(true)}>{t("widgets:catalog:open")}</Button>} > <DetailList rows={[ { label: t("widgets:catalog:status"), value: drawerOpen ? t("widgets:catalog:open-status") : t("widgets:catalog:closed-status"), }, ]} /> </SectionCard> <Drawer open={drawerOpen} onOpenChange={setDrawerOpen} title={t("widgets:catalog:drawer-message-title")} description="William Smith · 09:34" footer={ <> <Button variant="secondary" onClick={() => setDrawerOpen(false)}> {t("widgets:catalog:cancel")} </Button> <Button onClick={() => setDrawerOpen(false)}>{t("widgets:catalog:save")}</Button> </> } // The footer's Cancel button already closes the drawer, so the // header X would be a second way to do the same thing. showCloseButton={false} testId="drawer-demo" > <p className="text-sm">Hi team, just a reminder about our meeting tomorrow at 10 AM.</p> </Drawer>
<SectionCard title={t("widgets:catalog:drawer-below-header")} action={ <Button testId="drawer-below-header-open" onClick={() => setBelowHeaderDrawerOpen(true)}> {t("widgets:catalog:open-below-header")} </Button> } > <DetailList rows={[ { label: t("widgets:catalog:status"), value: belowHeaderDrawerOpen ? t("widgets:catalog:open-status") : t("widgets:catalog:closed-status"), }, ]} /> </SectionCard> <Drawer open={belowHeaderDrawerOpen} onOpenChange={setBelowHeaderDrawerOpen} variant="flush" belowHeader title={t("widgets:catalog:drawer-below-header-title")} footer={ <Button onClick={() => setBelowHeaderDrawerOpen(false)}> {t("widgets:catalog:cancel")} </Button> } showCloseButton={false} testId="drawer-below-header-demo" > <p className="text-sm"> Panel top sits at the header's bottom edge, panel bottom sits at the viewport's bottom edge — this footer button must stay visible. </p> </Drawer>
<SectionCard title={t("widgets:catalog:floating-panel")} action={ <Button onClick={() => setFloatingPanelOpen(true)}> {t("widgets:catalog:floating-panel-open")} </Button> } > <DetailList rows={[ { label: t("widgets:catalog:status"), value: floatingPanelOpen ? t("widgets:catalog:open-status") : t("widgets:catalog:closed-status"), }, ]} /> </SectionCard> {floatingPanelOpen && ( <FloatingPanel title={t("widgets:catalog:floating-panel")} headerActions={ <Button variant="secondary" onClick={() => setFloatingPanelOpen(false)}> {t("widgets:catalog:floating-panel-close")} </Button> } storageKey="styleguide:floating-panel" testId="floating-panel-demo" > <p className="text-sm">{t("widgets:catalog:floating-panel-body")}</p> </FloatingPanel> )}
<InboxDemo /> <InboxReaderExamples /> <FinancingCalculatorDemo /> <FormFieldsDemo /> <ComparisonDemo /> <AiTextDemo /> <PlanCatalogDemo /> <VariantsDemo /> <VariantsRoundTwoDemo /> </div> );}
// PlanCard/PlanGrid: a billing-plan catalog (current plan, a purchasable// plan, and a plan whose price failed to load) plus one compact card for a// one-off purchase (e.g. a credit pack) — compact drops the feature list// and description for a denser, single-row layout.function PlanCatalogDemo(): ReactNode { const t = useTranslation(); return ( <SectionCard title={t("widgets:catalog:plans")} subtitle={t("widgets:catalog:plans-subtitle")}> <PlanGrid testId="plan-catalog-demo"> <PlanCard testId="plan-card-starter" title={t("widgets:catalog:plan-starter")} description={t("widgets:catalog:plan-starter-description")} price={{ amount: "$9", period: t("widgets:catalog:plan-per-month") }} features={[ t("widgets:catalog:plan-feature-projects"), t("widgets:catalog:plan-feature-support"), ]} current /> <PlanCard testId="plan-card-pro" title={t("widgets:catalog:plan-pro")} description={t("widgets:catalog:plan-pro-description")} price={{ amount: "$29", period: t("widgets:catalog:plan-per-month") }} features={[ t("widgets:catalog:plan-feature-projects-unlimited"), t("widgets:catalog:plan-feature-support-priority"), t("widgets:catalog:plan-feature-analytics"), ]} cta={{ label: t("widgets:catalog:plan-choose"), onClick: () => {} }} /> <PlanCard testId="plan-card-enterprise" title={t("widgets:catalog:plan-enterprise")} description={t("widgets:catalog:plan-enterprise-description")} price={null} features={[t("widgets:catalog:plan-feature-sso"), t("widgets:catalog:plan-feature-sla")]} cta={{ label: t("widgets:catalog:plan-contact-sales"), onClick: () => {}, disabled: true, }} /> </PlanGrid> <div className="mt-4 max-w-xs"> <PlanCard testId="plan-card-credits" variant="compact" title={t("widgets:catalog:plan-credits")} price={{ amount: "$5" }} cta={{ label: t("widgets:catalog:plan-buy"), onClick: () => {} }} /> </div> </SectionCard> );}
type InboxMessage = { readonly id: string; readonly sender: string; readonly subject: string; readonly snippet: string; readonly unread: boolean;};type InboxPage = { readonly rows: readonly InboxMessage[]; readonly nextCursor: string | null };
// Inbox-like scroll list: filter (unread toggle + search) + row action// (archive, no-op like the other demo buttons here). Clicking a row shows// the message in the right panel — split via resizable, like the list/read// pair in real mail clients (a draggable handle instead of an OS resize// grip).function InboxDemo(): ReactNode { const t = useTranslation(); const { Button } = usePrimitives(); const [unreadOnly, setUnreadOnly] = useState(false); const [search, setSearch] = useState(""); // Debounced: InfinityList refetches whenever its payload identity changes // (payloadKey = JSON.stringify(payload)), so wiring `search` directly in // would refetch on every keystroke — a loading flash per character. const [debouncedSearch, setDebouncedSearch] = useState(""); useEffect(() => { const id = setTimeout(() => setDebouncedSearch(search), 250); return () => clearTimeout(id); }, [search]); const [selected, setSelected] = useState<InboxMessage | null>(null); return ( <SectionCard title={t("widgets:catalog:inbox")} action={ <div className="flex items-center gap-2"> <ModeSwitch value={unreadOnly ? "unread" : "all"} onChange={(v) => setUnreadOnly(v === "unread")} options={[ { value: "all", label: t("widgets:catalog:filter-all") }, { value: "unread", label: t("widgets:catalog:filter-unread") }, ]} /> <Button variant="secondary" onClick={() => {}}> {t("widgets:catalog:mark-all-read")} </Button> </div> } > <TextField label={t("widgets:catalog:search")} id="inbox-search" name="inbox-search" value={search} onChange={setSearch} placeholder={t("widgets:catalog:search-placeholder")} /> <ResizablePanelGroup orientation="horizontal" className="mt-3 h-96 rounded-lg border"> <ResizablePanel defaultSize="35" minSize="25" className="overflow-y-auto"> <InfinityList<InboxPage, InboxMessage> query="widgets:query:metrics:inbox-messages" payload={{ unreadOnly, search: debouncedSearch }} pageSize={6} rows={(data) => data.rows} nextCursor={(data) => data.nextCursor} rowId={(row) => row.id} testId="inbox-demo" live={true} renderRow={(row) => ( <div className={`flex items-start justify-between gap-4 border-b px-3 py-2 last:border-b-0 hover:bg-muted/50 ${ selected?.id === row.id ? "bg-muted" : "" }`} > <button type="button" onClick={() => setSelected(row)} className="min-w-0 flex-1 text-left" > <div className={row.unread ? "font-semibold" : ""}> {row.sender} · {row.subject} </div> <div className="truncate text-sm text-muted-foreground">{row.snippet}</div> </button> <Button variant="secondary" size="sm" onClick={() => {}}> {t("widgets:catalog:archive")} </Button> </div> )} /> </ResizablePanel> <ResizableHandle withHandle /> <ResizablePanel defaultSize="65" minSize="30" className="overflow-y-auto p-4"> {selected === null ? ( <EmptyState title={t("widgets:catalog:no-message-selected-title")} description={t("widgets:catalog:no-message-selected-description")} /> ) : ( <div> <div className="font-semibold">{selected.sender}</div> <div className="text-sm text-muted-foreground">{selected.subject}</div> <p className="mt-4 text-sm">{selected.snippet}</p> </div> )} </ResizablePanel> </ResizablePanelGroup> </SectionCard> );}
// Ghost-text completion + correct/translate/rewrite toolbar. The server// handler here is a hand-rolled demo feature (ai-text-demo.ts, canned// strings), not the real enterprise feature — the public framework must not// import the private enterprise package. The title field is deliberately pre-filled with// a value wider than the box (ghost-overlay scroll sync), the note textarea// with more lines than visible (vertical scroll sync).function AiTextDemo(): ReactNode { const t = useTranslation(); const [title, setTitle] = useState(() => t("widgets:catalog:long-title-demo")); const [note, setNote] = useState(() => Array.from({ length: 12 }, (_, i) => t("widgets:catalog:long-note-line", { n: i + 1 })).join( "\n", ), ); return ( <SectionCard title={t("widgets:catalog:ai-text")} subtitle={t("widgets:catalog:ai-text-subtitle")} > <AiTextField id="ai-text-title" name="title" label={t("widgets:catalog:title")} value={title} onChange={setTitle} /> <AiTextArea id="ai-text-note" name="note" label={t("widgets:catalog:note")} value={note} onChange={setNote} rows={4} /> </SectionCard> );}
// Field widgets for non-number types (select/date/text/boolean/textarea) —// wrap the same usePrimitives input kinds as NumberField.interface FieldsDraft { readonly land: string; readonly datum: string; readonly name: string; readonly aktiv: boolean; readonly notiz: string; readonly abruf: number;}
const FIELDS_DEFAULTS: FieldsDraft = { land: "NW", datum: "2026-07-10", name: "", aktiv: true, notiz: "", abruf: 40,};
function FormFieldsDemo(): ReactNode { const t = useTranslation(); const { draft, field } = useDraft<FieldsDraft>(FIELDS_DEFAULTS); const { Button } = usePrimitives(); return ( <SectionCard title={t("widgets:catalog:form-fields")}> <TextField label={t("widgets:catalog:name")} {...field("name")} placeholder={t("widgets:catalog:name-placeholder")} /> <SelectField label={t("widgets:catalog:state")} {...field("land")} options={[ { value: "NW", label: t("widgets:catalog:state-nw") }, { value: "BY", label: t("widgets:catalog:state-by") }, ]} /> <DateField label={t("widgets:catalog:date")} {...field("datum")} /> <BooleanField label={t("widgets:catalog:include-broker")} {...field("aktiv")} /> <RangeField label={t("widgets:catalog:call-rate", { n: draft.abruf })} {...field("abruf")} min={0} max={100} step={5} /> <TextareaField label={t("widgets:catalog:note")} {...field("notiz")} rows={3} /> <div className="flex items-center gap-2"> <Button size="sm" variant="secondary" onClick={() => {}}> {t("widgets:catalog:small")} </Button> <Button onClick={() => {}}>{t("widgets:catalog:standard")}</Button> </div> </SectionCard> );}
// Transposed comparison (row = metric, column = variant), best value// highlighted — for scenario/offer comparisons.function ComparisonDemo(): ReactNode { const t = useTranslation(); const locale = useLocale().locale(); const scenarios = [ { name: "A", rate: 890, interest: 84000 }, { name: "B", rate: 940, interest: 71000 }, ]; const minIndex = (pick: (s: (typeof scenarios)[number]) => number): number => { let bestI = 0; let bestV = Number.POSITIVE_INFINITY; scenarios.forEach((s, i) => { const v = pick(s); if (v < bestV) { bestV = v; bestI = i; } }); return bestI; }; return ( <SectionCard title={t("widgets:catalog:comparison")}> <ComparisonTable columns={scenarios} columnHeader={(s) => s.name} columnKey={(s) => s.name} metricLabel={t("widgets:catalog:metric")} metrics={[ { label: t("widgets:catalog:monthly-rate"), value: (s) => euro(s.rate, locale), bestIndex: () => minIndex((s) => s.rate), }, { label: t("widgets:catalog:total-interest"), value: (s) => euro(s.interest, locale), bestIndex: () => minIndex((s) => s.interest), }, ]} /> </SectionCard> );}
// Live-input calculator: useDraft → pure calculation → ResultPanel/ResultTable.// Shows the form kit carries the apps' calculator pattern without custom CSS.interface CalcDraft { readonly sum: number | undefined; readonly interest: number | undefined; readonly repayment: number | undefined;}
const CALC_DEFAULTS: CalcDraft = { sum: 300000, interest: 3.8, repayment: 2 };
function FinancingCalculatorDemo(): ReactNode { const t = useTranslation(); const locale = useLocale().locale(); const { draft, field } = useDraft<CalcDraft>(CALC_DEFAULTS); const ready = draft.sum !== undefined && draft.interest !== undefined; const rate = ready ? Math.round((draft.sum * ((draft.interest + (draft.repayment ?? 0)) / 100)) / 12) : 0; return ( <div className="grid gap-4 lg:grid-cols-2"> <SectionCard title={t("widgets:catalog:financing")}> <MoneyField label={t("widgets:catalog:loan")} {...field("sum")} required /> <PercentField label={t("widgets:catalog:nominal-rate")} {...field("interest")} required /> <PercentField label={t("widgets:catalog:repayment")} {...field("repayment")} /> </SectionCard> <ResultPanel title={t("widgets:catalog:result")} empty={!ready} emptyText={t("widgets:catalog:enter-loan-and-interest")} rows={[ { label: t("widgets:catalog:loan"), value: euro(draft.sum ?? 0, locale) }, { label: t("widgets:catalog:monthly-rate"), value: euro(rate, locale), emphasize: true }, ]} > <ResultTable columns={[ { header: t("widgets:catalog:tranche"), cell: (r: { label: string; rate: number }) => r.label, }, { header: t("widgets:catalog:rate"), align: "right", cell: (r) => euro(r.rate, locale), }, ]} rows={[{ label: t("widgets:catalog:bank-loan"), rate }]} rowKey={(r) => r.label} /> </ResultPanel> </div> );}See also
Section titled “See also”- Dialog and Lightbox overlays, the overlay primitives the widget kit does not cover.
- Build a marketing landing page : the same widgets on an apex page.
- Styleguide sample, the runnable app behind the catalog above.