Zoom and pan

Look closer at a stretch of a long series and move along it (zoom, pan, a viewport), by wheel, pinch, drag or key, and keep several charts on the same stretch.

import { useChart } from "@umriss-ui/charts";

zoomable on an x axis turns the gestures and keys on; nothing else is needed. The span shown is part of the chart's view (ADR-0047): useChart(rows, { initialView, onViewChange }) takes a start and reports what the reader does, whole, at most once per frame; setView(view), domains and setDomain(axisId, span | null) stand on what the hook returns. A lone x axis is "x".

Once an axis is zoomed, the chart offers 'Show all' over a corner of its plot - a button of its own, a tab stop beside the plot's, that brings every zoomed axis back and goes. Its words are the wording's showAll.

Charts stay in step by sending each other what the reader did: each chart's onViewChange calls the other's setView. setView is not reported, so two charts linked both ways do not answer each other, and a pan does not jump back. syncId shares only the pointer.

A zoom stops at zoomLimits; without them at three readings and at the data's extent. A y axis with domain="visible" follows the stretch shown.

Examples

Gestures and keys

With zoomable the x axis zooms on Ctrl or ⌘ with the wheel, a pinch or + and −, pans on a drag or Shift with the arrows, and goes back on a double click, 0 or 'Show all' - without a line of state of yours.

import { Tooltip, useChart } from "@umriss-ui/charts";

/* 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;

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;

const HOUR = 60 * MINUTE;

const DAY = 24 * HOUR;

/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);

/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;

/** A service's last week, Monday to Monday, one point every `step`
    milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
    95th percentile more than doubles and its errors climb. The hour is counted,
    not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): 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(2000 + index * 37);
  const n = Math.floor((7 * DAY) / step);
  const points = new Array<MetricPoint>(n);
  /* The drift wanders at the same pace whatever the step. */
  const keep = Math.pow(0.8, step / (5 * MINUTE));
  const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
  let drift = 0;
  for (let i = 0; i < n; i++) {
    const offset = i * step;
    const day = Math.floor(offset / DAY);
    const hour = (offset % DAY) / HOUR;
    const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
    drift = keep * drift + (r() - 0.5) * kick;
    const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
    const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
    points[i] = {
      t: LAST_WEEK + offset,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    };
  }
  return points;
}

const SEARCH_WEEK = week("search", 60_000);

export default function GesturesAndKeys() {
  const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK);
  return (
    <Chart height={260} ariaLabel="Search latency over last week, a reading a minute, zoomable">
      {/* The span shown is the chart's own: `domain` is where it starts. */}
      <XAxis value="t" time domain="data" zoomable />
      <YAxis label="ms" />
      <Line value="p95" name="p95" />
      <Tooltip mode="x" />
    </Chart>
  );
}

Limit the zoom

zoomLimits keeps the span between a narrowest and a widest; without it a zoom stops at three readings and at the data's extent.

import { Tooltip, useChart } from "@umriss-ui/charts";

/* 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;

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;

const HOUR = 60 * MINUTE;

const DAY = 24 * HOUR;

/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);

/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;

/** A service's last week, Monday to Monday, one point every `step`
    milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
    95th percentile more than doubles and its errors climb. The hour is counted,
    not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): 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(2000 + index * 37);
  const n = Math.floor((7 * DAY) / step);
  const points = new Array<MetricPoint>(n);
  /* The drift wanders at the same pace whatever the step. */
  const keep = Math.pow(0.8, step / (5 * MINUTE));
  const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
  let drift = 0;
  for (let i = 0; i < n; i++) {
    const offset = i * step;
    const day = Math.floor(offset / DAY);
    const hour = (offset % DAY) / HOUR;
    const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
    drift = keep * drift + (r() - 0.5) * kick;
    const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
    const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
    points[i] = {
      t: LAST_WEEK + offset,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    };
  }
  return points;
}

const SEARCH_WEEK = week("search", 60_000);

