Show a trace or a page's requests as bars on a shared time axis, with spans that expand to show what they started.

Use a Waterfall for anything with a start and a duration that nests: a browser's network requests, an OpenTelemetry trace, the steps of a build. Each span is a row with its own bar, placed on one time axis, and screen reader and keyboard users get a treegrid they can walk span by span.

import type { WaterfallData } from "@ui-sdk/core/waterfall"
import "@ui-sdk/web/waterfall"

const requests: WaterfallData = [
  { value: "document", label: "/", start: 0, duration: 182 },
  { value: "styles", label: "app.css", start: 190, duration: 96 },
  { value: "script", label: "app.js", start: 192, duration: 410 },
  { value: "font", label: "inter.woff2", start: 300, duration: 220 },
  { value: "user", label: "api/user", start: 610, duration: 240 },
  { value: "avatar", label: "avatar.png", start: 860, duration: 150 },
]

const sizes: Record<string, string> = {
  document: "14 kB",
  styles: "8 kB",
  script: "212 kB",
  font: "48 kB",
  user: "1 kB",
  avatar: "36 kB",
}

function mount(root: HTMLElement) {
  root.innerHTML = `
    <ui-waterfall aria-label="Network requests">
      <ui-waterfall-header-row>
        <ui-waterfall-column-header>Name</ui-waterfall-column-header>
        <ui-waterfall-column-header>Size</ui-waterfall-column-header>
        <ui-waterfall-column-header aria-label="Timeline">
          <ui-waterfall-axis max-ticks="5"></ui-waterfall-axis>
          <ui-waterfall-mark time="620">DOMContentLoaded</ui-waterfall-mark>
        </ui-waterfall-column-header>
      </ui-waterfall-header-row>
      ${requests
        .map(
          (request) => `
            <ui-waterfall-row value="${request.value}">
              <ui-waterfall-row-header>${request.label}</ui-waterfall-row-header>
              <ui-waterfall-cell>${sizes[request.value]}</ui-waterfall-cell>
              <ui-waterfall-bar></ui-waterfall-bar>
            </ui-waterfall-row>`
        )
        .join("")}
    </ui-waterfall>
  `
  const waterfall = root.querySelector("ui-waterfall")!
  waterfall.getBarLabel = (request, { start, duration }) =>
    `${sizes[request.value]}, starts at ${start}, takes ${duration}`
  waterfall.items = requests
}

export { mount }
Use the arrow keys to move between requests. Press Right to move into a row's cells, where each bar reads out when its request starts and how long it takes.

Installation

npm install @ui-sdk/web @ui-sdk/core

Importing the component defines its elements. On the server the import does nothing.

import "@ui-sdk/web/waterfall"

Composition

Use the following composition to build a ui-waterfall:

ui-waterfall
โ”œโ”€โ”€ ui-waterfall-header-row
โ”‚   โ”œโ”€โ”€ ui-waterfall-column-header
โ”‚   โ””โ”€โ”€ ui-waterfall-column-header
โ”‚       โ”œโ”€โ”€ ui-waterfall-axis
โ”‚       โ”‚   โ””โ”€โ”€ ui-waterfall-tick
โ”‚       โ””โ”€โ”€ ui-waterfall-mark
โ”œโ”€โ”€ ui-waterfall-row
โ”‚   โ”œโ”€โ”€ ui-waterfall-row-header
โ”‚   โ”‚   โ””โ”€โ”€ ui-waterfall-indicator
โ”‚   โ”œโ”€โ”€ ui-waterfall-cell
โ”‚   โ””โ”€โ”€ ui-waterfall-bar
โ””โ”€โ”€ ui-waterfall-row
    โ”œโ”€โ”€ ui-waterfall-row-header
    โ””โ”€โ”€ ui-waterfall-bar

Pass every span as items and write a ui-waterfall-row for each with the span's value. The rows are flat siblings: the waterfall reads each one's level, place among its siblings and children from items. Give the waterfall an accessible name.

ui-waterfall-row-header names the span, and holds the ui-waterfall-indicator of a span with children. ui-waterfall-bar is the cell on the time axis. ui-waterfall-cell is any other column, like a status or a size. The header row, the axis and marks are optional.

Usage

Each span has a value, a label, a start and a duration in milliseconds, and the spans it started as children. Set them as the items property:

<ui-waterfall aria-label="Network requests">
  <ui-waterfall-row value="document">
    <ui-waterfall-row-header>/</ui-waterfall-row-header>
    <ui-waterfall-bar></ui-waterfall-bar>
  </ui-waterfall-row>
  <ui-waterfall-row value="script">
    <ui-waterfall-row-header>app.js</ui-waterfall-row-header>
    <ui-waterfall-bar></ui-waterfall-bar>
  </ui-waterfall-row>
</ui-waterfall>

<script type="module">
  import "@ui-sdk/web/waterfall"

  document.querySelector("ui-waterfall").items = [
    { value: "document", label: "/", start: 0, duration: 182 },
    { value: "script", label: "app.js", start: 192, duration: 410 },
  ]
</script>

Every row sets --waterfall-start and --waterfall-size, where its span begins and how much of the axis it covers, as percentages. Place the bar with them in plain CSS, with no layout in JavaScript:

[data-slot="waterfall-bar"] {
  position: relative;
}

[data-slot="waterfall-bar"]::before {
  content: "";
  position: absolute;
  inset-block: 6px;
  inset-inline-start: var(--waterfall-start);
  width: var(--waterfall-size);
  background: steelblue;
}

With no ui-waterfall-row elements of its own, the waterfall renders a row for every span, with its header and bar, from a <template> inside it when it has one.

Network Requests

The first example adds a header row. ui-waterfall-axis renders a ui-waterfall-tick at round times, at most max-ticks of them, while it's left empty, and ui-waterfall-mark draws a moment across every row, like DOMContentLoaded, placed by --waterfall-mark. The axis is hidden from screen readers, who hear each bar's own times instead. The getBarLabel property changes what a bar says; this one adds the request's size.

Nested Spans

Spans with children expand and collapse. Click a span's indicator, or press Right and Left on its row. This example writes every row, and the waterfall hides the ones inside a collapsed span: rows it hides get hidden.

Clicking a row, or pressing Enter or Space on it, selects it. The value property and the valuechange event hold the selected span, like the one a details panel shows. The format property changes how times read, here as narrow milliseconds.

Console
import type { WaterfallData, WaterfallDataItem } from "@ui-sdk/core/waterfall"
import "@ui-sdk/web/waterfall"
import { ChevronRight, createElement } from "lucide"
import { log } from "./log"

const trace: WaterfallData = [
  {
    value: "checkout",
    label: "POST /checkout",
    start: 0,
    duration: 920,
    children: [
      { value: "auth", label: "auth.verify", start: 12, duration: 64 },
      {
        value: "cart",
        label: "cart.load",
        start: 80,
        duration: 310,
        children: [
          {
            value: "cart-db",
            label: "db.query carts",
            start: 96,
            duration: 140,
          },
          {
            value: "prices",
            label: "pricing.quote",
            start: 240,
            duration: 136,
          },
        ],
      },
      {
        value: "payment",
        label: "payments.charge",
        start: 400,
        duration: 470,
        children: [
          { value: "fraud", label: "fraud.score", start: 410, duration: 120 },
          {
            value: "gateway",
            label: "gateway.capture",
            start: 540,
            duration: 310,
          },
        ],
      },
      { value: "email", label: "email.enqueue", start: 880, duration: 30 },
    ],
  },
]

function rows(items: WaterfallDataItem[]): string {
  return items
    .map(
      (item) => `
        <ui-waterfall-row value="${item.value}">
          <ui-waterfall-row-header>
            ${
              item.children
                ? `<ui-waterfall-indicator>${createElement(ChevronRight, { "aria-hidden": "true" }).outerHTML}</ui-waterfall-indicator>`
                : ""
            }
            ${item.label}
          </ui-waterfall-row-header>
          <ui-waterfall-bar></ui-waterfall-bar>
        </ui-waterfall-row>
        ${rows(item.children ?? [])}`
    )
    .join("")
}

