Navigation

Tree

A nested list that opens and closes, for files, navigation or settings with a hierarchy.

Built on
role="tree"Roving tabindexGrid track animation
Client JavaScript
Yes, for state
Works without React
React only (needs JavaScript)
Source, gzipped
3.6 kB
Dependencies
React
  • src
    • components
      • button.tsx
      • tree.tsx
Try its options

Loading its options.

Code
import { Tree, TreeItem } from "@/components/equip/tree";

const folder = (
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <path d="M2 4.5c0-.6.4-1 1-1h3l1.5 1.5H13c.6 0 1 .4 1 1v6c0 .6-.4 1-1 1H3c-.6 0-1-.4-1-1z" />
  </svg>
);

const file = (
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <path d="M4 2.5h5l3 3v8H4z M9 2.5v3h3" />
  </svg>
);

export default function Demo() {
  return (
    <Tree
      label="Project files"
      defaultExpanded={["src", "components"]}
      defaultSelected="button"
      style={{ width: 200 }}
    >
      <TreeItem id="src" label="src" icon={folder}>
        <TreeItem id="components" label="components" icon={folder}>
          <TreeItem id="button" label="button.tsx" icon={file} />
          <TreeItem id="tree" label="tree.tsx" icon={file} />
        </TreeItem>
      </TreeItem>
    </Tree>
  );
}
Your versionchange its options in Look, This part

Examples

Navigation with a controlled selection

Arrows only move focus; Enter or Space selects, so a long tree can be browsed without opening every page.

  • Overview
  • Team
    • Members
    • Roles
    • Single sign-on
Showing Roles
Code
"use client";

import { useState } from "react";
import { Tree, TreeItem } from "@/components/equip/tree";

const pages: Record<string, string> = {
  overview: "Overview",
  members: "Members",
  roles: "Roles",
  sso: "Single sign-on",
  invoices: "Invoices",
  plan: "Plan and usage",
};

export default function Example() {
  const [page, setPage] = useState("roles");
  return (
    <div style={{ display: "grid", gap: 8, width: 240 }}>
      <Tree label="Settings" selected={page} onSelectedChange={setPage} defaultExpanded={["team"]}>
        <TreeItem id="overview" label="Overview" />
        <TreeItem id="team" label="Team">
          <TreeItem id="members" label="Members" />
          <TreeItem id="roles" label="Roles" />
          <TreeItem id="sso" label="Single sign-on" />
        </TreeItem>
        <TreeItem id="billing" label="Billing">
          <TreeItem id="invoices" label="Invoices" />
          <TreeItem id="plan" label="Plan and usage" />
        </TreeItem>
      </Tree>
      <span style={{ fontSize: 12, color: "var(--eq-ink-2)" }}>Showing {pages[page] ?? page}</span>
    </div>
  );
}

Deep nesting and disabled items

Press * to open every sibling at once. A disabled item can still be reached, so people can see it exists.

  • production
    • eu-west-1
      • api-gateway
      • workers (12)
      • postgres-primary
Code
import { Tree, TreeItem } from "@/components/equip/tree";

export default function Example() {
  return (
    <Tree label="Infrastructure" defaultExpanded={["prod", "eu-west-1"]} style={{ width: 260 }}>
      <TreeItem id="prod" label="production">
        <TreeItem id="eu-west-1" label="eu-west-1">
          <TreeItem id="api" label="api-gateway" />
          <TreeItem id="workers" label="workers (12)" />
          <TreeItem id="db" label="postgres-primary" />
        </TreeItem>
        <TreeItem id="us-east-1" label="us-east-1">
          <TreeItem id="api-us" label="api-gateway" />
          <TreeItem id="db-us" label="postgres-replica" />
        </TreeItem>
        <TreeItem id="ap" label="ap-southeast-2 (locked)" disabled>
          <TreeItem id="api-ap" label="api-gateway" />
        </TreeItem>
      </TreeItem>
      <TreeItem id="staging" label="staging">
        <TreeItem id="api-staging" label="api-gateway" />
      </TreeItem>
    </Tree>
  );
}

Props

