Skip to content

Row Expansion Table

Add expandable rows to show additional details inline.

Open in
Preview with Controlled State
Open in

The Row Expansion Table allows users to expand rows to view more details inline. This is useful for showing additional information without navigating away or opening a modal.

  1. Add the required components:
npx shadcn@latest add table input button dropdown-menu card separator
  1. Add tanstack/react-table dependency:
npm install @tanstack/react-table
  1. Copy the DataTable components into your project. See the Installation Guide for detailed instructions.

We are going to build a table to show orders with expandable items. Here’s what our data looks like:

type OrderItem = {
id: string
productName: string
category: string
price: number
quantity: number
}
type Order = {
id: string
orderNumber: string
customer: string
email: string
total: number
status: "pending" | "processing" | "shipped" | "delivered" | "cancelled"
date: string
items: OrderItem[]
}

Let’s start by building a table with expandable rows.

First, we’ll define our columns with an expand column.

columns.tsx
"use client"
import {
DataTableColumnHeader,
DataTableColumnTitle,
DataTableColumnSortMenu,
} from "@/components/niko-table/components"
import type { DataTableColumnDef } from "@/components/niko-table/types"
import { Button } from "@/components/ui/button"
import { ChevronRight, ChevronDown } from "lucide-react"
export type Order = {
id: string
orderNumber: string
customer: string
email: string
total: number
status: string
date: string
items: OrderItem[]
}
// Expanded content component
function OrderDetails({ order }: { order: Order }) {
return (
<div className="bg-muted/30 p-6">
<h3 className="font-semibold">Order Items</h3>
<div className="space-y-2">
{order.items.map(item => (
<div key={item.id} className="flex justify-between">
<span>{item.productName}</span>
<span>${item.price.toFixed(2)}</span>
</div>
))}
</div>
</div>
)
}
export const columns: DataTableColumnDef<Order>[] = [
{
id: "expand",
header: () => null,
cell: ({ row }) => {
if (!row.getCanExpand()) return null
return (
<Button
variant="ghost"
size="sm"
onClick={row.getToggleExpandedHandler()}
className="h-6 w-6 p-0"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)
},
size: 50,
enableSorting: false,
enableHiding: false,
meta: {
expandedContent: (order: Order) => <OrderDetails order={order} />,
},
},
{
accessorKey: "orderNumber",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Order #" />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
},
{
accessorKey: "customer",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Customer" />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
},
{
accessorKey: "total",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Total" />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
cell: ({ row }) => {
const total = row.getValue("total") as number
return <div className="font-mono">${total.toFixed(2)}</div>
},
},
{
accessorKey: "status",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Status" />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
},
]

Next, we’ll create the table with row expansion enabled.

