Row Expansion Table
Add expandable rows to show additional details inline.
"use client"
import * as React from "react"import { DataTableRoot, DataTable, DataTableHeader, DataTableBody, DataTableEmptyBody,} from "@/components/niko-table/core"import { DataTableToolbarSection, DataTablePagination, DataTableSearchFilter, DataTableViewMenu, DataTableEmptyFilteredMessage, DataTableEmptyTitle, DataTableEmptyDescription, DataTableEmptyIcon, DataTableEmptyMessage, DataTableColumnHeader, DataTableColumnTitle, DataTableColumnSortMenu,} from "@/components/niko-table/components"import { SYSTEM_COLUMN_IDS, FILTER_VARIANTS } from "@/components/niko-table/lib"import type { DataTableColumnDef } from "@/components/niko-table/types"import { Button } from "@/components/ui/button"import { Badge } from "@/components/ui/badge"import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"import { Separator } from "@/components/ui/separator"import { ChevronRight, ChevronDown, UserSearch, SearchX } from "lucide-react"
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[]}
const data: Order[] = [ { id: "1", orderNumber: "ORD-001", customer: "John Smith", total: 459.97, status: "shipped", date: "2024-01-15", items: [ { id: "1-1", productName: "Wireless Headphones", category: "Electronics", price: 199.99, quantity: 1, }, { id: "1-2", productName: "Phone Case", category: "Accessories", price: 29.99, quantity: 2, }, { id: "1-3", productName: "Screen Protector", category: "Accessories", price: 19.99, quantity: 1, }, ], }, { id: "2", orderNumber: "ORD-002", customer: "Sarah Johnson", total: 249.48, status: "processing", date: "2024-01-16", items: [ { id: "2-1", productName: "Running Shoes", category: "Sports", price: 89.99, quantity: 1, }, { id: "2-2", productName: "Sports Socks", category: "Sports", price: 19.99, quantity: 3, }, { id: "2-3", productName: "Water Bottle", category: "Sports", price: 24.99, quantity: 1, }, ], }, { id: "3", orderNumber: "ORD-003", customer: "Mike Davis", total: 189.97, status: "delivered", date: "2024-01-14", items: [ { id: "3-1", productName: "Coffee Maker", category: "Home", price: 129.99, quantity: 1, }, { id: "3-2", productName: "Coffee Beans", category: "Food", price: 24.99, quantity: 1, }, { id: "3-3", productName: "Coffee Grinder", category: "Home", price: 49.99, quantity: 1, }, ], }, { id: "4", orderNumber: "ORD-004", customer: "Emily Wilson", total: 299.97, status: "cancelled", date: "2024-01-13", items: [ { id: "4-1", productName: "Laptop Stand", category: "Electronics", price: 99.99, quantity: 1, }, { id: "4-2", productName: "Wireless Mouse", category: "Electronics", price: 49.99, quantity: 1, }, { id: "4-3", productName: "Keyboard", category: "Electronics", price: 79.99, quantity: 1, }, ], },]
// Expanded content componentfunction OrderDetails({ order }: { order: Order }) { return ( <div className="bg-muted/30 p-6"> <Card> <CardHeader> <CardTitle className="text-base">Order Items</CardTitle> </CardHeader> <CardContent> <div className="space-y-4"> {/* Order items list */} <div className="space-y-2"> {order.items.map(item => ( <div key={item.id} className="flex items-center justify-between rounded-lg border bg-card p-3" > <div className="flex-1"> <div className="font-medium">{item.productName}</div> <div className="text-sm text-muted-foreground"> {item.category} </div> </div> <div className="flex items-center gap-4"> <div className="text-sm text-muted-foreground"> Qty: {item.quantity} </div> <div className="font-mono font-medium"> ${item.price.toFixed(2)} </div> </div> </div> ))} </div>
<Separator />
{/* Order summary */} <div className="space-y-2"> <div className="flex justify-between text-sm"> <span className="text-muted-foreground">Subtotal</span> <span className="font-mono">${order.total.toFixed(2)}</span> </div> <div className="flex justify-between text-sm"> <span className="text-muted-foreground">Shipping</span> <span className="font-mono">$0.00</span> </div> <div className="flex justify-between text-sm"> <span className="text-muted-foreground">Tax</span> <span className="font-mono"> ${(order.total * 0.1).toFixed(2)} </span> </div> <Separator /> <div className="flex justify-between font-medium"> <span>Total</span> <span className="font-mono"> ${(order.total * 1.1).toFixed(2)} </span> </div> </div> </div> </CardContent> </Card> </div> )}
export default function RowExpansionSimpleExample() { const columns: DataTableColumnDef<Order>[] = React.useMemo( () => [ { // Auto-detected: column with id="expand" or meta.expandedContent enables row expansion id: SYSTEM_COLUMN_IDS.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 hover:bg-accent" > {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: { // Setting expandedContent triggers auto-detection of row expansion expandedContent: (row: Order) => <OrderDetails order={row} />, }, }, { accessorKey: "orderNumber", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Order #" /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => ( <div className="font-mono font-medium"> {row.getValue("orderNumber")} </div> ), }, { accessorKey: "customer", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Customer" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} /> </DataTableColumnHeader> ), cell: ({ row }) => { return ( <div> <div className="font-medium">{row.getValue("customer")}</div> <div className="text-sm text-muted-foreground"> {row.original.email} </div> </div> ) }, }, { accessorKey: "items", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Items" /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => { const itemCount = row.original.items.length return ( <div className="text-sm"> {itemCount} {itemCount === 1 ? "item" : "items"} </div> ) }, }, { accessorKey: "date", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Date" /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => ( <div className="text-sm"> {new Date(row.getValue("date")).toLocaleDateString()} </div> ), }, { accessorKey: "total", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Total" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> </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 variant={FILTER_VARIANTS.TEXT} /> </DataTableColumnHeader> ), cell: ({ row }) => { const status = row.getValue("status") as string return ( <Badge variant={ status === "delivered" ? "default" : status === "shipped" ? "secondary" : status === "processing" ? "outline" : "destructive" } > {status} </Badge> ) }, filterFn: (row, id, value: string[]) => { return value.includes(row.getValue(id)) }, }, ], [], )
return ( <DataTableRoot data={data} columns={columns} // Optional: Only show expand button for rows with items // Without this, all rows are expandable by default getRowCanExpand={row => row.original.items.length > 0} > <DataTableToolbarSection> <DataTableSearchFilter placeholder="Search orders..." /> <DataTableViewMenu /> </DataTableToolbarSection>
<DataTable> <DataTableHeader /> <DataTableBody> <DataTableEmptyBody> <DataTableEmptyMessage> <DataTableEmptyIcon> <UserSearch className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle>No orders found</DataTableEmptyTitle> <DataTableEmptyDescription> There are no orders to display at this time. </DataTableEmptyDescription> </DataTableEmptyMessage> <DataTableEmptyFilteredMessage> <DataTableEmptyIcon> <SearchX className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle>No matches found</DataTableEmptyTitle> <DataTableEmptyDescription> Try adjusting your filters or search to find what you're looking for. </DataTableEmptyDescription> </DataTableEmptyFilteredMessage> </DataTableEmptyBody> </DataTableBody> </DataTable> <DataTablePagination /> </DataTableRoot> )}Preview with Controlled State
View Full State Object
{}[]
[]
{
"pageIndex": 0,
"pageSize": 10
}{}"use client"
import * as React from "react"import { useState } from "react"import type { PaginationState, SortingState, ColumnFiltersState, VisibilityState, ExpandedState,} from "@tanstack/react-table"import { DataTableRoot, DataTable, DataTableHeader, DataTableBody, DataTableEmptyBody,} from "@/components/niko-table/core"import { DataTableToolbarSection, DataTablePagination, DataTableSearchFilter, DataTableViewMenu, DataTableEmptyFilteredMessage, DataTableEmptyTitle, DataTableEmptyDescription, DataTableEmptyIcon, DataTableEmptyMessage, DataTableColumnHeader, DataTableColumnTitle, DataTableColumnSortMenu,} from "@/components/niko-table/components"import { SYSTEM_COLUMN_IDS, FILTER_VARIANTS } from "@/components/niko-table/lib"import type { DataTableColumnDef } from "@/components/niko-table/types"import { Button } from "@/components/ui/button"import { Badge } from "@/components/ui/badge"import { Card, CardContent, CardHeader, CardTitle, CardAction, CardDescription,} from "@/components/ui/card"import { Separator } from "@/components/ui/separator"import { ChevronRight, ChevronDown, UserSearch, SearchX } from "lucide-react"
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[]}
const data: Order[] = [ { id: "1", orderNumber: "ORD-001", customer: "John Smith", total: 459.97, status: "shipped", date: "2024-01-15", items: [ { id: "1-1", productName: "Wireless Headphones", category: "Electronics", price: 199.99, quantity: 1, }, { id: "1-2", productName: "Phone Case", category: "Accessories", price: 29.99, quantity: 2, }, { id: "1-3", productName: "Screen Protector", category: "Accessories", price: 19.99, quantity: 1, }, ], }, { id: "2", orderNumber: "ORD-002", customer: "Sarah Johnson", total: 249.48, status: "processing", date: "2024-01-16", items: [ { id: "2-1", productName: "Running Shoes", category: "Sports", price: 89.99, quantity: 1, }, { id: "2-2", productName: "Sports Socks", category: "Sports", price: 19.99, quantity: 3, }, { id: "2-3", productName: "Water Bottle", category: "Sports", price: 24.99, quantity: 1, }, ], }, { id: "3", orderNumber: "ORD-003", customer: "Mike Davis", total: 189.97, status: "delivered", date: "2024-01-14", items: [ { id: "3-1", productName: "Coffee Maker", category: "Home", price: 129.99, quantity: 1, }, { id: "3-2", productName: "Coffee Beans", category: "Food", price: 24.99, quantity: 1, }, { id: "3-3", productName: "Coffee Grinder", category: "Home", price: 49.99, quantity: 1, }, ], }, { id: "4", orderNumber: "ORD-004", customer: "Emily Wilson", total: 299.97, status: "cancelled", date: "2024-01-13", items: [ { id: "4-1", productName: "Laptop Stand", category: "Electronics", price: 99.99, quantity: 1, }, { id: "4-2", productName: "Wireless Mouse", category: "Electronics", price: 49.99, quantity: 1, }, { id: "4-3", productName: "Keyboard", category: "Electronics", price: 79.99, quantity: 1, }, ], },]
// Expanded content componentfunction OrderDetails({ order }: { order: Order }) { return ( <div className="bg-muted/30 p-6"> <Card> <CardHeader> <CardTitle className="text-base">Order Items</CardTitle> </CardHeader> <CardContent> <div className="space-y-4"> {/* Order items list */} <div className="space-y-2"> {order.items.map(item => ( <div key={item.id} className="flex items-center justify-between rounded-lg border bg-card p-3" > <div className="flex-1"> <div className="font-medium">{item.productName}</div> <div className="text-sm text-muted-foreground"> {item.category} </div> </div> <div className="flex items-center gap-4"> <div className="text-sm text-muted-foreground"> Qty: {item.quantity} </div> <div className="font-mono font-medium"> ${item.price.toFixed(2)} </div> </div> </div> ))} </div>
<Separator />
{/* Order summary */} <div className="space-y-2"> <div className="flex justify-between text-sm"> <span className="text-muted-foreground">Subtotal</span> <span className="font-mono">${order.total.toFixed(2)}</span> </div> <div className="flex justify-between text-sm"> <span className="text-muted-foreground">Shipping</span> <span className="font-mono">$0.00</span> </div> <div className="flex justify-between text-sm"> <span className="text-muted-foreground">Tax</span> <span className="font-mono"> ${(order.total * 0.1).toFixed(2)} </span> </div> <Separator /> <div className="flex justify-between font-medium"> <span>Total</span> <span className="font-mono"> ${(order.total * 1.1).toFixed(2)} </span> </div> </div> </div> </CardContent> </Card> </div> )}
export default function RowExpansionStateExample() { // Controlled state management for all table state const [globalFilter, setGlobalFilter] = useState<string | object>("") const [sorting, setSorting] = useState<SortingState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({}) const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10, }) const [expanded, setExpanded] = useState<ExpandedState>({})
const columns: DataTableColumnDef<Order>[] = React.useMemo( () => [ { // Auto-detected: column with id="expand" or meta.expandedContent enables row expansion id: SYSTEM_COLUMN_IDS.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 hover:bg-accent" > {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: { // Setting expandedContent triggers auto-detection of row expansion expandedContent: (row: Order) => <OrderDetails order={row} />, }, }, { accessorKey: "orderNumber", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Order #" /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => ( <div className="font-mono font-medium"> {row.getValue("orderNumber")} </div> ), }, { accessorKey: "customer", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Customer" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.TEXT} /> </DataTableColumnHeader> ), cell: ({ row }) => { return ( <div> <div className="font-medium">{row.getValue("customer")}</div> <div className="text-sm text-muted-foreground"> {row.original.email} </div> </div> ) }, }, { accessorKey: "items", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Items" /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => { const itemCount = row.original.items.length return ( <div className="text-sm"> {itemCount} {itemCount === 1 ? "item" : "items"} </div> ) }, }, { accessorKey: "date", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Date" /> <DataTableColumnSortMenu /> </DataTableColumnHeader> ), cell: ({ row }) => ( <div className="text-sm"> {new Date(row.getValue("date")).toLocaleDateString()} </div> ), }, { accessorKey: "total", header: () => ( <DataTableColumnHeader> <DataTableColumnTitle title="Total" /> <DataTableColumnSortMenu variant={FILTER_VARIANTS.NUMBER} /> </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 variant={FILTER_VARIANTS.TEXT} /> </DataTableColumnHeader> ), cell: ({ row }) => { const status = row.getValue("status") as string return ( <Badge variant={ status === "delivered" ? "default" : status === "shipped" ? "secondary" : status === "processing" ? "outline" : "destructive" } > {status} </Badge> ) }, filterFn: (row, id, value: string[]) => { return value.includes(row.getValue(id)) }, }, ], [], )
const resetAllState = () => { setGlobalFilter("") setSorting([]) setColumnFilters([]) setColumnVisibility({}) setPagination({ pageIndex: 0, pageSize: 10 }) setExpanded({}) }
// Calculate expanded rows metrics const expandedOrders = React.useMemo(() => { return data.filter(order => expanded[order.id as keyof typeof expanded]) }, [expanded])
const totalExpandedItems = React.useMemo(() => { return expandedOrders.reduce( (total, order) => total + order.items.length, 0, ) }, [expandedOrders])
const totalExpandedValue = React.useMemo(() => { return expandedOrders.reduce((total, order) => total + order.total, 0) }, [expandedOrders])
return ( <div className="w-full space-y-4"> <DataTableRoot data={data} columns={columns} state={{ globalFilter, sorting, columnFilters, columnVisibility, pagination, expanded, }} onGlobalFilterChange={setGlobalFilter} onSortingChange={setSorting} onColumnFiltersChange={setColumnFilters} onColumnVisibilityChange={setColumnVisibility} onPaginationChange={setPagination} onExpandedChange={setExpanded} // Optional: Only show expand button for rows with items // Without this, all rows are expandable by default getRowCanExpand={row => row.original.items.length > 0} > <DataTableToolbarSection> <DataTableSearchFilter placeholder="Search orders..." /> <DataTableViewMenu /> </DataTableToolbarSection>
<DataTable> <DataTableHeader /> <DataTableBody> <DataTableEmptyBody> <DataTableEmptyMessage> <DataTableEmptyIcon> <UserSearch className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle>No orders found</DataTableEmptyTitle> <DataTableEmptyDescription> There are no orders to display at this time. </DataTableEmptyDescription> </DataTableEmptyMessage> <DataTableEmptyFilteredMessage> <DataTableEmptyIcon> <SearchX className="size-12" /> </DataTableEmptyIcon> <DataTableEmptyTitle>No matches found</DataTableEmptyTitle> <DataTableEmptyDescription> Try adjusting your filters or search to find what you're looking for. </DataTableEmptyDescription> </DataTableEmptyFilteredMessage> </DataTableEmptyBody> </DataTableBody> </DataTable> <DataTablePagination /> </DataTableRoot>
{/* State Display for demonstration */} <Card> <CardHeader> <CardTitle>Row Expansion State</CardTitle> <CardDescription> Live view of the row expansion table state with order details </CardDescription> <CardAction> <Button variant="outline" size="sm" onClick={resetAllState}> Reset All State </Button> </CardAction> </CardHeader> <CardContent className="space-y-4"> <div className="grid gap-2 text-xs text-muted-foreground"> <div className="flex justify-between"> <span className="font-medium">Search Query:</span> <span className="text-foreground"> {typeof globalFilter === "string" ? globalFilter || "None" : "Mixed Filters"} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Total Orders:</span> <span className="text-foreground">{data.length}</span> </div>
<div className="flex justify-between"> <span className="font-medium">Expanded Orders:</span> <span className="text-foreground">{expandedOrders.length}</span> </div>
<div className="flex justify-between"> <span className="font-medium">Items in Expanded:</span> <span className="text-foreground">{totalExpandedItems}</span> </div>
<div className="flex justify-between"> <span className="font-medium">Expanded Orders Value:</span> <span className="text-foreground"> ${totalExpandedValue.toFixed(2)} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Sorting:</span> <span className="text-foreground"> {sorting.length > 0 ? sorting .map(s => `${s.id} ${s.desc ? "desc" : "asc"}`) .join(", ") : "None"} </span> </div>
<div className="flex justify-between"> <span className="font-medium">Page:</span> <span className="text-foreground"> {pagination.pageIndex + 1} (Size: {pagination.pageSize}) </span> </div>
<div className="flex justify-between"> <span className="font-medium">Hidden Columns:</span> <span className="text-foreground"> { Object.values(columnVisibility).filter(v => v === false) .length } </span> </div> </div>
{/* Detailed state (collapsible) */} <details className="border-t pt-4"> <summary className="cursor-pointer text-xs font-medium hover:text-foreground"> View Full State Object </summary> <div className="mt-4 space-y-3 text-xs"> <div> <strong>Expanded State:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(expanded, null, 2)} </pre> </div> <div> <strong>Expanded Orders Details:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify( expandedOrders.map(order => ({ id: order.id, orderNumber: order.orderNumber, customer: order.customer, itemCount: order.items.length, total: order.total, })), null, 2, )} </pre> </div> <div> <strong>Sorting:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(sorting, null, 2)} </pre> </div> <div> <strong>Pagination:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(pagination, null, 2)} </pre> </div> <div> <strong>Column Visibility:</strong> <pre className="mt-1 overflow-auto rounded bg-muted p-2"> {JSON.stringify(columnVisibility, null, 2)} </pre> </div> </div> </details> </CardContent> </Card> </div> )}Introduction
Section titled “Introduction”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.
Installation
Section titled “Installation”- Add the required components:
npx shadcn@latest add table input button dropdown-menu card separator- Add
tanstack/react-tabledependency:
npm install @tanstack/react-table- Copy the DataTable components into your project. See the Installation Guide for detailed instructions.
Prerequisites
Section titled “Prerequisites”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[]}Basic Table with Row Expansion
Section titled “Basic Table with Row Expansion”Let’s start by building a table with expandable rows.
Column Definitions
Section titled “Column Definitions”First, we’ll define our columns with an expand column.
"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 componentfunction 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> ), },]<DataTable /> component
Section titled “<DataTable /> component”Next, we’ll create the table with row expansion enabled.
"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 componentfunction 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> )}How Row Expansion Works
Section titled “How Row Expansion Works”Row expansion is enabled by:
- Adding a column with
id: "expand"or settingmeta.expandedContenton any column - Setting
enableExpanding: truein the config - Providing
expandedContentin the column’smetaproperty
Expanded Content
Section titled “Expanded Content”The expanded content is defined in the column’s meta.expandedContent:
{ id: "expand", meta: { expandedContent: (order: Order) => <OrderDetails order={order} />, },}Conditional Expansion
Section titled “Conditional Expansion”Control which rows can be expanded:
<DataTableRoot data={data} columns={columns} config={{ enableExpanding: true, }} getRowCanExpand={row => row.original.items.length > 0}> {/* ... */}</DataTableRoot>Controlled Expansion State
Section titled “Controlled Expansion State”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> )}Exporting Data
Section titled “Exporting Data”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
Using DataTableExportButton
Section titled “Using DataTableExportButton”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> )}Using exportTableToCSV Directly
Section titled “Using exportTableToCSV Directly”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> )}Exporting Only Selected Rows
Section titled “Exporting Only Selected Rows”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> )}Important Notes
Section titled “Important Notes”-
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.
-
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)[]}- Export respects current filters: The export function respects all active filters, sorting, and pagination. Only the currently visible/filtered rows are exported.
When to Use
Section titled “When to Use”✅ 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:
- You need to show details for all rows (use Aside Table)
- Expanded content is very large (use Aside Table)
- You don’t need additional details (use Basic Table)
Next Steps
Section titled “Next Steps”- Aside Table - Show details in sidebars
- Tree Table - Hierarchical data with expansion