PropTypeDefaultWhat it does
labelstringnoneNames the tree for assistive tech, e.g. "Project files".
selectedstring | nullnoneId of the selected item, when you control it.
defaultSelectedstringnoneId of the item selected at first, when uncontrolled.
onSelectedChange(id: string) => voidnoneCalled when a different item is selected.
defaultExpandedstring[]noneIds of the items that start open.
childrenReactNodenoneTreeItem elements.
...restComponentProps<"ul">noneEvery native ul attribute, applied to the tree.
TreeItem idstringnoneIdentifies the item for selection and expansion. It is not used as the DOM id.
TreeItem labelReactNodenoneThe row's text. Type-ahead matches against it.
TreeItem iconReactNodenoneA 16px icon before the label.
TreeItem childrenReactNodenoneNested TreeItem elements. Having them makes the item expandable.
TreeItem disabledbooleannoneReachable but cannot be selected, opened or closed.
TreeItem ...restComponentProps<"li">noneEvery native li attribute.

Anatomy

root.eq-treeThe ul with role="tree".
item.eq-tree-itemThe li with role="treeitem"; it takes focus.
row.eq-tree-rowThe visible line of an item; the focus ring is drawn here.
toggle.eq-tree-toggleHolds the chevron, or keeps the indent on a leaf.
chevron.eq-tree-chevron
icon.eq-tree-icon
label.eq-tree-label
panel.eq-tree-panelAnimates the children open and closed.
group.eq-tree-groupThe ul with role="group"; its edge is the guide line.

Keyboard

ArrowDownArrowUpMoves to the next or previous visible item.
ArrowRightOpens a closed item, or moves to the first child of an open one.
ArrowLeftCloses an open item, or moves to the parent.
HomeEndMoves to the first or last visible item.
EnterSpaceSelects the focused item.
*Opens every sibling of the focused item.
A-ZMoves to the next visible item whose label starts with that letter.

Notes

  • The tree is one tab stop with a roving tabindex: Tab returns to the last focused item, or the selected one.
  • Focus does not select. Clicking a row selects it and opens or closes it if it has children.
  • Closed children stay in the DOM but are hidden from assistive tech and the tab order once the close has played.
  • If closing a branch hides the item holding the tab stop, the stop moves to the nearest visible parent.
  • Only items with children get aria-expanded, and each item states its aria-level.

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.tsx263 lines
"use client";

