More Tables Snippets
Expandable Table — Free HTML CSS JS Snippet
Expandable Rows Table · Tables · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Expandable Rows Table — Parent Rows with Nested Detail Tables, Chevron Toggle & Accordion Behaviour

An expandable rows table shows summary data in each row and reveals detailed sub-data when the row is clicked — without navigating to a new page. This pattern is used in order management tables (click an order to see line items), expense reports (click a category to see individual transactions), file managers (click a folder to see files), and any data set with a parent-child hierarchy. This snippet provides a complete implementation with chevron rotation, accordion single-open behaviour, an inner detail table, status badges, and customer avatar initials.
How the expand/collapse works
Each main row has a data-id attribute. The detail row below it has id="detail-N" matching the data-id. When a row is clicked, toggle(row) reads the data-id, finds the detail row, and adds/removes the .open class on both. The detail row defaults to display: none via CSS and switches to display: table-row when .open is applied.
Accordion behaviour
Before opening a new row, toggle() closes all currently open rows: document.querySelectorAll(".main-row.open").forEach(r => { r.classList.remove("open"); detail.classList.remove("open"); }). This ensures only one row is expanded at a time — the accordion pattern — which keeps the table scannable. Remove the "close all others" block to allow multiple rows to be open simultaneously.
The chevron indicator
A › character in the first column rotates 90 degrees via transform: rotate(90deg) when .open is applied to the parent row. The CSS transition: transform 0.2s animates the rotation smoothly. The colour changes from grey to indigo to signal the active state.
The inner detail table
The detail row contains a full colspan="7" cell (spanning all columns). Inside it, a .detail-wrap div with a left border accent (border-left: 3px solid #6366f1) and indentation creates visual hierarchy. Inside .detail-wrap, a nested .inner-table shows line-item data (product name, SKU, quantity, price, subtotal). The inner table uses the same border-collapse collapse style as the outer table for visual consistency.
Customising the table
Update the outer table columns (Order ID, Customer, Date, Items, Total, Status) to match your data model. Change the inner table columns to match your detail data. Update the colspan value on the detail td to match your column count. Add or remove badge colour classes as needed. For a flat list instead, use the Data Table snippet; for true hierarchical nesting, see the tree table.
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't have to trace the row-to-detail wiring by eye. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to explain exactly how the data-id attribute on each main row is used to find and toggle its matching detail row by constructed id, and why the toggle function closes every other open row before opening the clicked one. The same assistant can help optimize it — ask whether hardcoding every order's line items directly in the HTML will scale once there are hundreds of orders, and what a lazy-fetch-on-first-expand approach would need to change in the toggle function. It's also useful for extending the table: ask it to add a smooth height animation instead of the instant display toggle, support expanding multiple rows at once as an option, or add a small loading spinner inside the detail row while its data is being fetched for the first time. 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:
Build a table with expandable master-detail rows in plain HTML, CSS, and JavaScript — no library.
Requirements:
- Each summary row must carry a data-id attribute, and immediately following it in the same tbody, a matching detail row whose id is derived from that same identifier (for example "detail-" plus the id), so the two rows can be found and linked programmatically without any external mapping.
- The detail row must be a single td with a colspan equal to the total number of columns in the outer table, containing a nested, completely independent inner table with its own columns and widths for the line-item detail.
- The detail row must be hidden by default via CSS (display: none) and switched to display: table-row only when an "open" class is applied — never removed from the DOM, so the same markup can be toggled back and forth cheaply.
- Clicking a summary row must first find and close every other row that is currently open (removing the open class from both its summary and detail row), then toggle open state on the clicked row and its detail row — enforcing that only one row's detail is visible at a time.
- A chevron indicator in the first column of each summary row must rotate 90 degrees via a CSS transform transition when its row is open, and change color to signal the active state.
- Give the detail panel a visually distinct left border accent and indentation relative to the outer table so it reads clearly as a nested, subordinate data view rather than another top-level row.
- Style at least three status badge variants with tinted backgrounds for the summary row's status column.Step by step
How to Use
- 1Click any row to expand its detailClicking a row reveals the nested detail table with line items. The chevron rotates 90 degrees and the row gets a background tint. Clicking another row closes the open one (accordion behaviour).
- 2Update the outer table dataIn the HTML, edit each main-row tr: change order ID, customer initials and gradient, date, item count, total amount, and badge class/text. Update the data-id attribute and matching detail row id accordingly.
- 3Update the inner detail tableInside each detail-row, edit the inner-table tbody rows with your actual line-item data. Adjust the number of columns in the inner thead and tbody to match your data structure.
- 4Allow multiple rows open simultaneouslyRemove the "Close all others" block in the toggle() function: delete the document.querySelectorAll(".main-row.open").forEach(...) lines. The function will then only toggle the clicked row without affecting others.
- 5Add more columns or rowsAdd th headers to the outer table thead and matching td cells to each main-row tr. Update the colspan="7" on each detail-row td to match the new total column count. Duplicate the main-row + detail-row pair for each new data record.
- 6Export in your formatClick "HTML" for a standalone file, "JSX" for a React component managing open state in useState, or "Tailwind" for a React + Tailwind CSS version.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The detail row has a single td element with colspan="7" — or whatever number matches the total column count in the outer table. This single cell spans the full row width. Inside it, a div with padding and a left border creates the indented detail panel. The inner-table inside this div is a completely separate table, not constrained by the outer column widths, so it can have different columns and widths.
Track which rows have been loaded: const loaded = new Set(). In toggle(), before opening a new row, check if loaded.has(id). If not: show a loading spinner in the detail row, fetch("/api/orders/"+id+"/items").then(data => { renderDetail(id, data); loaded.add(id); row.classList.add("open"); detail.classList.add("open"); }). The detail row content is rendered from the API response on first expand and cached for subsequent expands.
CSS cannot animate from height: 0 to height: auto directly. Instead: set the detail row to display: table-row always (remove display:none), and use max-height animation: max-height: 0; overflow: hidden on the detail div, transitioning to max-height: 500px on .open. Or use the Web Animations API: detail.animate([{height: "0"},{height: detail.scrollHeight+"px"}], {duration: 250, easing: "ease"}).
Click "JSX" to download. Manage open row state with useState<string|null>(null) for accordion mode (one open at a time) or useState<Set<string>>(new Set()) for multi-open mode. Render the detail tr conditionally: {openId === row.id && <tr><td colSpan={7}><DetailContent data={row.items} /></td></tr>}. For lazy data loading, use useState to track per-row data and fetch on first expand inside a click handler.