row-expansion-table.tsx
"use client"
import {
DataTableRoot,
DataTable,
DataTableHeader,
DataTableBody,
DataTableEmptyBody,
} from "@/components/niko-table/core"
import {
DataTableToolbarSection,
DataTablePagination,
DataTableSearchFilter,
DataTableViewMenu,
DataTableColumnHeader,
DataTableColumnTitle,
DataTableColumnSortMenu,
} from "@/components/niko-table/components"
import type { DataTableColumnDef } from "@/components/niko-table/types"
import { Button } from "@/components/ui/button"
import { ChevronRight, ChevronDown } from "lucide-react"
type Order = {
id: string
orderNumber: string
customer: string
email: string
total: number
status: string
date: string
items: OrderItem[]
}
// Expanded content component
function OrderDetails({ order }: { order: Order }) {
return (
<div className="bg-muted/30 p-6">
<h3 className="font-semibold">Order Items</h3>
<div className="space-y-2">
{order.items.map(item => (
<div key={item.id} className="flex justify-between">
<span>{item.productName}</span>
<span>${item.price.toFixed(2)}</span>
</div>
))}
</div>
</div>
)
}
const columns: DataTableColumnDef<Order>[] = [
{
id: "expand",
header: () => null,
cell: ({ row }) => {
if (!row.getCanExpand()) return null
return (
<Button
variant="ghost"
size="sm"
onClick={row.getToggleExpandedHandler()}
className="h-6 w-6 p-0"
>
{row.getIsExpanded() ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
</Button>
)
},
size: 50,
enableSorting: false,
enableHiding: false,
meta: {
expandedContent: (order: Order) => <OrderDetails order={order} />,
},
},
{
accessorKey: "orderNumber",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Order #" />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
},
{
accessorKey: "customer",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Customer" />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
},
{
accessorKey: "total",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Total" />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
cell: ({ row }) => {
const total = row.getValue("total") as number
return <div className="font-mono">${total.toFixed(2)}</div>
},
},
{
accessorKey: "status",
header: () => (
<DataTableColumnHeader>
<DataTableColumnTitle title="Status" />
<DataTableColumnSortMenu />
</DataTableColumnHeader>
),
},
]
export function RowExpansionTable({ data }: { data: Order[] }) {
return (
<DataTableRoot
data={data}
columns={columns}
config={{
enableExpanding: true,
}}
getRowCanExpand={row => row.original.items.length > 0}
>
<DataTableToolbarSection>
<DataTableSearchFilter placeholder="Search orders..." />
<DataTableViewMenu />
</DataTableToolbarSection>
<DataTable>
<DataTableHeader />
<DataTableBody>
<DataTableEmptyBody />
</DataTableBody>
</DataTable>
<DataTablePagination />
</DataTableRoot>
)
}

Row expansion is enabled by:

  1. Adding a column with id: "expand" or setting meta.expandedContent on any column
  2. Setting enableExpanding: true in the config
  3. Providing expandedContent in the column’s meta property

The expanded content is defined in the column’s meta.expandedContent:

{
id: "expand",
meta: {
expandedContent: (order: Order) => <OrderDetails order={order} />,
},
}

Control which rows can be expanded:

<DataTableRoot
data={data}
columns={columns}
config={{
enableExpanding: true,
}}
getRowCanExpand={row => row.original.items.length > 0}
>
{/* ... */}
</DataTableRoot>

Full control over which rows are expanded:

import { useState } from "react"
import type { ExpandedState } from "@tanstack/react-table"
export function ControlledExpansionTable({ data }: { data: Order[] }) {
const [expanded, setExpanded] = useState<ExpandedState>({})
return (
<DataTableRoot
data={data}
columns={columns}
config={{
enableExpanding: true,
}}
state={{
expanded,
}}
onExpandedChange={setExpanded}
>
<DataTable>
<DataTableHeader />
<DataTableBody>
<DataTableEmptyBody />
</DataTableBody>
</DataTable>
</DataTableRoot>
)
}

You can export table data to CSV format. When exporting row expansion tables, keep in mind:

  • Expanded content is not exported - Only the main row data is included in the CSV
  • Exclude the expand column - The expand column should be excluded from exports as it’s just a UI control
  • Export respects filters - Only visible/filtered rows are exported

The simplest way to add export functionality:

import { DataTableExportButton } from "@/components/niko-table/components"
export function RowExpansionTableWithExport({ data }: { data: Order[] }) {
return (
<DataTableRoot
data={data}
columns={columns}
config={{
enableExpanding: true,
}}
getRowCanExpand={row => row.original.items.length > 0}
>
<DataTableToolbarSection>
<DataTableSearchFilter placeholder="Search orders..." />
<DataTableExportButton
filename="orders"
excludeColumns={["expand"] as unknown as (keyof Order)[]}
/>
<DataTableViewMenu />
</DataTableToolbarSection>
<DataTable>
<DataTableHeader />
<DataTableBody />
<DataTableEmptyBody />
</DataTable>
<DataTablePagination />
</DataTableRoot>
)
}

For more control, use the exportTableToCSV function directly:

import { useDataTable } from "@/components/niko-table/core"
import { exportTableToCSV } from "@/components/niko-table/filters"
import { Button } from "@/components/ui/button"
import { Download } from "lucide-react"
function ExportButton() {
const { table } = useDataTable<Order>()
const handleExport = () => {
exportTableToCSV(table, {
filename: "orders",
excludeColumns: ["expand"] as unknown as (keyof Order)[],
})
}
return (
<Button size="sm" variant="outline" onClick={handleExport}>
<Download className="mr-2 h-4 w-4" />
Export CSV
</Button>
)
}
export function RowExpansionTableWithCustomExport({ data }: { data: Order[] }) {
return (
<DataTableRoot
data={data}
columns={columns}
config={{
enableExpanding: true,
}}
getRowCanExpand={row => row.original.items.length > 0}
>
<DataTableToolbarSection>
<DataTableSearchFilter placeholder="Search orders..." />
<ExportButton />
<DataTableViewMenu />
</DataTableToolbarSection>
<DataTable>
<DataTableHeader />
<DataTableBody />
<DataTableEmptyBody />
</DataTable>
<DataTablePagination />
</DataTableRoot>
)
}

If you have row selection enabled, you can export only the selected rows:

function ExportSelectedButton() {
const { table } = useDataTable<Order>()
const handleExport = () => {
exportTableToCSV(table, {
filename: "selected-orders",
excludeColumns: ["expand", "select"] as unknown as (keyof Order)[],
onlySelected: true,
})
}
const selectedCount = table.getFilteredSelectedRowModel().rows.length
if (selectedCount === 0) return null
return (
<Button size="sm" variant="outline" onClick={handleExport}>
<Download className="mr-2 h-4 w-4" />
Export Selected ({selectedCount})
</Button>
)
}
  1. Expanded content is not included: The CSV export only includes the main row data. Expanded content (like order items) is not exported. If you need to export nested data, you’ll need to flatten it into additional columns or create a custom export function.

  2. Always exclude the expand column: The expand column (id: "expand") should be excluded from exports as it’s purely a UI control:

excludeColumns={["expand"] as unknown as (keyof Order)[]}
  1. Export respects current filters: The export function respects all active filters, sorting, and pagination. Only the currently visible/filtered rows are exported.

✅ Use Row Expansion Table when:

  • You need to show additional details for specific rows
  • You want to keep users in context (no navigation)
  • The expanded content is directly related to the row
  • You prefer inline expansion over sidebars

❌ Consider other options when: