Splitter

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.

Examples

Two panes

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>
  );
}

One above the other

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>
  );
}

Keep the share

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>
  );
}

Three panes

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>
  );
}

When to use something else

Keyboard

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.

API

SplitterProps

NameTypeDefaultDescription
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

defaultValuenumber50Uncontrolled: the share to start at.

Shown in: Two panes, One above the other, Three panes

valuenumber—Controlled: the first pane's share of the room, in per cent.

Shown in: Keep the share

minnumber0The smallest share of the first pane, and where Enter collapses it to.

Shown in: One above the other, Keep the share, Three panes

maxnumber100The largest share of the first pane.

Shown in: One above the other, Keep the share, Three panes

stepnumber5How far an arrow key moves the separator, in per cent.

Shown in: Keep the share

separatorLabelstringthe 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

refRef<HTMLDivElement>—Receives the component's root element. from RefProp

Shown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more

Events

NameTypeDefaultDescription
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.

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/core

Getting started

Customising

Layout

Typography

Actions

Forms

Choosing

Dates and times

Feedback

Overlays

Navigation

Data display

API index