function mount(root: HTMLElement) {
  root.innerHTML = `
    <div data-slot="stack">
      <div data-slot="controls">
        <button type="button">Expand All</button>
        <button type="button">Collapse All</button>
      </div>
      <ui-waterfall aria-label="Checkout trace" default-expanded="checkout">
        <ui-waterfall-header-row>
          <ui-waterfall-column-header>Span</ui-waterfall-column-header>
          <ui-waterfall-column-header aria-label="Timeline">
            <ui-waterfall-axis></ui-waterfall-axis>
          </ui-waterfall-column-header>
        </ui-waterfall-header-row>
        ${rows(trace)}
      </ui-waterfall>
    </div>
  `
  const waterfall = root.querySelector("ui-waterfall")!
  const [expand, collapse] = root.querySelectorAll("button")
  waterfall.format = {
    style: "unit",
    unit: "millisecond",
    unitDisplay: "narrow",
  }
  waterfall.items = trace
  expand.addEventListener("click", () => waterfall.api.expandAll())
  collapse.addEventListener("click", () => waterfall.api.collapseAll())
  waterfall.addEventListener("valuechange", (event) =>
    log(root, `Selected ${event.detail.value}`)
  )
}

export { mount }
Expand payments.charge with Right, then press Enter on gateway.capture. The console logs the selected span. Collapse All closes every span.

To write only the rows that show, pass the expanded property to visibleWaterfallRows(items, expanded) on each expandedchange, which flattens the open spans in order. The Large Traces example does.

Zoom

The range property sets the stretch of time the axis shows, as [start, end]. Change it to zoom or pan: the bars and ticks follow, and a bar outside the range shrinks to its edge. Without it, the axis runs from the earliest start to the latest end.

Write ui-waterfall-tick elements in the axis for ticks of your own: an empty one shows its time.

import type { WaterfallData } from "@ui-sdk/core/waterfall"
import "@ui-sdk/web/waterfall"
import { ChevronRight, createElement } from "lucide"

const trace: WaterfallData = [
  {
    value: "render",
    label: "render /dashboard",
    start: 0,
    duration: 1200,
    children: [
      { value: "session", label: "session", start: 20, duration: 80 },
      { value: "metrics", label: "metrics", start: 110, duration: 640 },
      { value: "alerts", label: "alerts", start: 110, duration: 260 },
      { value: "html", label: "stream html", start: 760, duration: 420 },
    ],
  },
]

const spans = [trace[0], ...trace[0].children!]
const full: [number, number] = [0, 1200]

function zoomed([start, end]: [number, number], factor: number) {
  const center = (start + end) / 2
  const half = Math.min(((end - start) * factor) / 2, full[1] / 2)
  const from = Math.min(Math.max(full[0], center - half), full[1] - half * 2)
  return [from, from + half * 2] as [number, number]
}

function mount(root: HTMLElement) {
  root.innerHTML = `
    <div data-slot="stack">
      <div data-slot="controls">
        <button type="button">Zoom In</button>
        <button type="button">Zoom Out</button>
        <button type="button">Reset</button>
      </div>
      <ui-waterfall
        aria-label="Dashboard render"
        default-expanded="render"
        default-value="metrics"
      >
        <ui-waterfall-header-row>
          <ui-waterfall-column-header>Span</ui-waterfall-column-header>
          <ui-waterfall-column-header aria-label="Timeline">
            <ui-waterfall-axis></ui-waterfall-axis>
          </ui-waterfall-column-header>
        </ui-waterfall-header-row>
        ${spans
          .map(
            (span) => `
              <ui-waterfall-row value="${span.value}">
                <ui-waterfall-row-header>
                  ${
                    span.children
                      ? `<ui-waterfall-indicator>${createElement(ChevronRight, { "aria-hidden": "true" }).outerHTML}</ui-waterfall-indicator>`
                      : ""
                  }
                  ${span.label}
                </ui-waterfall-row-header>
                <ui-waterfall-bar></ui-waterfall-bar>
              </ui-waterfall-row>`
          )
          .join("")}
      </ui-waterfall>
    </div>
  `
  const waterfall = root.querySelector("ui-waterfall")!
  const [zoomIn, zoomOut, reset] = root.querySelectorAll("button")
  let range = full
  const show = (next: [number, number]) => {
    range = next
    waterfall.range = range
  }
  waterfall.items = trace
  show(full)
  zoomIn.addEventListener("click", () => show(zoomed(range, 0.5)))
  zoomOut.addEventListener("click", () => show(zoomed(range, 2)))
  reset.addEventListener("click", () => show(full))
}

