Skip to content

UI widgets

Kumiko apps don’t build their own cards, badges or charts. The renderer ships a mid-level widget kit that sits between the raw primitives (usePrimitives()) and full declarative screens — everything token-based, theme-aware and covered by the styleguide e2e suite. App repos enforce this with the kumiko-guard-ui CI guards: hand-rolled *Card/*Table/*Badge components, raw palette classes, inline styles and hardcoded UI strings fail the build.

Import everything from @cosmicdrift/kumiko-renderer-web:

import {
StatCard, MiniStat, Sparkline, // KPIs
SectionCard, CollapsibleSection, // sections
StatusBadge, // status pills (tone: ok|warn|bad|critical|muted)
TimeseriesChart, StatusBarChart, // SVG charts, no chart dependency
QueryTable, // query-backed table with loading/error/empty
DetailList, ProgressBar, ModeSwitch, // key-value list, bar, segmented control
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";

For data, use the hook set from @cosmicdrift/kumiko-renderer instead of useState+useEffect+fetch: useQuery (pass live: true for SSE invalidation), useMutation and useDisclosure.

You needReach for
KPI tiles with icon, delta, trendStatCard, MiniStat, Sparkline
A titled section with an action slotSectionCard
Status pills (ok / warn / bad / critical)StatusBadge + the --color-status-* theme tokens
A time series or an uptime barTimeseriesChart (gaps render as dips via value: null), StatusBarChart
A table driven by a queryQueryTable — or go fully declarative with the projectionList screen type
A whole KPI pagethe dashboard screen type below — no JSX at all
Empty / loading / error surfacesEmptyState, LoadingState, ErrorState
A calculator form (number fields → live result)useDraft + the field widgets + ResultPanel — see below
A single labelled inputNumberField / MoneyField / PercentField / SelectField / DateField / TextField / BooleanField / TextareaField / RangeField / FileField
A text field with AI ghost-text/correct/translate/rewriteAiTextField / AiTextArea — drop-in for TextField/TextareaField; degrades gracefully when the server’s ai-text feature (enterprise) isn’t mounted
A computed result list or tableResultPanel (rows + footer action slot), ResultTable, ComparisonTable (transposed metric × variant)

Two rules keep apps consistent: colors only through theme tokens (bg-primary, text-status-ok, brand overrides in src/styles.css via @theme), and user-visible text only through t("…") keys.

The styleguide sample renders every widget on one page — it is both the visual reference and the e2e surface for the kit:

Terminal window
cd samples/apps/styleguide && bun dev # → http://localhost:4180/widgets

The catalog page composes the widgets directly in a custom screen:

// Visueller Katalog des Widget-Kits — jede Sektion zeigt ein Widget mit
// statischen Daten. Dient zugleich als e2e-Renderfläche (content.spec).
import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
import {
BooleanField,
CollapsibleSection,
ComparisonTable,
DateField,
DetailList,
EmptyState,
MiniStat,
ModeSwitch,
MoneyField,
PercentField,
ProgressBar,
RangeField,
ResultPanel,
ResultTable,
SectionCard,
SelectField,
StatCard,
StatusBadge,
StatusBarChart,
TextareaField,
TextField,
TimeseriesChart,
useDraft,
} from "@cosmicdrift/kumiko-renderer-web";
import { Wallet } from "lucide-react";
import { type ReactNode, useState } from "react";
const UPTIME = 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: `Tag ${i + 1}`,
}));
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 [mode, setMode] = useState<"annuity" | "fixed">("annuity");
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="Portfolio"
value="92.753 €"
sub="über 4 Konten"
delta={{ value: "2,1 %", direction: "up", tone: "positive" }}
spark={[3, 5, 4, 7, 6, 9, 11, 10]}
/>
<StatCard label="Restschuld" value="184.000 €" tone="warn" trend="−1.200 €/Monat" />
<MiniStat label="Zins p.a." value="3,1 %" />
<MiniStat label="Rate" value="890 €" tone="positive" emphasize />
</div>
<SectionCard
title="Uptime"
subtitle="Letzte 90 Tage"
action={<StatusBadge tone="ok">Operational</StatusBadge>}
>
<StatusBarChart
ariaLabel="Uptime der letzten 90 Tage"
entries={UPTIME}
startLabel="90 Tage"
endLabel="heute"
/>
</SectionCard>
<SectionCard title="Antwortzeit" subtitle="Letzte 24 Stunden">
<TimeseriesChart
points={RESPONSE_TIMES}
windowStartMs={0}
windowEndMs={24 * 60 * 60 * 1000}
ariaLabel="Antwortzeit-Verlauf"
axisLabels={{ start: "vor 24h", mid: "vor 12h", end: "jetzt" }}
/>
</SectionCard>
<SectionCard title="Status-Tones" action={<ProgressBar value={0.65} className="w-40" />}>
<div className="flex flex-wrap gap-2">
<StatusBadge tone="ok">operational</StatusBadge>
<StatusBadge tone="warn">degraded</StatusBadge>
<StatusBadge tone="bad">partial outage</StatusBadge>
<StatusBadge tone="critical">major outage</StatusBadge>
<StatusBadge tone="muted">maintenance</StatusBadge>
</div>
</SectionCard>
<SectionCard
title="Tilgungsmodell"
action={
<ModeSwitch
value={mode}
onChange={setMode}
options={[
{ value: "annuity", label: "Annuität" },
{ value: "fixed", label: "Feste Rate" },
]}
/>
}
>
<DetailList
rows={[
{ label: "Modell", value: mode === "annuity" ? "Annuität" : "Feste Rate" },
{ label: "Sollzins", value: "3,1 %" },
{ label: "Status", value: <StatusBadge tone="ok">aktiv</StatusBadge> },
]}
/>
</SectionCard>
<CollapsibleSection title="Erweiterte Einstellungen">
<EmptyState
title="Noch keine Sondertilgungen"
description="Lege die erste an, um den Plan zu verkürzen."
/>
</CollapsibleSection>
<FinancingCalculatorDemo />
<FormFieldsDemo />
<ComparisonDemo />
</div>
);
}
// Feld-Widgets für Nicht-Zahl-Typen (Select/Date/Text/Boolean/Textarea) —
// wrappen dieselben usePrimitives-Input-kinds wie 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 { draft, patch, field } = useDraft<FieldsDraft>(FIELDS_DEFAULTS);
const { Button } = usePrimitives();
return (
<SectionCard title="Feld-Widgets">
<TextField label="Name" {...field("name")} placeholder="z. B. Variante A" />
<SelectField
label="Bundesland"
{...field("land")}
options={[
{ value: "NW", label: "Nordrhein-Westfalen" },
{ value: "BY", label: "Bayern" },
]}
/>
<DateField label="Datum" {...field("datum")} onChange={(v) => patch({ datum: v ?? "" })} />
<BooleanField label="Makler einbeziehen" {...field("aktiv")} />
<RangeField
label={`Abruf: ${draft.abruf} %`}
{...field("abruf")}
min={0}
max={100}
step={5}
/>
<TextareaField label="Notiz" {...field("notiz")} rows={3} />
<div className="flex items-center gap-2">
<Button size="sm" variant="secondary" onClick={() => {}}>
Klein
</Button>
<Button onClick={() => {}}>Standard</Button>
</div>
</SectionCard>
);
}
// Transponierter Vergleich (Zeile = Kennzahl, Spalte = Variante), beste
// hervorgehoben — für Szenario-/Angebotsvergleiche.
function ComparisonDemo(): ReactNode {
const euro = (n: number): string => `${n.toLocaleString("de-DE")} €`;
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="Vergleich">
<ComparisonTable
columns={scenarios}
columnHeader={(s) => s.name}
columnKey={(s) => s.name}
metricLabel="Kennzahl"
metrics={[
{
label: "Monatsrate",
value: (s) => euro(s.rate),
bestIndex: () => minIndex((s) => s.rate),
},
{
label: "Gesamtzins",
value: (s) => euro(s.interest),
bestIndex: () => minIndex((s) => s.interest),
},
]}
/>
</SectionCard>
);
}
// Live-Input-Rechner: useDraft → pure Berechnung → ResultPanel/ResultTable.
// Belegt, dass das Form-Kit das Rechner-Muster der Apps ohne Custom-CSS trägt.
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 { 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;
const euro = (n: number): string => `${n.toLocaleString("de-DE")} €`;
return (
<div className="grid gap-4 lg:grid-cols-2">
<SectionCard title="Finanzierung">
<MoneyField label="Darlehen" {...field("sum")} required />
<PercentField label="Sollzins" {...field("interest")} required />
<PercentField label="Tilgung" {...field("repayment")} />
</SectionCard>
<ResultPanel
title="Ergebnis"
empty={!ready}
emptyText="Darlehen und Zins eingeben."
rows={[
{ label: "Darlehen", value: euro(draft.sum ?? 0) },
{ label: "Monatsrate", value: euro(rate), emphasize: true },
]}
>
<ResultTable
columns={[
{ header: "Tranche", cell: (r: { label: string; rate: number }) => r.label },
{ header: "Rate", align: "right", cell: (r) => euro(r.rate) },
]}
rows={[{ label: "Bankdarlehen", rate }]}
rowKey={(r) => r.label}
/>
</ResultPanel>
</div>
);
}

The other recurring app shape — N number fields → a pure function → a live result panel — has its own kit so screens stay composition, not hand-wired <Field><Input> blocks and hand-built <dl>/<table>. 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>

Two rules specific to this kit: units (€ / %) live in the label (t("…Summe (€)")), never as a separate badge; and the calc stays a pure function in lib/ with its own test, never inline in the .tsx. For side-by-side variant comparisons (scenario / offer tables) use ComparisonTable — row = metric, column = variant, best per row highlighted. The live examples are in the styleguide catalog above (FinancingCalculatorDemo, FormFieldsDemo, ComparisonDemo).

The same content works without any JSX: the dashboard screen type renders stat, chart and list panels straight from schema. Labels are i18n keys (the boot validator checks coverage), queries return pre-formatted records, and the framework renders through the widgets above:

// 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";
// 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))),
}));
export const widgetsFeature = defineFeature("widgets", (r) => {
r.screen({ id: "widgets", type: "custom", renderer: { react: { __component: "widgets" } } });
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: "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: "custom",
id: "filter-echo",
component: { react: { __component: "widgets-dashboard-filter-echo" } },
},
],
});
r.queryHandler(
"metrics:portfolio-stat",
z.object({ region: z.string().optional() }),
async ({ payload: { region } }) => ({
value: region === "us" ? "38.120 $" : region === "eu" ? "54.630 €" : "92.753 €",
sub: "über 4 Konten",
tone: "positive",
delta: "12 %",
deltaDirection: "up",
deltaTone: "positive",
}),
{ access: { openToAll: true } },
);
r.queryHandler(
"metrics:net-worth-assets",
z.object({ region: z.string().optional() }),
async () => ({ value: "120.000 €" }),
{ access: { openToAll: true } },
);
r.queryHandler(
"metrics:net-worth-debts",
z.object({ region: z.string().optional() }),
async () => ({ value: "65.370 €" }),
{ access: { openToAll: true } },
);
r.queryHandler(
"metrics:response-times",
z.object({}),
async () => ({
points: RESPONSE_POINTS,
windowStartMs: 0,
windowEndMs: 24 * 60 * 60 * 1000,
}),
{ access: { openToAll: true } },
);
r.queryHandler(
"metrics:latest-items",
z.object({}),
async () => ({
rows: [
{ id: "i1", name: "API-Timeout eu-central", status: "resolved" },
{ id: "i2", name: "Zertifikat erneuert", status: "done" },
],
nextCursor: null,
}),
{ access: { openToAll: true } },
);
r.queryHandler(
"metrics:upcoming-events",
z.object({}),
async () => ({
rows: [
{ primary: "Zinsanpassung Baudarlehen", trailing: "Aug 2026" },
{ primary: "Bausparvertrag zuteilungsreif", trailing: "Okt 2026" },
],
}),
{ access: { openToAll: true } },
);
r.queryHandler(
"metrics:goal-progress",
z.object({}),
async () => ({
rows: [
{ label: "Baudarlehen", value: "42.000 € offen", fraction: 0.71 },
{ label: "Autokredit", value: "3.200 € offen", fraction: 0.92 },
],
}),
{ access: { openToAll: true } },
);
r.translations({
keys: {
"screen:widgets.title": { de: "Widgets", en: "Widgets" },
"screen:widgets-dashboard.title": {
de: "Dashboard (deklarativ)",
en: "Dashboard (declarative)",
},
"widgets:dashboard:portfolio": { de: "Portfolio", en: "Portfolio" },
"widgets:dashboard:net-worth": { de: "Netto-Vermögen", en: "Net worth" },
"widgets:dashboard:net-worth-assets": { de: "Vermögen", en: "Assets" },
"widgets:dashboard:net-worth-debts": { de: "Schulden", en: "Debts" },
"widgets:dashboard:response-times": { de: "Antwortzeit", en: "Response time" },
"widgets:dashboard:latest": { de: "Neueste Ereignisse", en: "Latest events" },
"widgets:dashboard:col-name": { de: "Name", en: "Name" },
"widgets:dashboard:col-status": { de: "Status", en: "Status" },
"widgets:dashboard:upcoming": { de: "Nächste Termine", en: "Upcoming" },
"widgets:dashboard:goal-progress": { de: "Tilgungsfortschritt", en: "Payoff progress" },
"widgets:dashboard:filter-region": { de: "Region", en: "Region" },
"widgets:dashboard:filter-region-eu": { de: "Europa", en: "Europe" },
"widgets:dashboard:filter-region-us": { de: "USA", en: "USA" },
},
});
r.nav({
id: "widgets",
label: "Widgets",
parent: "gallery:nav:styleguide",
screen: "widgets:screen:widgets",
icon: "layout-grid",
order: 20,
});
r.nav({
id: "widgets-dashboard",
label: "Dashboard (deklarativ)",
parent: "gallery:nav:styleguide",
screen: "widgets:screen:widgets-dashboard",
icon: "gauge",
order: 21,
});
});

Open it side by side with the catalog at /widgets-dashboard — same visuals, zero component code.

type: "custom" screens stay possible but need an inline allowlist tag with a reason, e.g. an interactive calculator that runs an engine in the browser:

// kumiko-lint-ignore app-feature-structure interactive calculator (browser engine), no declarative fit
r.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.