export default function ZoomLimits() {
  const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK);
  return (
    <Chart height={260} ariaLabel="Search latency over last week, zoomable between an hour and two days">
      {/* Never closer than an hour, never wider than two days - the week
          shows at the start and once more after a double click. */}
      <XAxis value="t" time domain="data" zoomable zoomLimits={{ min: HOUR, max: 2 * DAY }} />
      <YAxis label="ms" />
      <Line value="p95" name="p95" />
      <Tooltip mode="x" />
    </Chart>
  );
}

Fit the y axis to what is visible

With domain="visible" the y axis fits the zoomed stretch - here Saturday evening's outage, handed in as initialView - and follows when you zoom out.

import { Tooltip, useChart } from "@umriss-ui/charts";

/* 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;

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;

const HOUR = 60 * MINUTE;

const DAY = 24 * HOUR;

/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);

/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;

/** A service's last week, Monday to Monday, one point every `step`
    milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
    95th percentile more than doubles and its errors climb. The hour is counted,
    not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): 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(2000 + index * 37);
  const n = Math.floor((7 * DAY) / step);
  const points = new Array<MetricPoint>(n);
  /* The drift wanders at the same pace whatever the step. */
  const keep = Math.pow(0.8, step / (5 * MINUTE));
  const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
  let drift = 0;
  for (let i = 0; i < n; i++) {
    const offset = i * step;
    const day = Math.floor(offset / DAY);
    const hour = (offset % DAY) / HOUR;
    const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
    drift = keep * drift + (r() - 0.5) * kick;
    const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
    const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
    points[i] = {
      t: LAST_WEEK + offset,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    };
  }
  return points;
}

const SEARCH_WEEK = week("search", 60_000);
const SATURDAY = new Date(2026, 2, 14).getTime();

export default function VisibleDomain() {
  const { Chart, XAxis, YAxis, Line } = useChart(SEARCH_WEEK, {
    // The lone x axis is "x": its span is where the chart starts.
    initialView: { domains: { x: [SATURDAY + 16 * HOUR, SATURDAY + 22 * HOUR] } },
  });
  return (
    <Chart height={260} ariaLabel="Search latency on Saturday evening, the y axis fitted to it">
      <XAxis value="t" time domain="data" zoomable />
      <YAxis label="ms" domain="visible" />
      <Line value="p95" name="p95" />
      <Tooltip mode="x" />
    </Chart>
  );
}

Keep charts in step

Charts with one syncId share the pointer; send each one's onViewChange to the others' setView, and a zoom in any of them zooms all.

import { useEffect, useState } from "react";
import { Tooltip, useChart, type ChartView } from "@umriss-ui/charts";

/* 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;

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;

const HOUR = 60 * MINUTE;

const DAY = 24 * HOUR;

/** Monday, 9 March 2026, 00:00 - the start of last week. */
const LAST_WEEK = at(9, 0);

/** Search returned nothing for some regions (INC-1043): Saturday, 18:30 to 19:55. */
const SEARCH_OUTAGE = [at(14, 18, 30) - LAST_WEEK, at(14, 19, 55) - LAST_WEEK] as const;

/** A service's last week, Monday to Monday, one point every `step`
    milliseconds: busy by day, quieter at the weekend. During INC-1043 Search's
    95th percentile more than doubles and its errors climb. The hour is counted,
    not asked of a Date - a week of seconds is 604,800 points. */
function week(serviceId: string, step: number): 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(2000 + index * 37);
  const n = Math.floor((7 * DAY) / step);
  const points = new Array<MetricPoint>(n);
  /* The drift wanders at the same pace whatever the step. */
  const keep = Math.pow(0.8, step / (5 * MINUTE));
  const kick = 0.08 * Math.sqrt(1 - keep * keep) / 0.6;
  let drift = 0;
  for (let i = 0; i < n; i++) {
    const offset = i * step;
    const day = Math.floor(offset / DAY);
    const hour = (offset % DAY) / HOUR;
    const load = (day >= 5 ? 0.85 : 1) * (0.35 + 0.65 * Math.max(0, Math.sin((Math.PI * (hour - 5)) / 16)));
    drift = keep * drift + (r() - 0.5) * kick;
    const outage = serviceId === "search" && offset >= SEARCH_OUTAGE[0] && offset < SEARCH_OUTAGE[1];
    const p95 = Math.round(service.latencySlo * (0.45 + 0.2 * load + drift) * (outage ? 2.3 : 1) * (1 + r() * 0.08));
    points[i] = {
      t: LAST_WEEK + offset,
      p50: Math.round(p95 * (0.42 + r() * 0.06)),
      p95,
      errorRate: Math.round((0.05 + r() * 0.12 + (outage ? 3 : 0)) * 100) / 100,
      requests: Math.round(VOLUME[service.tier] * load * (1 + (r() - 0.5) * 0.1)),
    };
  }
  return points;
}