export { mount }
Press Zoom In twice. The axis shows smaller steps, and the metrics span stretches past the edges.

Large Traces

A trace can have thousands of spans. Render only the rows in view: flatten the open spans with visibleWaterfallRows, and render the nearby rows at their offsets. The waterfall still reports every row's level and place among its siblings, so screen readers hear the whole hierarchy. Give the waterfall aria-rowcount with the number of rows that show, and each row aria-rowindex with its index plus one, so a screen reader counts rows in the whole trace, not just the ones in the page.

The highlight event reports the index of the row the keyboard moves to. Scroll it into view there, so a row that isn't rendered yet takes focus once it is. An arrow key changes only the two rows it moves between, however long the trace is.

import {
  visibleWaterfallRows,
  type WaterfallData,
} from "@ui-sdk/core/waterfall"
import "@ui-sdk/web/waterfall"
import { ChevronRight, createElement } from "lucide"

const trace: WaterfallData = Array.from({ length: 2000 }, (_, job) => ({
  value: `job-${job + 1}`,
  label: `job ${job + 1}`,
  start: job * 4,
  duration: 60,
  children: Array.from({ length: 4 }, (_, step) => ({
    value: `job-${job + 1}/step-${step + 1}`,
    label: `step ${step + 1}`,
    start: job * 4 + step * 15,
    duration: 15,
  })),
}))

const row = 32
const height = 288

function mount(root: HTMLElement) {
  root.innerHTML = `
    <div data-slot="scroll">
      <ui-waterfall aria-label="Build trace" default-expanded="job-1"></ui-waterfall>
    </div>
  `
  const scroller = root.querySelector("div")!
  const waterfall = root.querySelector("ui-waterfall")!

  let rows = visibleWaterfallRows(trace, ["job-1"])

  const rendered = new Map<string, HTMLElement>()
  const render = () => {
    waterfall.style.height = `${rows.length * row}px`
    waterfall.setAttribute("aria-rowcount", String(rows.length))
    const start = Math.max(0, Math.floor(scroller.scrollTop / row) - 4)
    const end = Math.min(
      rows.length,
      Math.ceil((scroller.scrollTop + height) / row) + 4
    )
    const visible = new Map(
      rows.slice(start, end).map((entry) => [entry.item.value, entry])
    )
    for (const [value, el] of rendered) {
      if (visible.has(value)) {
        continue
      }
      el.remove()
      rendered.delete(value)
    }
    for (const [value, { item, index }] of visible) {
      let el = rendered.get(value)
      if (!el) {
        waterfall.insertAdjacentHTML(
          "beforeend",
          `<ui-waterfall-row value="${item.value}">
            <ui-waterfall-row-header>
              ${
                item.children
                  ? `<ui-waterfall-indicator>${createElement(ChevronRight, { "aria-hidden": "true" }).outerHTML}</ui-waterfall-indicator>`
                  : ""
              }
              ${item.label}
            </ui-waterfall-row-header>
            <ui-waterfall-bar></ui-waterfall-bar>
          </ui-waterfall-row>`
        )
        el = waterfall.lastElementChild as HTMLElement
        rendered.set(value, el)
      }
      el.style.top = `${index * row}px`
      el.setAttribute("aria-rowindex", String(index + 1))
    }
  }
  render()
  waterfall.range = [0, 400]
  waterfall.items = trace
  scroller.addEventListener("scroll", render)
  waterfall.addEventListener("expandedchange", (event) => {
    rows = visibleWaterfallRows(trace, event.detail.value)
    render()
  })
  waterfall.addEventListener("highlight", (event) => {
    const { index } = event.detail
    const top = index * row
    if (top < scroller.scrollTop) {
      scroller.scrollTop = top
    } else if (top + row > scroller.scrollTop + scroller.clientHeight) {
      scroller.scrollTop = top + row - scroller.clientHeight
    }
    render()
  })
}

export { mount }
Press End to focus job 2000, or Page Down to move a page at a time. The trace has 10,000 spans, but only nearby rows render.

Controlled

Selection and expanded spans are independent:

ValueShapeChange Notification
valueA string or null.valuechange
expandedAn array of span values.expandedchange

Read or set these properties on the element, and listen to their change events, or use default-value and default-expanded for starting state. Call event.preventDefault() in a change event to reject a change. State and Events covers the shared binding pattern.

Controller

The element's api can expand or collapse spans, change the selection, or focus a row. reveal(value) opens a span's ancestors and focuses its row. The Nested Spans example calls api.expandAll() and api.collapseAll() for Expand All and Collapse All. See Controllers for setup and lifecycle.

Custom Rendering

Elements have no render functions. Bind a part to an element of your own with as-child, or put anything inside it and show it from the part's data attributes. See Parts and Composition for binding your own element.

Styling

Style a row's state from its data-selected, data-expanded, data-branch and data-level, which its header, indicator, cells and bar carry too. Focus moves between rows and cells, so style a focused one with :focus-visible. Indent a row's header with --waterfall-level, and place its bar with --waterfall-start and --waterfall-size.

Each part has a data-slot, such as waterfall-row, and its state attributes are listed under Data Attributes.

<ui-waterfall-tick>

VariableWhat it holds
--waterfall-tickWhere the tick's time sits on the axis, as a percentage. Use it for inset-inline-start.

<ui-waterfall-mark>

VariableWhat it holds
--waterfall-markWhere the mark's time sits on the axis, as a percentage. Use it for inset-inline-start.

<ui-waterfall-row>

VariableWhat it holds
--waterfall-levelThe span's depth, from 1 at the top level. Use it to indent the row's header.
--waterfall-startWhere the span starts on the axis, as a percentage, clamped to the range. Use it for the bar's inset-inline-start.
--waterfall-sizeHow much of the axis the span covers, as a percentage. Use it for the bar's width. Zero for a span outside the range.

Data Attributes

<ui-waterfall-mark>

[data-slot="waterfall-mark"]

AttributeWhat it means
data-outsidePresent while the mark's time is outside the range, so it doesn't show on the axis.

<ui-waterfall-row>

[data-slot="waterfall-row"]

AttributeWhat it means
data-level12The span's depth, from 1 at the top level. Also in the --waterfall-level variable.
data-branchPresent on a span with children.
data-expandedPresent while a span with children is expanded.
data-selectedPresent on the selected span's row and its parts.

<ui-waterfall-row-header>

[data-slot="waterfall-row-header"]

AttributeWhat it means
data-level12The span's depth, from 1 at the top level. Also in the --waterfall-level variable.
data-branchPresent on a span with children.
data-expandedPresent while a span with children is expanded.
data-selectedPresent on the selected span's row and its parts.

<ui-waterfall-indicator>

[data-slot="waterfall-indicator"]

AttributeWhat it means
data-level1The span's depth, from 1 at the top level. Also in the --waterfall-level variable.
data-branchPresent on a span with children.
data-expandedPresent while a span with children is expanded.

<ui-waterfall-cell>

[data-slot="waterfall-cell"]

AttributeWhat it means
data-level12The span's depth, from 1 at the top level. Also in the --waterfall-level variable.
data-branchPresent on a span with children.
data-expandedPresent while a span with children is expanded.
data-selectedPresent on the selected span's row and its parts.

<ui-waterfall-bar>

[data-slot="waterfall-bar"]

AttributeWhat it means
data-level12The span's depth, from 1 at the top level. Also in the --waterfall-level variable.
data-branchPresent on a span with children.
data-expandedPresent while a span with children is expanded.
data-selectedPresent on the selected span's row and its parts.

API Reference

Props are attributes in kebab case, like default-value and max-ticks. Lists, like expanded, are values separated by commas. items, range, format, getBarLabel and controller are properties. Callbacks are DOM events on <ui-waterfall> that don't bubble, with the new state in event.detail.value and the reason and event beside it.

Waterfall

itemsWaterfallData
The spans, each with a value, a label, a start, a duration and the spans it started as children.
range?[number, number]
The stretch of time the axis shows, as [start, end] in milliseconds. Bars outside it shrink to its edges. Change it to zoom or pan.
value?string | null
The selected span's value. Pass it to control the selection. null selects nothing.
defaultValue?string | null
The span selected at first, when uncontrolled.
valuechange event
Runs when the selection wants to change. details.reason says why, and details.cancel() stops it.
expanded?string[]
The expanded spans' values. Pass it to control which spans show their children.
defaultExpanded?string[]
The spans expanded at first, when uncontrolled.
expandedchange event
Runs when spans want to expand or collapse. details.reason says why, and details.cancel() stops it.
highlight event
Runs when the keyboard moves focus to a row, with its place among the rows that show, from 0. A waterfall that renders only the rows in view scrolls that row into view here, so it can take focus once it renders.
format?NumberFormat
How times read on the axis and to screen readers: Intl.NumberFormat options for a number of milliseconds, or a function of it.
locale?string
The locale times are formatted in. Defaults to the page's lang, or the browser's language without one, once the page is interactive. Server markup uses English until then, so it never disagrees with the client.
getBarLabel?((item: WaterfallDataItem, details: WaterfallBarLabelDetails) => string)
What screen readers hear for a span's bar. Set it in the page's language.
controller?object
Drives the waterfall from anywhere. Create one with createWaterfallController().
debug?boolean
Logs every state change with its reason to the console, in development builds.

WaterfallAxis

maxTicks?number
The most ticks the axis shows. Ticks fall on round times, so it may show fewer.

WaterfallTick and WaterfallMark

timenumber
The moment it marks, in milliseconds.

WaterfallRow

valuestring
The value of the span the row shows.

Items as Data

Each span in items:

valuestring
Identifies the span. Unique in the waterfall.
labelstring
What its row shows, and what typeahead matches.
startnumber
When it starts, in milliseconds, on the same clock as every other span.
durationnumber
How long it lasts, in milliseconds.
children?WaterfallDataItem[]
The spans it started. Leave it out for a span with none.

Each row visibleWaterfallRows(items, expanded) returns:

itemWaterfallDataItem
The span this row shows, the same object you passed in.
parentstring | null
The value of the span it's nested in, or null at the top level.
levelnumber
Its depth, 1 at the top level.
indexnumber
Its place among the rows that show, from 0.

State

The same state shows as data attributes on each element.

Waterfall:

range[number, number]
The stretch of time the axis shows, as [start, end] in milliseconds.

ui-waterfall-axis:

ticksWaterfallTickData[]
The ticks it shows, at round times inside the range.

ui-waterfall-tick and ui-waterfall-mark:

timenumber
The moment it marks, in milliseconds.
labelstring
The time as format and the locale show it.
positionnumber
Where it sits between the range's start and end, from 0 to 100.
outsideboolean
Whether its time is outside the range, so it doesn't show on the axis.

ui-waterfall-row, and its ui-waterfall-row-header, ui-waterfall-indicator, ui-waterfall-cell and ui-waterfall-bar:

expandedboolean
Whether it's a span with children that's expanded.
selectedboolean
Whether it's the selected span.
branchboolean
Whether its span has children.
levelnumber
Its depth, 1 at the top level.

Reasons

Why spans expanded or collapsed:

trigger-pressMouseEvent | KeyboardEvent | TouchEvent
Click, tap, Enter or Space on the part that opens, closes or toggles. Arrow keys that open a menu or submenu count too. A tap on a combobox's input that opens a sheet reports its touchend.
list-navigationKeyboardEvent
Arrow, Home or End keys moved the selection, typing picked an item, or Ctrl or โŒ˜ with A selected every item of a listbox.
imperativenone
A controller or api call, or a property set on a web component.

Why the selection changed:

imperativenone
A controller or api call, or a property set on a web component.
item-pressMouseEvent | KeyboardEvent
A member of a collection was chosen: a menu item, a tab, a radio or a checkbox item.

Controller

The element's api property has the same methods.

getValue()() => string | null
The selected span's value, or null.
setValue(value, options?)(value: string | null, options?: WaterfallChangeOptions<WaterfallValueChangeReason> | undefined) => boolean
Selects a span, or nothing with null. Returns false when canceled.
getExpanded()() => string[]
The values of the expanded spans.
setExpanded(expanded, options?)(expanded: string[], options?: WaterfallChangeOptions<WaterfallExpandedChangeReason> | undefined) => boolean
Expands exactly these spans. Returns false when canceled.
isExpanded(value)(value: string) => boolean
Whether a span is expanded.
expand(value, options?)(value: string, options?: WaterfallChangeOptions<WaterfallExpandedChangeReason> | undefined) => boolean
Expands a span.
collapse(value, options?)(value: string, options?: WaterfallChangeOptions<WaterfallExpandedChangeReason> | undefined) => boolean
Collapses a span. Focus inside it moves to its row.
toggle(value, options?)(value: string, options?: WaterfallChangeOptions<WaterfallExpandedChangeReason> | undefined) => boolean
Expands a collapsed span, or collapses an expanded one.
expandAll(options?)(options?: WaterfallChangeOptions<WaterfallExpandedChangeReason> | undefined) => boolean
Expands every span with children.
collapseAll(options?)(options?: WaterfallChangeOptions<WaterfallExpandedChangeReason> | undefined) => boolean
Collapses every span.
reveal(value)(value: string) => void
Expands a span's ancestors and focuses its row, through onHighlight when it isn't rendered.
focus(value?)(value?: string | undefined) => void
Focuses a span's row, or the row with the tab stop.

Accessibility

The waterfall follows the WAI-ARIA treegrid pattern. Rows have the row role with aria-level, aria-posinset, aria-setsize, aria-selected="true" on the selected row, and aria-expanded on spans with children. ui-waterfall-row-header is a rowheader, so screen readers repeat the span's name as focus moves down a column, and each ui-waterfall-bar is a gridcell labeled with when its span starts and how long it lasts, like "Starts at 120 ms, lasts 340 ms". Times are formatted with Intl in the page's language.

Keyboard

The waterfall has one tab stop. Tab enters at the selected row, or the first row, then returns to the row or cell focused last.

KeyAction
Down / Up ArrowFocus the next or previous row. In a cell, keep the column.
Right ArrowOn a row, expand it, or move into its first cell. In a cell, move right.
Left ArrowIn a cell, move left, then back to the row. On a row, collapse it, or focus its parent.
Home / EndFocus the first or last row. In a cell, the row's first or last cell.
Ctrl+Home / Ctrl+EndFocus the first or last row, keeping the column.
Page Up / Page DownMove a page of rows.
Enter / SpaceSelect the row.
TypingFocus the next row whose label starts with that text.

Command replaces Ctrl on a Mac. In right-to-left text, Left and Right swap.

Tested Behavior

Every line below is an automated test.

Rows
  • Spans start collapsed, and only top-level rows show.
  • Clicking the indicator expands a span without selecting it.
  • Clicking a row's cell selects the row and focuses the cell.
  • A change can be canceled.
  • A controller expands and collapses everything, and reveals a span.
  • Collapsing a span from its indicator moves focus out of it.
Keyboard
  • The waterfall is one tab stop.
  • Down and Up move between rows, Home and End to the first and last.
  • Right expands a collapsed row, then moves into its cells.
  • Left moves back through the cells to the row, collapses it, then moves to the parent.
  • Down and Up in a cell keep its column.
  • Home and End in a cell go to the row's ends, and with Control to the column's.
  • A focused cell keeps the tab stop.
  • A cell hidden by a collapse gives up the tab stop.
  • Enter and Space select the focused row.
  • Typing moves to the next row whose name starts with it.
Time axis
  • Each row places its bar as a share of the range.
  • Each bar tells screen readers when its span starts and how long it lasts.
  • The axis shows round times, hidden from screen readers.

Runs in React, Vue and web components.