import {
  createContext,
  useContext,
  useEffect,
  useRef,
  useState,
  type ComponentProps,
  type FocusEvent,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import "./tree.css";

export type TreeProps = Omit<ComponentProps<"ul">, "children" | "onSelect"> & {
  /** Names the tree for assistive tech, e.g. "Project files". */
  label: string;
  /** Id of the selected item, when you control it. */
  selected?: string | null;
  defaultSelected?: string;
  onSelectedChange?: (id: string) => void;
  /** Ids of the items that start open. */
  defaultExpanded?: string[];
  /** TreeItem elements. */
  children: ReactNode;
};

type TreeContext = {
  expanded: Set<string>;
  selected: string | null;
  tabStop: string | null;
  toggle: (id: string) => void;
  select: (id: string) => void;
};

const Context = createContext<TreeContext>({
  expanded: new Set(),
  selected: null,
  tabStop: null,
  toggle: () => {},
  select: () => {},
});

const Level = createContext(1);

function itemOf(node: EventTarget | null) {
  return node instanceof Element ? node.closest<HTMLElement>('[role="treeitem"]') : null;
}

export function Tree({
  label,
  selected: selectedProp,
  defaultSelected,
  onSelectedChange,
  defaultExpanded,
  className,
  children,
  onKeyDown,
  onFocus,
  ...rest
}: TreeProps) {
  const ref = useRef<HTMLUListElement>(null);
  const [expanded, setExpanded] = useState(() => new Set(defaultExpanded));
  const [internal, setInternal] = useState<string | null>(defaultSelected ?? null);
  const [focused, setFocused] = useState<string | null>(null);
  const selected = selectedProp !== undefined ? selectedProp : internal;

  function visible() {
    return Array.from(ref.current?.querySelectorAll<HTMLElement>('[role="treeitem"]') ?? []).filter(
      (item) => !item.parentElement?.closest('[role="treeitem"][aria-expanded="false"]'),
    );
  }

  // One item is always the tab stop: the focused one, else the selected one, else the first.
  // If a collapse hides it, the stop moves up to the nearest visible ancestor.
  const [tabStop, setTabStop] = useState<string | null>(null);
  useEffect(() => {
    const items = visible();
    const want = focused ?? selected;
    let item = items.find((node) => node.dataset.id === want);
    if (!item && want) {
      const hidden = ref.current?.querySelector<HTMLElement>(`[role="treeitem"][data-id="${CSS.escape(want)}"]`);
      let parent = itemOf(hidden?.parentElement ?? null);
      while (parent && !items.includes(parent)) parent = itemOf(parent.parentElement);
      item = parent ?? undefined;
    }
    setTabStop((item ?? items[0])?.dataset.id ?? null);
  }, [focused, selected, expanded]);

  function toggle(id: string, open?: boolean) {
    setExpanded((current) => {
      const next = new Set(current);
      if (open ?? !next.has(id)) next.add(id);
      else next.delete(id);
      return next;
    });
  }

  function select(id: string) {
    if (selectedProp === undefined) setInternal(id);
    if (id !== selected) onSelectedChange?.(id);
  }

  function handleFocus(event: FocusEvent<HTMLUListElement>) {
    onFocus?.(event);
    const item = itemOf(event.target);
    if (item?.dataset.id) setFocused(item.dataset.id);
  }

  function handleKeyDown(event: KeyboardEvent<HTMLUListElement>) {
    onKeyDown?.(event);
    if (event.defaultPrevented) return;
    const item = itemOf(event.target);
    const id = item?.dataset.id;
    if (!item || id === undefined) return;
    const items = visible();
    const index = items.indexOf(item);
    const parent = itemOf(item.parentElement);
    const isParent = item.hasAttribute("aria-expanded");
    const isOpen = item.getAttribute("aria-expanded") === "true";
    const usable = item.getAttribute("aria-disabled") !== "true";
    let next: HTMLElement | null | undefined;

    switch (event.key) {
      case "ArrowDown":
        next = items[index + 1];
        break;
      case "ArrowUp":
        next = items[index - 1];
        break;
      case "Home":
        next = items[0];
        break;
      case "End":
        next = items[items.length - 1];
        break;
      case "ArrowRight":
        if (!isParent) break;
        if (!isOpen) {
          if (usable) toggle(id, true);
        } else {
          next = item.querySelector<HTMLElement>('[role="group"] > [role="treeitem"]');
        }
        event.preventDefault();
        break;
      case "ArrowLeft":
        if (isParent && isOpen && usable) {
          toggle(id, false);
          event.preventDefault();
        } else {
          next = parent;
        }
        break;
      case "Enter":
      case " ":
        event.preventDefault();
        if (usable) select(id);
        return;
      case "*": {
        const siblings = Array.from(item.parentElement?.children ?? []).filter(
          (node): node is HTMLElement =>
            node instanceof HTMLElement && node.getAttribute("aria-expanded") === "false" && node.getAttribute("aria-disabled") !== "true",
        );
        setExpanded((current) => new Set([...current, ...siblings.map((node) => node.dataset.id ?? "")]));
        event.preventDefault();
        return;
      }
      default: {
        if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey) return;
        const letter = event.key.toLowerCase();
        for (let step = 1; step <= items.length; step++) {
          const candidate = items[(index + step) % items.length];
          const text = candidate.querySelector(":scope > .eq-tree-row .eq-tree-label")?.textContent ?? "";
          if (text.trim().toLowerCase().startsWith(letter)) {
            next = candidate;
            break;
          }
        }
      }
    }

    if (!next) return;
    event.preventDefault();
    next.focus();
  }

  return (
    <ul
      {...rest}
      ref={ref}
      role="tree"
      aria-label={label}
      className={["eq-tree", className].filter(Boolean).join(" ")}
      onFocus={handleFocus}
      onKeyDown={handleKeyDown}
    >
      <Context.Provider value={{ expanded, selected, tabStop, toggle, select }}>{children}</Context.Provider>
    </ul>
  );
}

export type TreeItemProps = Omit<ComponentProps<"li">, "id" | "children"> & {
  /** Identifies the item for selection and expansion. Not used as the DOM id. */
  id: string;
  label: ReactNode;
  /** An icon before the label, e.g. a folder or file glyph. */
  icon?: ReactNode;
  /** Nested TreeItem elements. Their presence makes the item expandable. */
  children?: ReactNode;
  disabled?: boolean;
};