const IMAGES_WEEK = week("images", 5 * 60_000);

const CHANNELS = [
  { name: "Requests", unit: "/min", value: "requests" },
  { name: "p95", unit: "ms", value: "p95" },
  { name: "p50", unit: "ms", value: "p50" },
] as const;

export default function CursorSync() {
  // The three share a view: each sends the others what the reader did to it.
  const [peers] = useState(() => new Map<string, (view: ChartView) => void>());
  return (
    <div>
      {/* Each plot begins where its y axis ends: labels of one width - three
          figures here - keep the crosshairs in one column. */}
      {CHANNELS.map((c) => (
        <Channel key={c.name} channel={c} peers={peers} />
      ))}
    </div>
  );
}

function Channel({ channel: c, peers }: { channel: (typeof CHANNELS)[number]; peers: Map<string, (view: ChartView) => void> }) {
  const { Chart, XAxis, YAxis, Line, setView } = useChart(IMAGES_WEEK, {
    onViewChange: (view) => {
      for (const [name, goTo] of peers) if (name !== c.name) goTo(view);
    },
  });
  useEffect(() => {
    peers.set(c.name, setView);
    return () => void peers.delete(c.name);
  }, [peers, c.name, setView]);
  return (
    <Chart height={140} syncId="images" ariaLabel={`Image service ${c.name} over last week`}>
      <XAxis value="t" time domain="data" zoomable />
      <YAxis label={c.unit} domain="visible" tickCount={4} />
      <Line value={c.value} name={c.name} />
      <Tooltip mode="x" />
    </Chart>
  );
}

Keyboard

The keys of Chart apply here.

API

XAxisProps<T>

NameTypeDefaultDescription
valuerequiredAxisField<T> | ((d: T, index: number) => AxisValue)—Where a row lies on this axis: a field of the row, compared by its name, or a function of it, compared by its source text as a series' is (ValueFunction) - with the same closure limit. A number is a position, a Date an instant, a string a category: every distinct one gets a place of its own, in the order the rows bring them, a tick and its name as the label (ADR-0052). The type decides, never the content - "2024" is a category, 2024 a number - and a row without a value has no place. The axis reads the rows of every series bound to it; where those bring their own data, name their row: <XAxis<HourCount> value="hour" />.

Shown in: Gestures and keys, Limit the zoom, Fit the y axis to what is visible and 74 more

domain| "nice"
| "data"
| readonly [number, number]
| readonly [Date, Date]
"nice""nice" widens the data's extent to ticks - to named ticks where there are any, on a time axis to whole units of a step of hours or longer -, "data" keeps it, a pair is fixed: numbers, on a time axis milliseconds or two Dates, on a category axis places. On a zoomable axis it is the start: what the axis shows while the chart's view names no span for it.

Shown in: Gestures and keys, Limit the zoom, Fit the y axis to what is visible and 11 more

position"bottom" | "top""bottom"Which edge the axis stands at. A second x axis on the opposite edge is how a series counts in a unit of its own.

Shown in: Add axes for other magnitudes (Axis), Bind every kind of series to a second axis (Axis)

timeboolean—The values are instants: milliseconds since the epoch, or with time said ISO 8601 strings as an API sends them, read by the platform's rule - a date with a time and no offset is local time, a date alone is UTC; a string that is none is not drawn. Rows that hold a Date put the axis in time without time. Ticks on local boundaries from the minute to the month, labels by level in en-GB with a 24-hour clock - 15:00, 17 Mar, Mar 2026 - and the date on the first tick and on the first of a new day (17 Mar 00:00); a day tick there carries the year. The tooltip's x value carries the seconds where the readings lie less than a minute apart. Another language is a tickFormat, which is handed the instant in milliseconds. calendar implies it.

Shown in: Gestures and keys, Limit the zoom, Fit the y axis to what is visible and 36 more

calendarreadonly WorkingInterval[]—Working calendar: the intervals in which time counts. With it the axis stands in working time - nights and weekends are out, and every removed span gets a break mark. The scale stays affine; the mapping happens in materialisation (ADR-0001).

Shown in: Leave out the hours nobody works (Axis)

zoomablebooleanfalseZoom and pan: Ctrl or ⌘ with the wheel - and a pinch - zoom around the pointer, a drag, a horizontal wheel or Shift with the wheel pan, a double click goes back to domain; with the focus on the chart, + and − zoom, Shift with ← or → pans and 0 goes back. The span shown is part of the chart's view, by this axis' id (useChart's initialView, onViewChange, setView, setDomain). The plain wheel always scrolls the page.

Shown in: Gestures and keys, Limit the zoom, Fit the y axis to what is visible and 6 more

zoomLimitsZoomLimitsat most the data's extent or this axis' own domain, whichever is wider, at least three data stepsThe narrowest and the widest span zoom may reach, in the axis' units - milliseconds on a time axis.

Shown in: Limit the zoom

idstring"x"Axis id, through which series bind themselves.

Shown in: Add axes for other magnitudes (Axis), Align a second axis to the grid (Axis), Bind every kind of series to a second axis (Axis) and 7 more

labelstring—Axis title.

Shown in: Gestures and keys, Limit the zoom, Fit the y axis to what is visible and 66 more

tickCountnumber—Target value; the 1-2-5 algorithm may deviate.

Shown in: Keep charts in step, Fix the domain and name the ticks (Axis), Leave out the hours nobody works (Axis) and 8 more

tickFormat(v: number) => string—Label of a tick. Compared by its source text: one that reads a changed closure variable under the same text does not relabel the axis - give it a new text or remount. A bound Intl.NumberFormat#format is compared by identity.

Shown in: Fix the domain and name the ticks (Axis), Leave out the hours nobody works (Axis), Bind every kind of series to a second axis (Axis) and 21 more

gridbooleanonly on the first axis of its orientationGrid lines.

Shown in: One vehicle through the day (StateBand), Give each one a lane (StateBand), See where the vans stand idle (Scenarios)

ticksreadonly number[]—Fixed tick values instead of the 1-2-5 algorithm, on a numeric axis: an hour axis' named hours, a lane grid. An axis that reads categories needs none - every category has its tick.

Shown in: Bind every kind of series to a second axis (Axis), Mark bands, limits and cells (Chart), Compare progress with a plan (Line) and 7 more

categoriesreadonly string[]—The categories of the axis, in their order. Without it they stand in the order the rows first bring them. With it the list is the order and the extent: a category no row carries keeps its empty place, and a row whose category is not listed is not drawn - DEV names it. It makes the axis a category axis whatever the rows hold (ADR-0052).

Shown in: Fix the order and keep an empty place (Axis)

categoryLabel(category: string) => stringthe category itselfWhat is written for a category - under its tick, in the tooltip, the readout and the data table - where the rows hold a key and not a name. Two categories may share a label and keep two places. Compared by its source text, as tickFormat is.

Shown in: Fix the order and keep an empty place (Axis), Tell series apart without colour (Chart), Legend above or below (Legend) and 1 more

ChartOptions<Z = unknown>

NameTypeDefaultDescription
initialViewChartView—The view to start from, read once when the hook first runs; a different value on a later render is ignored, as React ignores a changed defaultValue. To go to a view later, call setView. The chart remembers none: where a view is kept is the application's decision.

Shown in: Fit the y axis to what is visible, Hide a series from the legend (Legend)

Events

NameTypeDefaultDescription
onViewChange(view: ChartView) => void—Every change the reader makes, once, always the whole view - the one to keep, or to hand another chart's setView. A pan or zoom is reported at most once per frame. Not called for the view the chart starts with, nor for one setView applies.

Shown in: Keep charts in step, Read it by keyboard and screen reader (Chart)

onSelect(hit: TooltipHit<Z>) => void—The reader chose a point: a click on a hit (a tap too), or Enter on the Active point - the same hit the tooltip's render receives. Once per click; the end of a drag does not report, and the second click of a double click does not either. The chart holds no selection and draws none: what is chosen is the application's state. Needs a <Tooltip>, which makes the hits; the hit does not travel over syncId.

Shown in: Choose a hit (Tooltip)

Types on this page

AxisField<T, V = AxisValue>

The fields of a row an axis can read - or, with V, those that hold only some of what an axis reads: a matrix' row is a position or a category, never an instant.

type AxisField<T, V = AxisValue> = NoInfer<
  {
    [K in keyof T & string]-?: T[K] extends V ? K : never;
  }[keyof T & string]
>;

AxisValue

What an axis reads from a row (ADR-0052): a number - a position, or an instant on a time axis -, a string, which is a category, or a Date, which is an instant. The type of the value decides what kind the axis is, never its content: "2024" is a category, 2024 a number. On an axis that says time a string is an ISO 8601 instant. null/undefined is no place.

type AxisValue = number | string | Date | null | undefined;

WorkingInterval

An interval in which time counts; thought of as half-open, but mappable from both sides at its edges: both from and to have a working time.

NameTypeDefaultDescription
fromrequirednumber—
torequirednumber—

ZoomLimits

The narrowest and the widest span zoom may reach, in the axis' units - the schedule's shape.

NameTypeDefaultDescription
minrequirednumber—

Shown in: Limit the zoom

maxrequirednumber—

Shown in: Limit the zoom

TooltipHit<T = unknown>

What the pointer hit: the x value under it and one point per series there. A custom tooltip's render receives it.

NameTypeDefaultDescription
xValuerequirednumber—

Shown in: Fix the domain and name the ticks (Axis), Write the tooltip yourself (Tooltip), Choose a hit (Tooltip)

pointsrequiredreadonly TooltipPoint<T>[]—

Shown in: Fix the domain and name the ticks (Axis), Write the tooltip yourself (Tooltip), Choose a hit (Tooltip)

xPxrequirednumber—Pixel position of the crosshair (snapped to the data point).
yPxrequirednumber—Pixel position of the primary hit.

TooltipPoint<T = unknown>

One hit per series in the tooltip.

NameTypeDefaultDescription
seriesNamerequiredstring—

Shown in: Fix the domain and name the ticks (Axis), Write the tooltip yourself (Tooltip), Choose a hit (Tooltip)

colorrequiredstring—
xValuerequirednumber—The point's x value on its own x axis - with several x axes it can differ from the hit's.
yValuerequirednumber—The point's y value; in a stack its own value, not its top - in a normalised stack its share in percent.

Shown in: Fix the domain and name the ticks (Axis), Choose a hit (Tooltip)

datumrequiredT—

Shown in: Write the tooltip yourself (Tooltip)

indexrequirednumber—
levelnumber—Only for the matrix: its level, out of the level channel. It stands here and not in yValue, because yValue is the position on the y axis - for a matrix therefore the row. Putting both into one field would be exactly the overloading ADR-0011 is written against.
segment{ from: number; to: number; label: string }—Only for state series: the section being pointed at. A duration does not stand here - it is the difference of two numbers the entry already carries, and formatting it would presuppose knowing what the x axis means. The caller knows that, not this library.
boxBoxNumbers & Partial<BoxExtras> & { /** As the caller gave them; only where the series has `outliers`. */ outliers?: readonly number[]; }—Only for a box: its further numbers; yValue is its median. Fields of their own for the reason level has one (ADR-0011).

BoxNumbers<V = number>

A box's further numbers beside its median, by name.

NameTypeDefaultDescription
lowerQuartilerequiredV—
upperQuartilerequiredV—
lowerWhiskerrequiredV—
upperWhiskerrequiredV—

BoxExtras<V = number>

The optional numbers of a box by name.

NameTypeDefaultDescription
meanrequiredV—
notchLowerrequiredV—
notchUpperrequiredV—
countrequiredV—

Known limits

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

Every page of @umriss-ui/charts

Getting started

Chart

Series

Limits and alarms

Around the chart

API index