Data

Tree table

A table whose rows nest and open in place, for budgets, folders and plans with columns of figures.

Built on
<table>ARIA treegridRoving tabindexposition: sticky
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
4.5 kB
Dependencies
React
TeamCost
Web$4,120
CDN$1,840
App servers$2,280
Code
"use client";

import { TreeTable } from "@/components/equip/tree-table";

type Cost = { id: string; name: string; cost: number; children?: Cost[] };

const costs: Cost[] = [
  {
    id: "web",
    name: "Web",
    cost: 4120,
    children: [
      { id: "web-cdn", name: "CDN", cost: 1840 },
      { id: "web-app", name: "App servers", cost: 2280 },
    ],
  },
  { id: "data", name: "Data", cost: 6310, children: [{ id: "data-db", name: "Database", cost: 6310 }] },
];

const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });

export default function Demo() {
  return (
    <div style={{ width: 224 }}>
      <TreeTable
        label="Cloud costs"
        dense
        rows={costs}
        rowKey={(c) => c.id}
        getChildren={(c) => c.children}
        defaultExpanded={["web"]}
        columns={[
          { key: "name", header: "Team" },
          { key: "cost", header: "Cost", align: "end", width: 80, render: (c) => usd.format(c.cost) },
        ]}
      />
    </div>
  );
}

Examples

Budget by team

Rows start closed. Arrow Right opens a row, Arrow Left closes it or moves to its parent, and * opens every sibling.

Cost centreOwnerBudgetSpent
SupportAna Silva$9,000$8,420
Code
"use client";

import { TreeTable } from "@/components/equip/tree-table";

type Line = { id: string; name: string; owner: string; budget: number; spent: number; children?: Line[] };

const lines: Line[] = [
  {
    id: "platform",
    name: "Platform",
    owner: "Ines Duarte",
    budget: 48000,
    spent: 41250,
    children: [
      { id: "platform-compute", name: "Compute", owner: "Ines Duarte", budget: 30000, spent: 27900 },
      { id: "platform-storage", name: "Storage", owner: "Theo Brandt", budget: 12000, spent: 9180 },
      { id: "platform-network", name: "Network egress", owner: "Theo Brandt", budget: 6000, spent: 4170 },
    ],
  },
  {
    id: "growth",
    name: "Growth",
    owner: "Mara Okafor",
    budget: 22000,
    spent: 23640,
    children: [
      {
        id: "growth-ads",
        name: "Paid search",
        owner: "Mara Okafor",
        budget: 15000,
        spent: 17320,
        children: [
          { id: "growth-ads-brand", name: "Brand terms", owner: "Leo Park", budget: 5000, spent: 4810 },
          { id: "growth-ads-generic", name: "Generic terms", owner: "Leo Park", budget: 10000, spent: 12510 },
        ],
      },
      { id: "growth-events", name: "Events", owner: "Mara Okafor", budget: 7000, spent: 6320 },
    ],
  },
  { id: "support", name: "Support", owner: "Ana Silva", budget: 9000, spent: 8420 },
];

const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 });

export default function Example() {
  return (
    <TreeTable
      label="Quarterly budget by team"
      rows={lines}
      rowKey={(l) => l.id}
      getChildren={(l) => l.children}
      columns={[
        { key: "name", header: "Cost centre" },
        { key: "owner", header: "Owner", width: 120 },
        { key: "budget", header: "Budget", align: "end", width: 96, render: (l) => usd.format(l.budget) },
        {
          key: "spent",
          header: "Spent",
          align: "end",
          width: 96,
          render: (l) => (
            <span style={{ color: l.spent > l.budget ? "var(--eq-danger)" : undefined }}>{usd.format(l.spent)}</span>
          ),
        },
      ]}
      style={{ minWidth: 520 }}
    />
  );
}

Folder sizes, dense and scrolling

With maxHeight the body scrolls under a sticky header. Each row states its level, position and set size to screen readers.

FolderFilesSize
src2141,842 kB
components96712 kB
forms31248 kB
layout1896 kB
charts47368 kB
routes64590 kB
lib54539 kB
public386,210 kB
fonts81,120 kB
images305,090 kB
tests121403 kB
scripts932 kB
Code
"use client";

import { TreeTable } from "@/components/equip/tree-table";

type Entry = { path: string; name: string; files: number; bytes: number; children?: Entry[] };