export function TreeItem({ id, label, icon, children, disabled, className, ...rest }: TreeItemProps) {
  const { expanded, selected, tabStop, toggle, select } = useContext(Context);
  const level = useContext(Level);
  const isParent = children != null && children !== false;
  const isOpen = isParent && expanded.has(id);

  return (
    <li
      {...rest}
      role="treeitem"
      className={["eq-tree-item", className].filter(Boolean).join(" ")}
      data-id={id}
      tabIndex={tabStop === id ? 0 : -1}
      aria-level={level}
      aria-expanded={isParent ? isOpen : undefined}
      aria-selected={disabled ? undefined : selected === id}
      aria-disabled={disabled || undefined}
    >
      <div
        className="eq-tree-row"
        onClick={() => {
          if (disabled) return;
          select(id);
          if (isParent) toggle(id);
        }}
      >
        <span className="eq-tree-toggle" aria-hidden="true">
          {isParent && (
            <svg className="eq-tree-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>
        {icon != null && (
          <span className="eq-tree-icon" aria-hidden="true">
            {icon}
          </span>
        )}
        <span className="eq-tree-label">{label}</span>
      </div>
      {isParent && (
        <div className="eq-tree-panel">
          <ul role="group" className="eq-tree-group">
            <Level.Provider value={level + 1}>{children}</Level.Provider>
          </ul>
        </div>
      )}
    </li>
  );
}
components/equip/tree.css138 lines
@layer equip {
  .eq-tree,
  .eq-tree-group {
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .eq-tree {
    color: var(--eq-ink);
    font: var(--eq-weight) var(--eq-text-s) / 1.3 var(--eq-font);
  }

  /* The item itself takes focus; the ring is drawn on its row so it never wraps the children. */
  .eq-tree-item:focus {
    outline: none;
  }

  .eq-tree-item:focus-visible > .eq-tree-row {
    outline: 2px solid var(--eq-focus);
    outline-offset: -2px;
  }

  .eq-tree-row {
    display: flex;
    align-items: center;
    gap: var(--eq-space-1);
    box-sizing: border-box;
    min-height: var(--eq-h-s);
    padding-inline: var(--eq-space-1) var(--eq-space-2);
    border-radius: var(--eq-radius-s);
    white-space: nowrap;
    cursor: pointer;
    user-select: none;
    transition:
      background-color var(--eq-dur) var(--eq-ease),
      color var(--eq-dur) var(--eq-ease),
      scale var(--eq-dur) var(--eq-ease);
  }

  @media (hover: hover) {
    .eq-tree-item:not([aria-disabled="true"]) > .eq-tree-row:hover {
      background: var(--eq-tint);
    }
  }

  .eq-tree-item:not([aria-disabled="true"]) > .eq-tree-row:active {
    scale: var(--eq-press);
  }

  .eq-tree-item[aria-selected="true"] > .eq-tree-row,
  .eq-tree-item[aria-selected="true"] > .eq-tree-row:hover {
    background: var(--eq-solid);
    color: var(--eq-solid-ink);
  }

  .eq-tree-item[aria-disabled="true"] > .eq-tree-row {
    opacity: 0.45;
    cursor: not-allowed;
  }

  .eq-tree-toggle {
    flex: none;
    display: grid;
    place-items: center;
    width: 16px;
    height: 16px;
  }

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

  .eq-tree-item[aria-selected="true"] > .eq-tree-row .eq-tree-chevron {
    color: currentColor;
  }

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

  .eq-tree-icon {
    flex: none;
    display: grid;
    place-items: center;
    width: 16px;
    height: 16px;
    color: var(--eq-ink-2);
  }

  .eq-tree-item[aria-selected="true"] > .eq-tree-row .eq-tree-icon {
    color: currentColor;
  }

  .eq-tree-icon > svg {
    width: 16px;
    height: 16px;
  }

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

  /* Children open by growing their row track from 0fr to 1fr, which every current engine
     can animate. Hidden children are invisible, so they leave the tab order and the
     accessibility tree once the close has played. */
  .eq-tree-panel {
    display: grid;
    grid-template-rows: 0fr;
    visibility: hidden;
    transition:
      grid-template-rows var(--eq-dur) var(--eq-ease),
      visibility var(--eq-dur) var(--eq-ease);
  }

  .eq-tree-item[aria-expanded="true"] > .eq-tree-panel {
    grid-template-rows: 1fr;
    /* inherit, not visible, so a closed ancestor still hides an open branch inside it. */
    visibility: inherit;
    transition:
      grid-template-rows var(--eq-dur-move) var(--eq-spring),
      visibility var(--eq-dur-move) var(--eq-ease);
  }

  /* The guide line sits under the parent's chevron; each level indents by 16px. */
  .eq-tree-group {
    min-height: 0;
    overflow: hidden;
    margin-inline-start: calc(var(--eq-space-1) + 7px);
    padding-inline-start: var(--eq-space-1);
    border-inline-start: 1px solid var(--eq-line);
  }
}

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