Tree Table — Expandable Hierarchy Rows HTML CSS JS

Tree Table · Tables · Plain HTML, CSS & JS · Live preview

Share & Support

What's included

Features

Unlimited nesting
A recursive flatten renders a tree of any depth as the flat rows a <table> requires, tagged with depth and parent.
Ancestor-aware visibility
A row shows only when every ancestor is expanded, so collapsing any level correctly hides all its descendants.
Disclosure triangles
Rotating triangles indicate expand/collapse state, with invisible spacers keeping leaf rows aligned.
Depth indentation and styling
Each level indents and lightens, reinforcing the hierarchy beyond indentation alone.
Expand-all / collapse-all
One header button opens or closes the entire tree, essential for deep hierarchies.
Per-row roll-up bar
A mini utilization bar fills proportionally and flags overspend, giving context down the tree.
Simple expansion state
An id→boolean map is the single source of truth that the render reads — no scattered DOM state.
Semantic table
A real <table> with thead/tbody and button toggles, the accessible base for hierarchical data.

About this UI Snippet

Tree Table — Collapsible Nested Rows for Hierarchical Data

Screenshot of the Tree Table snippet rendered live

Some data is naturally a hierarchy — a budget by department and sub-team, a file system, a category tree, an org chart of accounts — and a flat table can't show those parent/child relationships. A tree table solves it: rows nest under expandable parents, indented by depth, so you can drill from a top-level total down to its details and collapse what you don't need. This snippet builds that pattern in plain HTML, CSS, and vanilla JavaScript over a real <table>, with any depth of nesting.

Nested data, flattened for rendering

The source is a nested tree of objects, each with optional children. flatten() walks that tree depth-first into a flat list of rows, each tagged with its depth and parent id — because a <table> body is a flat list of <tr>s, not a nested structure. This flatten-with-metadata step is the core technique: it lets you keep your data as a natural hierarchy while rendering it as the linear rows a table requires, and it works for unlimited depth (the demo goes three levels deep).

Visibility derived from ancestor expansion

A child row is visible only when *every* ancestor above it is expanded — collapsing a top-level row should hide its grandchildren too, not just its direct children. isVisible() walks up the parent chain and returns false if any ancestor is collapsed, so the show/hide logic is always correct no matter how deep the nesting or which level you collapse. Expansion state lives in a simple expanded map of id → boolean, the single source of truth that the render reads.

Indentation and disclosure triangles

Each row indents by its depth (via a left-padding rule per depth level) and shows a rotating disclosure triangle that points right when collapsed and down when expanded — the universal tree-control affordance. Leaf rows (no children) get an invisible spacer where the triangle would be, so their text still aligns with siblings that do have triangles. Top-level rows are bold, deeper rows progressively lighter, reinforcing the hierarchy visually beyond just indentation.

Roll-up context per row

Because tree tables usually show aggregates (a parent's budget is the sum of its children's), each row here includes a little spend bar that fills proportionally and turns red past 90% — so you can scan utilization down the hierarchy. In a real app the parent values would be computed from the children (covered in the FAQs); the structure supports either pre-computed or rolled-up totals.

Expand-all / collapse-all

A header button toggles the entire tree open or closed at once — essential for a deep hierarchy where expanding level by level is tedious. It rebuilds the expanded map (all parent ids true, or empty) and re-renders, flipping its own label between "Expand all" and "Collapse all." The tree starts with the top level expanded so the structure is immediately visible without hiding everything.

A real table, kept accessible

It's a genuine semantic <table> with <thead>/<tbody>, so it reads as tabular data to assistive tech and works with table styling. The disclosure triangles are real <button>s. For full accessibility you'd add aria-expanded to each toggle and role="treegrid" semantics (covered in the FAQs), but the foundation — semantic table, flattened render, ancestor-aware visibility — is the right base for any hierarchical table.

Build with AI

Build, Understand, Optimize, and Extend It With AI

Rather than tracing the recursion by hand, paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly why isVisible() has to walk the full parentId chain instead of just checking the immediate parent's expanded state, and what would break if it only checked one level up. It's also worth asking about optimization — the current render() calls flatten() on the full DATA tree and rebuilds every row's HTML string on every single toggle click, so ask whether that's a real cost at, say, ten thousand rows, and what a targeted update would look like instead. For extending it, have it add a recursive roll-up so parent budget and spent totals are computed from children rather than hand-entered, real aria-expanded/role=treegrid accessibility, or a lazy-loading mode where a node's children are fetched only the first time it's expanded. Treat the code less like a finished artifact and more like a starting point for a conversation.

