Two panes with a draggable line between them (also called a split view or resizable panes). Use it when the user needs both at once and decides how much of each: a list beside its detail, a chart above its alerts.
import { Splitter } from "@umriss-ui/core";
A splitter divides the room it is given and does not size it: give its box a height. For three panes, nest a splitter in one of the two.
Name the separator after the pane it sizes, so a screen reader says "Services, 35" instead of the general name.
Give the splitter a box with a height and two children; defaultValue is the first pane's starting share in per cent.
import { Card, Splitter, Text } from "@umriss-ui/core";
export default function TwoPanes() {
return (
<Card style={{ height: 200 }}>
<Splitter defaultValue={35} style={{ height: "100%" }}>
<div style={{ padding: 16 }}>
<Text size="sm">Today's tours</Text>
</div>
<div style={{ padding: 16 }}>
<Text size="sm">The chosen tour's stops</Text>
</div>
</Splitter>
</Card>
);
}
Set orientation to vertical for a chart above its events; min and max keep either pane from vanishing under a drag.
import { Badge, Card, Sparkline, Splitter, Stack, Text } from "@umriss-ui/core";
/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);
interface Service {
id: string;
name: string;
team: string;
/** 1 is customer-facing and pages at night; 3 waits for the morning. */
tier: 1 | 2 | 3;
/** The latency objective: the 95th percentile stays below this, in ms. */
latencySlo: number;
/** The availability promised for a month, in per cent. */
availabilityTarget: number;
}
const SERVICES: readonly Service[] = [
{ id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
{ id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
{ id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
{ id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
{ id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
{ id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
{ id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
{ id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];
interface MetricPoint {
t: number;
/** Median and 95th percentile latency, in ms. */
p50: number;
p95: number;
/** Failed requests, in per cent. */
errorRate: number;
/** Requests per minute. */
requests: number;
}
/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;
/** A service's latency and error rate every five minutes of today, from
midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
const index = SERVICES.findIndex((one) => one.id === serviceId);
const service = SERVICES[index];
if (service === undefined) throw new Error(`No service "${serviceId}".`);
const r = random(1000 + index * 37);
const points: MetricPoint[] = [];
let drift = 0;
for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
const hour = (t - at(17, 0)) / (60 * MINUTE);
const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
drift = 0.8 * drift + (r() - 0.5) * 0.08;
const base = service.latencySlo * (0.45 + 0.2 * load + drift);
const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
const p95 = Math.round(base * surge * (1 + r() * 0.08));
points.push({
t,
p50: Math.round(p95 * (0.42 + r() * 0.06)),
p95,
errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
});
}
return points;
}
const P95 = metrics("checkout").map((point) => point.p95);
export default function OneAboveTheOther() {
return (
<Card style={{ height: 280, maxWidth: 560 }}>
<Splitter orientation="vertical" defaultValue={60} min={20} max={75} separatorLabel="Latency" style={{ height: "100%" }}>
<Stack gap={2} style={{ padding: 16, height: "100%" }}>
<Text size="xs" tone="muted">
Checkout · p95 latency today, ms
</Text>
<Sparkline data={P95} width={500} height={72} />
</Stack>
<Stack gap={2} style={{ padding: 16 }}>
<Stack direction="row" gap={2} align="center">
<Badge tone="danger">High</Badge>
<Text size="sm">p95 latency above 300 ms since 09:41</Text>
</Stack>
<Stack direction="row" gap={2} align="center">
<Badge>Resolved</Badge>
<Text size="sm">Error rate above 2 %, 09:43 to 10:11</Text>
</Stack>
</Stack>
</Splitter>
</Card>
);
}
Control value with onChange to keep the share in the user's settings, or to set it from a button. Focus the separator and the arrow keys move it by step, here ten per cent at a time.
import { useState } from "react";
import { Button, Card, Splitter, Stack, Text } from "@umriss-ui/core";
export default function KeepTheShare() {
const [share, setShare] = useState(50);
return (
<Stack gap={3} style={{ maxWidth: 560 }}>
<Stack direction="row" gap={2} align="center">
<Button size="sm" variant="secondary" onClick={() => setShare(30)}>
Narrow list
</Button>
<Button size="sm" variant="secondary" onClick={() => setShare(50)}>
Half and half
</Button>
<Text size="xs" tone="muted" style={{ fontVariantNumeric: "tabular-nums" }}>
People: {share.toFixed(1)} %
</Text>
</Stack>
<Card style={{ height: 180 }}>
<Splitter value={share} onChange={setShare} min={20} max={80} step={10} separatorLabel="People" style={{ height: "100%" }}>
<div style={{ padding: 16 }}>
<Text size="sm">People of the Web team</Text>
</div>
<div style={{ padding: 16 }}>
<Text size="sm">Chloe Durand · 40 h a week · 34 h planned in sprint 14</Text>
</div>
</Splitter>
</Card>
</Stack>
);
}
Nest a splitter in a pane for a list, its detail and the detail's alerts; name each separator after the pane it sizes.
import { useState } from "react";
import { Badge, Button, Card, Grid, Sparkline, Splitter, Stack, Stat, Text } from "@umriss-ui/core";
/* Data from the operations world, written out here so the example runs on its own. */
/** A small LCG - the same numbers on every computer. */
function random(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
const at = (day: number, hours: number, minutes = 0) => new Date(2026, 2, day, hours, minutes).getTime();
const MINUTE = 60_000;
/** Tuesday, 17 March 2026, 10:30 - the moment the screens are read at. */
const NOW = at(17, 10, 30);
interface Service {
id: string;
name: string;
team: string;
/** 1 is customer-facing and pages at night; 3 waits for the morning. */
tier: 1 | 2 | 3;
/** The latency objective: the 95th percentile stays below this, in ms. */
latencySlo: number;
/** The availability promised for a month, in per cent. */
availabilityTarget: number;
}
const SERVICES: readonly Service[] = [
{ id: "checkout", name: "Checkout", team: "Payments", tier: 1, latencySlo: 300, availabilityTarget: 99.95 },
{ id: "billing", name: "Billing", team: "Payments", tier: 1, latencySlo: 400, availabilityTarget: 99.9 },
{ id: "sign-in", name: "Sign-in", team: "Identity", tier: 1, latencySlo: 200, availabilityTarget: 99.95 },
{ id: "search", name: "Search", team: "Discovery", tier: 1, latencySlo: 250, availabilityTarget: 99.9 },
{ id: "images", name: "Image service", team: "Discovery", tier: 2, latencySlo: 500, availabilityTarget: 99.5 },
{ id: "notifications", name: "Notifications", team: "Messaging", tier: 2, latencySlo: 800, availabilityTarget: 99.5 },
{ id: "webhooks", name: "Webhooks", team: "Integrations", tier: 2, latencySlo: 1000, availabilityTarget: 99.5 },
{ id: "reports", name: "Reporting", team: "Insights", tier: 3, latencySlo: 2000, availabilityTarget: 99 },
];
interface MetricPoint {
t: number;
/** Median and 95th percentile latency, in ms. */
p50: number;
p95: number;
/** Failed requests, in per cent. */
errorRate: number;
/** Requests per minute. */
requests: number;
}
/** Requests per minute at the busiest hour, by tier. */
const VOLUME = { 1: 900, 2: 400, 3: 120 } as const;
/** A service's latency and error rate every five minutes of today, from
midnight up to now. Busy by day, quiet at night; Checkout's 95th percentile
climbs well past its objective from 09:40 to 10:10, and its errors with it. */
function metrics(serviceId: string): MetricPoint[] {
const index = SERVICES.findIndex((one) => one.id === serviceId);
const service = SERVICES[index];
if (service === undefined) throw new Error(`No service "${serviceId}".`);
const r = random(1000 + index * 37);
const points: MetricPoint[] = [];
let drift = 0;
for (let t = at(17, 0); t <= NOW; t += 5 * MINUTE) {
const hour = (t - at(17, 0)) / (60 * MINUTE);
const load = 0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16));
drift = 0.8 * drift + (r() - 0.5) * 0.08;
const base = service.latencySlo * (0.45 + 0.2 * load + drift);
const surge = serviceId === "checkout" && t >= at(17, 9, 40) && t < at(17, 10, 10) ? 1.9 : 1;
const p95 = Math.round(base * surge * (1 + r() * 0.08));
points.push({
t,
p50: Math.round(p95 * (0.42 + r() * 0.06)),
p95,
errorRate: Math.round((0.05 + r() * 0.12 + (surge > 1 ? 2.4 + r() : 0)) * 100) / 100,
requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
});
}
return points;
}
/** A kind of alert. */
interface AlertType {
id: string;
label: string;
priority: "high" | "medium" | "low";
}
const ALERT_TYPES: readonly AlertType[] = [
{ id: "checkout-latency", label: "Checkout · p95 latency above 300 ms", priority: "high" },
{ id: "checkout-errors", label: "Checkout · error rate above 2\u00a0%", priority: "high" },
{ id: "webhooks-queue", label: "Webhooks · queue older than 5 min", priority: "medium" },
{ id: "search-latency", label: "Search · p95 latency above 250 ms", priority: "medium" },
{ id: "images-disk", label: "Image service · disk 85 % full", priority: "low" },
{ id: "reports-job", label: "Reporting · nightly job late", priority: "low" },
];
/** One alert. */
type Alert = {
id: string;
type: string;
lifecycle: "active-unacknowledged" | "active-acknowledged" | "resolved-unacknowledged" | "resolved-acknowledged";
raised: number;
resolved?: number;
acknowledgedAt?: number;
} & (
| { availability?: "in-service" | "suppressed" | "disabled"; snooze?: undefined }
| { availability: "snoozed"; snooze: { until: number; by: string } }
);
/** The alerts as they stand at 10:30. */
const ALERTS: readonly Alert[] = [
{ id: "a-1", type: "checkout-latency", lifecycle: "active-acknowledged", raised: at(17, 9, 41), acknowledgedAt: at(17, 9, 46) },
{ id: "a-2", type: "checkout-errors", lifecycle: "resolved-unacknowledged", raised: at(17, 9, 43), resolved: at(17, 10, 11) },
{ id: "a-3", type: "webhooks-queue", lifecycle: "active-acknowledged", raised: at(17, 7, 12), acknowledgedAt: at(17, 8, 2) },
{ id: "a-4", type: "search-latency", lifecycle: "active-unacknowledged", raised: at(17, 10, 24) },
{ id: "a-5", type: "search-latency", lifecycle: "resolved-acknowledged", raised: at(17, 8, 5), resolved: at(17, 8, 9), acknowledgedAt: at(17, 8, 6) },
{ id: "a-6", type: "images-disk", lifecycle: "active-unacknowledged", raised: at(17, 6, 0), availability: "snoozed", snooze: { until: at(17, 14), by: "Tomasz Nowak" } },
{ id: "a-7", type: "reports-job", lifecycle: "active-unacknowledged", raised: at(17, 4, 30), availability: "disabled" },
];
const TIER_ONE = SERVICES.filter((service) => service.tier === 1);
export default function ThreePanes() {
const [chosen, setChosen] = useState("checkout");
const service = TIER_ONE.find((s) => s.id === chosen) ?? TIER_ONE[0]!;
const p95 = metrics(service.id).map((point) => point.p95);
const alerts = ALERTS.filter(
(alert) => alert.type.startsWith(`${service.id}-`) && alert.lifecycle.startsWith("active"),
);
return (
<Card style={{ height: 340 }}>
<Splitter defaultValue={26} min={20} max={45} separatorLabel="Services" style={{ height: "100%" }}>
<Stack gap={1} style={{ padding: 12 }}>
<Text size="xs" tone="muted" style={{ padding: "0 8px 4px" }}>
Tier 1
</Text>
{TIER_ONE.map((s) => (
<Button
key={s.id}
size="sm"
variant={s.id === chosen ? "secondary" : "ghost"}
aria-pressed={s.id === chosen}
onClick={() => setChosen(s.id)}
>
{s.name}
</Button>
))}
</Stack>
<Splitter orientation="vertical" defaultValue={62} min={30} max={75} separatorLabel={`${service.name} latency`} style={{ height: "100%" }}>
<Grid minItemWidth="180px" gap={4} style={{ padding: 16 }}>
<Stat
label={`${service.name} · p95`}
value={p95[p95.length - 1]}
unit="ms"
limits={{ limits: [{ value: service.latencySlo, side: "upper", severity: "warning" }] }}
/>
<Stack gap={2}>
<Text size="xs" tone="muted">
Today
</Text>
<Sparkline data={p95} width={180} height={48} />
</Stack>
</Grid>
<Stack gap={2} style={{ padding: 16 }}>
{alerts.length > 0 ? (
alerts.map((alert) => (
<Stack key={alert.id} direction="row" gap={2} align="center">
<Badge tone="warning">Active</Badge>
<Text size="sm">{ALERT_TYPES.find((type) => type.id === alert.type)?.label}</Text>
</Stack>
))
) : (
<Text size="sm" tone="muted">
No active alerts for {service.name}.
</Text>
)}
</Stack>
</Splitter>
</Splitter>
</Card>
);
}
| Key | Action |
|---|---|
Arrow Left / Arrow Right |
Side by side: moves the line by one step, 5 % unless set otherwise. |
Arrow Up / Arrow Down |
One above the other: moves the line by one step. |
Home / End |
Moves the line to the smallest or the largest share. |
Enter |
Folds the first pane to its smallest share; again, brings it back. |
SplitterProps| Name | Type | Default | Description |
|---|---|---|---|
orientation | "horizontal" | "vertical" | "horizontal" | How the two panes stand: horizontal side by side, vertical one
above the other - a trend above, its alarms below.Shown in: One above the other, Three panes |
defaultValue | number | 50 | Uncontrolled: the share to start at. Shown in: Two panes, One above the other, Three panes |
value | number | — | Controlled: the first pane's share of the room, in per cent. Shown in: Keep the share |
min | number | 0 | The smallest share of the first pane, and where Enter collapses it to. Shown in: One above the other, Keep the share, Three panes |
max | number | 100 | The largest share of the first pane. Shown in: One above the other, Keep the share, Three panes |
step | number | 5 | How far an arrow key moves the separator, in per cent. Shown in: Keep the share |
separatorLabel | string | the wording's "Resize the panes" | The separator's name. The APG names it after the first pane ("Trend"). Shown in: One above the other, Keep the share, Three panes |
childrenrequired | [ReactNode, ReactNode] | — | The first pane and the second, in this order. Shown in: Two panes, One above the other, Keep the share and 1 more |
ref | Ref<HTMLDivElement> | — | Receives the component's root element. from RefPropShown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more |
| Name | Type | Default | Description |
|---|---|---|---|
onChange | (value: number) => void | — | Reports every move, by pointer or key, as the first pane's share. Shown in: Keep the share |
Also takes every attribute of <div> – without children, defaultValue, onChange.
@umriss-ui/dashboard (ADR-0064).value and onChange.What umriss deliberately does not build, and why: ADR-0032.