const tree: Entry[] = [
  {
    path: "src",
    name: "src",
    files: 214,
    bytes: 1_842_000,
    children: [
      {
        path: "src/components",
        name: "components",
        files: 96,
        bytes: 712_400,
        children: [
          { path: "src/components/forms", name: "forms", files: 31, bytes: 248_100 },
          { path: "src/components/layout", name: "layout", files: 18, bytes: 96_300 },
          { path: "src/components/charts", name: "charts", files: 47, bytes: 368_000 },
        ],
      },
      { path: "src/routes", name: "routes", files: 64, bytes: 590_200 },
      { path: "src/lib", name: "lib", files: 54, bytes: 539_400 },
    ],
  },
  {
    path: "public",
    name: "public",
    files: 38,
    bytes: 6_210_000,
    children: [
      { path: "public/fonts", name: "fonts", files: 8, bytes: 1_120_000 },
      { path: "public/images", name: "images", files: 30, bytes: 5_090_000 },
    ],
  },
  { path: "tests", name: "tests", files: 121, bytes: 402_800 },
  { path: "scripts", name: "scripts", files: 9, bytes: 31_700 },
];

const size = new Intl.NumberFormat("en-US", { style: "unit", unit: "kilobyte", maximumFractionDigits: 0 });

export default function Example() {
  return (
    <TreeTable
      label="Repository size by folder"
      dense
      maxHeight={220}
      rows={tree}
      rowKey={(e) => e.path}
      getChildren={(e) => e.children}
      defaultExpanded={["src", "src/components", "public"]}
      columns={[
        { key: "name", header: "Folder", render: (e) => <span style={{ fontFamily: "var(--eq-font-mono)" }}>{e.name}</span> },
        { key: "files", header: "Files", align: "end", width: 72 },
        { key: "bytes", header: "Size", align: "end", width: 104, render: (e) => size.format(e.bytes / 1000) },
      ]}
      style={{ minWidth: 360 }}
    />
  );
}

Controlled expansion

expanded and onExpandedChange put the open rows in your state, so buttons outside the table can open or close everything.

TaskStatusHours
BuildIn progress52
Payments APIIn progress30
Checkout screensNot started22
Code
"use client";

import { useState } from "react";
import { Button } from "@/components/equip/button";
import { TreeTable } from "@/components/equip/tree-table";

type Task = { id: string; name: string; status: string; hours: number; children?: Task[] };

const plan: Task[] = [
  {
    id: "design",
    name: "Design review",
    status: "Done",
    hours: 14,
    children: [
      { id: "design-flows", name: "Checkout flows", status: "Done", hours: 8 },
      { id: "design-copy", name: "Error copy", status: "Done", hours: 6 },
    ],
  },
  {
    id: "build",
    name: "Build",
    status: "In progress",
    hours: 52,
    children: [
      { id: "build-api", name: "Payments API", status: "In progress", hours: 30 },
      { id: "build-ui", name: "Checkout screens", status: "Not started", hours: 22 },
    ],
  },
  {
    id: "launch",
    name: "Launch",
    status: "Not started",
    hours: 10,
    children: [{ id: "launch-notes", name: "Release notes", status: "Not started", hours: 3 }, { id: "launch-rollout", name: "Staged rollout", status: "Not started", hours: 7 }],
  },
];

const parents = plan.map((t) => t.id);