Prompt to recreate it

Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:

text
Build a "tree table" in plain HTML, CSS, and vanilla JavaScript over a real semantic table element — no nested tables, no libraries.

Requirements:
- Source data is a tree of objects, each with a name, numeric fields, and an optional children array of unlimited nesting depth.
- Write a recursive flatten(nodes, depth, parentId, out) function that walks the tree depth-first and produces a flat array of rows, each tagged with its own depth, its parentId, and a hasChildren boolean, since a table's tbody can only render a flat list of tr elements.
- Track expansion state as a single id-to-boolean map, not scattered per-row DOM flags.
- Write an isVisible(row, byId) function that walks up the full chain of ancestor parentIds (not just the immediate parent) and returns false if any ancestor anywhere up the chain is collapsed, so collapsing a top-level row correctly hides every level of its descendants, not only its direct children.
- Every row must indent by its depth (increasing left padding per level) and show a disclosure triangle that rotates when its row is expanded; leaf rows with no children must render an invisible spacer in the same column so their text still lines up with sibling rows that do have a triangle.
- Add a header button that toggles the entire tree fully open or fully closed in one click, rebuilding the expansion map and re-rendering, and that flips its own label between "Expand all" and "Collapse all".
- Re-render the whole table body from the flattened, visibility-filtered row list on every toggle click rather than mutating individual DOM rows in place.

Step by step

How to Use

  1. 1
    Paste HTML, CSS, and JSA budget tree table renders with departments expanded, showing nested sub-teams and line items.
  2. 2
    Collapse a branchClick a parent's triangle to collapse it — all its descendants hide, and the triangle rotates to point right.
  3. 3
    Drill downExpand a department, then a sub-team, to reach the deepest line items; indentation shows the depth.
  4. 4
    Toggle everythingUse "Collapse all" / "Expand all" in the header to open or close the whole tree at once.
  5. 5
    Read the spend barsEach row's mini bar shows budget utilization, turning red past 90%, so you can scan the hierarchy.
  6. 6
    Use your own dataReplace the DATA tree with your nested objects (name, values, optional children) — any depth renders.

Real-world uses

Common Use Cases

Budgets and financial breakdowns
Drill from totals to line items by department and sub-team — pair with a data table column toggle.
File and folder explorers
Show a directory tree with sizes and dates as expandable rows.
Category and taxonomy management
Manage nested product categories or tags in a hierarchical table.
Org charts and reporting lines
Display teams and reports as a collapsible tree of rows.
BOM and inventory hierarchies
Show bills of materials or nested inventory with roll-up quantities.
Learning tree rendering
A reference for flattening nested data and ancestor-aware visibility — compare with an expandable table for single-level row detail and a tree menu for navigation.

Got questions?

Frequently Asked Questions

Add a recursive aggregate function that, for any node with children, sums its children's values (which themselves may be sums) — compute budget/spent for parents as the total of their descendants rather than storing them. Run it once on the DATA tree before rendering, so every parent row shows the true roll-up of everything beneath it, and the numbers stay consistent as you edit leaves.

An HTML table body is a flat list of rows; nesting <table>s inside cells breaks column alignment and accessibility. Flattening the tree into rows tagged with depth and parent id keeps one aligned table while preserving the hierarchy in the data — indentation conveys depth visually, and the parent/depth metadata drives expand/collapse and visibility. It's the standard approach for tree tables.

Use role="treegrid" on the table, add aria-expanded (true/false) to each parent's toggle button reflecting its state, aria-level matching the depth on each row, and aria-setsize/aria-posinset for position. Ensure the toggles are keyboard-operable (they're buttons, so Enter/Space work) and consider arrow-key navigation between rows. The semantic <table> base makes layering these straightforward.

Mark nodes that have children but haven't loaded them (e.g. hasChildren: true, children: null), render their toggle, and on first expand fetch the children from your API, insert them into the node, and re-render. This avoids loading a massive tree upfront — the flatten/visibility logic works the same once children are populated.

In React, hold the expanded map (or a Set of ids) in useState and derive the flattened, visibility-filtered rows with useMemo, rendering with .map(); in Vue, use a reactive expanded object with a computed rows list; in Angular, use a component field and a getter. The flatten and isVisible functions are plain JavaScript that port unchanged — only the per-toggle re-render moves into the framework.