export default function Example() {
  const [open, setOpen] = useState<string[]>(["build"]);
  return (
    <div style={{ display: "grid", gap: 8, maxWidth: 480 }}>
      <div style={{ display: "flex", gap: 8 }}>
        <Button size="s" variant="outline" onClick={() => setOpen(parents)}>
          Expand all
        </Button>
        <Button size="s" variant="outline" onClick={() => setOpen([])}>
          Collapse all
        </Button>
      </div>
      <TreeTable
        label="Checkout project plan"
        rows={plan}
        rowKey={(t) => t.id}
        getChildren={(t) => t.children}
        expanded={open}
        onExpandedChange={setOpen}
        columns={[
          { key: "name", header: "Task" },
          { key: "status", header: "Status", width: 112 },
          { key: "hours", header: "Hours", align: "end", width: 72 },
        ]}
        style={{ minWidth: 400 }}
      />
    </div>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the treegrid for assistive tech.
columns{ key: string; header: string; align?: "start" | "end"; width?: number; render?: (row: T) => ReactNode }[]noneColumn definitions. The first column holds the indent and the chevron. Without render a cell shows the row's value for key.
rowsT[]noneThe top level rows.
rowKey(row: T) => stringnoneA stable key per row, unique across every level.
getChildren(row: T) => T[] | undefinednoneA row's children. A row with a non-empty array gets a chevron and aria-expanded.
expandedstring[]noneControlled keys of the open rows.
defaultExpandedstring[]noneKeys of the rows open at first, when uncontrolled.
onExpandedChange(keys: string[]) => voidnoneCalled with the new open keys.
maxHeightnumber | stringnoneCaps the height and scrolls the body under a sticky header.
densebooleanfalse28px rows, tighter padding and a 16px indent step.
...restComponentProps<"table">noneEvery native table attribute, on the table inside the scroll area.

Anatomy

scroll area.eq-tree-table-scrollBordered frame that scrolls both ways; holds the density variables.
root.eq-tree-tableThe table, role treegrid.
head.eq-tree-table-head
body.eq-tree-table-body
row.eq-tree-table-rowFocusable; carries aria-level, aria-posinset, aria-setsize and aria-expanded.
header.eq-tree-table-headerSticky column header.
cell.eq-tree-table-cell
content.eq-tree-table-contentFirst cell's inner line; indented by the row's --_level.
toggle.eq-tree-table-toggleClick target for the chevron, or the empty indent on a leaf. Hidden from assistive tech.
chevron.eq-tree-table-chevronTurns on the spring as the row opens.
text.eq-tree-table-text

Keyboard

ArrowDownArrowUpMoves to the next or previous visible row, keeping to the row or to the same column.
ArrowRightOn a closed row, opens it; on an open row or a leaf, moves into its first cell; on a cell, moves to the next cell.
ArrowLeftOn an open row, closes it; on a closed row, moves to its parent; on a cell, moves left, and from the first cell back to the row.
HomeEndOn a row, moves to the first or last row; on a cell, to the first or last cell of the row.
Ctrl+HomeCtrl+EndMoves to the first or last row, keeping the column.
EnterOpens or closes the focused row, or the row of a focused first cell.
*Opens every sibling of the focused row.

Notes

  • Follows the ARIA treegrid pattern: rows and cells both take focus, with one tab stop shared by all of them.
  • Only rows with children get aria-expanded; closed children are not rendered, and aria-rowindex counts visible rows.
  • If a branch closes around the focused row, focus moves to the row that closed.
  • The chevron is decorative for assistive tech, which reads the state from the row; pointers can still click it.
  • Cells are for display content: buttons or links inside a cell are not part of the arrow key model.
  • Totals on parent rows are your data, so the table never adds up children for you.

Source

Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.

components/equip/tree-table.tsx332 lines
"use client";

import { useEffect, useMemo, useRef, useState } from "react";
import type { ComponentProps, CSSProperties, FocusEvent, KeyboardEvent, MouseEvent, ReactNode } from "react";
import "./tree-table.css";

export type TreeTableColumn<T> = {
  key: string;
  header: string;
  align?: "start" | "end";
  /** Width in pixels. Columns without one share the remaining space. */
  width?: number;
  /** Renders the cell. Without it the cell shows the row's value for key. */
  render?: (row: T) => ReactNode;
};

export type TreeTableProps<T> = Omit<ComponentProps<"table">, "children"> & {
  /** Names the treegrid for assistive tech. */
  label: string;
  /** The first column holds the indent and the expand chevron. */
  columns: TreeTableColumn<T>[];
  /** The top level rows. */
  rows: T[];
  rowKey: (row: T) => string;
  /** A row's children. Rows that return a non-empty array can be expanded. */
  getChildren: (row: T) => T[] | undefined;
  expanded?: string[];
  defaultExpanded?: string[];
  onExpandedChange?: (keys: string[]) => void;
  /** Caps the height and scrolls the body under a sticky header. */
  maxHeight?: number | string;
  dense?: boolean;
};

type Line<T> = {
  row: T;
  key: string;
  level: number;
  setsize: number;
  posinset: number;
  /** Index of the parent in the visible list, or -1 at the top level. */
  parentIndex: number;
  hasChildren: boolean;
};

type Node<T> = { row: T; parent: string | null };

function valueOf(row: object, key: string): unknown {
  return (row as Record<string, unknown>)[key];
}

function clamp(n: number, min: number, max: number) {
  return Math.min(max, Math.max(min, n));
}

export function TreeTable<T extends object>({
  label,
  columns,
  rows,
  rowKey,
  getChildren,
  expanded,
  defaultExpanded,
  onExpandedChange,
  maxHeight,
  dense = false,
  className,
  onFocus: onFocusProp,
  onKeyDown: onKeyDownProp,
  ...rest
}: TreeTableProps<T>) {
  const rootRef = useRef<HTMLDivElement>(null);
  const refocus = useRef(false);
  const [innerExpanded, setInnerExpanded] = useState<string[]>(defaultExpanded ?? []);
  // col -1 is the row itself; 0 and up are its cells.
  const [focus, setFocus] = useState<{ key: string | null; col: number }>({ key: null, col: -1 });

  const openKeys = expanded ?? innerExpanded;
  const open = useMemo(() => new Set(openKeys), [openKeys]);

  // Every node, open or not, so a hidden focus can climb to the nearest visible parent.
  const nodes = new Map<string, Node<T>>();
  (function index(list: T[], parent: string | null) {
    for (const row of list) {
      const key = rowKey(row);
      nodes.set(key, { row, parent });
      const kids = getChildren(row);
      if (kids?.length) index(kids, key);
    }
  })(rows, null);

  const visible: Line<T>[] = [];
  (function walk(list: T[], level: number, parentIndex: number) {
    list.forEach((row, i) => {
      const key = rowKey(row);
      const kids = getChildren(row);
      const hasChildren = !!kids && kids.length > 0;
      const at = visible.length;
      visible.push({ row, key, level, setsize: list.length, posinset: i + 1, parentIndex, hasChildren });
      if (hasChildren && open.has(key)) walk(kids, level + 1, at);
    });
  })(rows, 1, -1);

  const lastRow = visible.length - 1;
  const lastCol = columns.length - 1;
  const where = new Map(visible.map((v, i) => [v.key, i]));

  // Resolve the tab stop: the focused row, or its nearest visible ancestor when a branch closed over it.
  let fr = focus.key == null ? 0 : (where.get(focus.key) ?? -1);
  let fc = focus.col;
  if (fr === -1) {
    let up = focus.key == null ? null : (nodes.get(focus.key)?.parent ?? null);
    while (up != null && !where.has(up)) up = nodes.get(up)?.parent ?? null;
    fr = up == null ? 0 : (where.get(up) ?? 0);
    fc = -1;
  }
  fc = clamp(fc, -1, lastCol);

  useEffect(() => {
    if (!refocus.current) return;
    refocus.current = false;
    rootRef.current?.querySelector<HTMLElement>(`[data-tt="${fr}:${fc}"]`)?.focus();
  });

  function commit(next: string[]) {
    if (expanded === undefined) setInnerExpanded(next);
    onExpandedChange?.(next);
  }

  function setOpen(keys: string[], value: boolean) {
    const next = new Set(openKeys);
    for (const k of keys) {
      if (value) next.add(k);
      else next.delete(k);
    }
    commit([...next]);
  }

  function isInside(key: string, ancestor: string) {
    let up = nodes.get(key)?.parent ?? null;
    while (up != null) {
      if (up === ancestor) return true;
      up = nodes.get(up)?.parent ?? null;
    }
    return false;
  }

  function toggle(key: string) {
    const closing = open.has(key);
    // A pointer can close a branch around the focused row; focus then lands on the branch.
    const active = document.activeElement;
    const at = active instanceof HTMLElement && rootRef.current?.contains(active) ? active.closest<HTMLElement>("[data-tt]")?.dataset.tt : undefined;
    const current = at ? visible[Number(at.split(":")[0])]?.key : undefined;
    if (closing && current && isInside(current, key)) {
      refocus.current = true;
      setFocus({ key, col: -1 });
    }
    setOpen([key], !closing);
  }

  function go(r: number, c: number) {
    if (visible.length === 0) return;
    const row = clamp(r, 0, lastRow);
    const col = clamp(c, -1, lastCol);
    setFocus({ key: visible[row].key, col });
    rootRef.current?.querySelector<HTMLElement>(`[data-tt="${row}:${col}"]`)?.focus();
  }

  function onFocus(e: FocusEvent<HTMLTableElement>) {
    onFocusProp?.(e);
    const at = (e.target as HTMLElement).dataset?.tt;
    if (!at) return;
    const [r, c] = at.split(":").map(Number);
    const key = visible[r]?.key;
    if (key && (key !== focus.key || c !== focus.col)) setFocus({ key, col: c });
  }

  function onKeyDown(e: KeyboardEvent<HTMLTableElement>) {
    onKeyDownProp?.(e);
    if (e.defaultPrevented || !(e.target instanceof HTMLElement)) return;
    const at = e.target.dataset.tt;
    if (!at) return;
    const [r, c] = at.split(":").map(Number);
    const line = visible[r];
    if (!line) return;
    const onRow = c === -1;
    const mod = e.ctrlKey || e.metaKey;

    switch (e.key) {
      case "ArrowRight":
        if (!onRow) go(r, c + 1);
        else if (line.hasChildren && !open.has(line.key)) setOpen([line.key], true);
        else go(r, 0);
        break;
      case "ArrowLeft":
        if (!onRow) go(r, c - 1);
        else if (line.hasChildren && open.has(line.key)) setOpen([line.key], false);
        else if (line.parentIndex >= 0) go(line.parentIndex, -1);
        break;
      case "ArrowDown":
        go(r + 1, c);
        break;
      case "ArrowUp":
        go(r - 1, c);
        break;
      case "Home":
        if (mod || onRow) go(0, c);
        else go(r, 0);
        break;
      case "End":
        if (mod || onRow) go(lastRow, c);
        else go(r, lastCol);
        break;
      case "Enter":
        if (!line.hasChildren || c > 0) return;
        toggle(line.key);
        break;
      case "*": {
        const parent = nodes.get(line.key)?.parent ?? null;
        const siblings = parent == null ? rows : (getChildren(nodes.get(parent)!.row) ?? []);
        setOpen(
          siblings.filter((s) => (getChildren(s)?.length ?? 0) > 0).map(rowKey),
          true,
        );
        break;
      }
      default:
        return;
    }
    e.preventDefault();
    e.stopPropagation();
  }

  function onChevron(e: MouseEvent<HTMLSpanElement>, key: string) {
    e.stopPropagation();
    toggle(key);
  }

  const tab = (r: number, c: number) => (r === fr && c === fc ? 0 : -1);

  return (
    <div ref={rootRef} className="eq-tree-table-scroll" data-dense={dense || undefined} style={{ maxHeight }}>
      <table
        role="treegrid"
        aria-label={label}
        aria-rowcount={visible.length + 1}
        aria-colcount={columns.length}
        {...rest}
        className={["eq-tree-table", className].filter(Boolean).join(" ")}
        onFocus={onFocus}
        onKeyDown={onKeyDown}
      >
        <thead className="eq-tree-table-head">
          <tr className="eq-tree-table-row" role="row" aria-rowindex={1}>
            {columns.map((col, i) => (
              <th
                key={col.key}
                className="eq-tree-table-header"
                role="columnheader"
                aria-colindex={i + 1}
                data-align={col.align === "end" && i > 0 ? "end" : undefined}
                style={col.width != null ? { width: col.width } : undefined}
              >
                {col.header}
              </th>
            ))}
          </tr>
        </thead>
        <tbody className="eq-tree-table-body">
          {visible.map((line, r) => (
            <tr
              key={line.key}
              className="eq-tree-table-row"
              role="row"
              aria-rowindex={r + 2}
              aria-level={line.level}
              aria-setsize={line.setsize}
              aria-posinset={line.posinset}
              aria-expanded={line.hasChildren ? open.has(line.key) : undefined}
              data-tt={`${r}:-1`}
              tabIndex={tab(r, -1)}
              style={{ "--_level": line.level - 1 } as CSSProperties}
            >
              {columns.map((col, i) => {
                const value = valueOf(line.row, col.key);
                const content = col.render ? col.render(line.row) : value == null ? "" : String(value);
                return (
                  <td
                    key={col.key}
                    className="eq-tree-table-cell"
                    role="gridcell"
                    aria-colindex={i + 1}
                    data-align={col.align === "end" && i > 0 ? "end" : undefined}
                    data-tt={`${r}:${i}`}
                    tabIndex={tab(r, i)}
                  >
                    {i === 0 ? (
                      <span className="eq-tree-table-content">
                        <span
                          className="eq-tree-table-toggle"
                          aria-hidden="true"
                          onClick={line.hasChildren ? (e) => onChevron(e, line.key) : undefined}
                        >
                          {line.hasChildren && (
                            <svg
                              className="eq-tree-table-chevron"
                              viewBox="0 0 16 16"
                              fill="none"
                              stroke="currentColor"
                              strokeWidth="1.5"
                              strokeLinecap="round"
                              strokeLinejoin="round"
                            >
                              <path d="M6 4l4 4-4 4" />
                            </svg>
                          )}
                        </span>
                        <span className="eq-tree-table-text">{content}</span>
                      </span>
                    ) : (
                      content
                    )}
                  </td>
                );
              })}
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}
components/equip/tree-table.css130 lines
@layer equip {
  .eq-tree-table-scroll {
    --_row: var(--eq-h-m);
    --_pad: var(--eq-space-3);
    --_indent: 20px;

    box-sizing: border-box;
    max-width: 100%;
    overflow: auto;
    overscroll-behavior: contain;
    border: 1px solid var(--eq-line);
    border-radius: var(--eq-radius-m);
    background: var(--eq-raised);
    /* Keeps a focused row from scrolling under the sticky header. */
    scroll-padding-block-start: var(--_row);
  }

  .eq-tree-table-scroll[data-dense] {
    --_row: var(--eq-h-s);
    --_pad: var(--eq-space-2);
    --_indent: 16px;
  }

  .eq-tree-table {
    width: 100%;
    /* Separate borders, so the header's bottom edge travels with it when it sticks. */
    border-collapse: separate;
    border-spacing: 0;
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.4 var(--eq-font);
  }

  .eq-tree-table-header,
  .eq-tree-table-cell {
    box-sizing: border-box;
    height: var(--_row);
    padding: 0 var(--_pad);
    border-bottom: 1px solid var(--eq-line);
    text-align: start;
    vertical-align: middle;
    white-space: nowrap;
    transition: background-color var(--eq-dur) var(--eq-ease);
  }

  .eq-tree-table-header {
    position: sticky;
    top: 0;
    z-index: 1;
    border-bottom-color: var(--eq-line-strong);
    background: var(--eq-raised);
    color: var(--eq-ink-2);
    font-size: var(--eq-text-xs);
    font-weight: var(--eq-weight-strong);
    letter-spacing: 0.01em;
  }

  :is(.eq-tree-table-header, .eq-tree-table-cell)[data-align="end"] {
    text-align: end;
    font-variant-numeric: tabular-nums;
  }

  /* The first cell carries the indent; each level steps in by one chevron and a gap. */
  .eq-tree-table-content {
    display: flex;
    align-items: center;
    gap: var(--eq-space-1);
    min-width: 0;
    padding-inline-start: calc(var(--_level, 0) * var(--_indent));
  }

  .eq-tree-table-toggle {
    flex: none;
    display: grid;
    place-items: center;
    width: 16px;
    height: 16px;
    border-radius: var(--eq-radius-s);
    color: var(--eq-ink-3);
  }

  .eq-tree-table-toggle:has(> svg) {
    cursor: pointer;
  }

  .eq-tree-table-chevron {
    width: 14px;
    height: 14px;
    transition: rotate var(--eq-dur-move) var(--eq-spring);
  }

  .eq-tree-table-row[aria-expanded="true"] .eq-tree-table-chevron {
    rotate: 90deg;
  }

  .eq-tree-table-row[aria-expanded] > .eq-tree-table-cell:first-child {
    font-weight: var(--eq-weight-strong);
  }

  .eq-tree-table-text {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  @media (hover: hover) {
    .eq-tree-table-body > .eq-tree-table-row:hover > .eq-tree-table-cell {
      background: var(--eq-tint);
    }

    .eq-tree-table-toggle:has(> svg):hover {
      background: var(--eq-tint-strong);
      color: var(--eq-ink);
    }
  }

  .eq-tree-table-body > .eq-tree-table-row:last-child > * {
    border-bottom: 0;
  }

  /* Rings sit inside: the scroll area would clip one drawn outside. */
  .eq-tree-table-row:focus-visible,
  .eq-tree-table-cell:focus-visible {
    outline: 2px solid var(--eq-focus);
    outline-offset: -2px;
  }

  .eq-tree-table-row:focus-visible > .eq-tree-table-cell {
    background: var(--eq-tint);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index