Skip to content
All examples

Invoices

Search, status filters, sortable columns, row selection and actions for the selected rows. On a phone, the less important columns give way.

Add this example to a project that has prfct installed. The command writes the files below and installs the components they use.

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/invoices.json
components/invoices/invoices.tsx
"use client"

import * as React from "react"
import {
  ArrowDownIcon,
  ArrowUpIcon,
  ArrowUpDownIcon,
  SearchIcon,
  Trash2Icon,
  XIcon,
} from "lucide-react"

import { Badge, BadgeDot } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { cn } from "@/lib/utils"

type Status = "Paid" | "Pending" | "Overdue"
type Invoice = {
  id: string
  customer: string
  amount: number
  status: Status
  due: string
}

const invoices: Invoice[] = [
  {
    id: "INV-2041",
    customer: "Northwind Traders",
    amount: 12400,
    status: "Paid",
    due: "2026-09-02",
  },
  {
    id: "INV-2042",
    customer: "Halden Studio",
    amount: 3180.5,
    status: "Pending",
    due: "2026-09-28",
  },
  {
    id: "INV-2043",
    customer: "Meridian Labs",
    amount: 890,
    status: "Overdue",
    due: "2026-08-30",
  },
  {
    id: "INV-2044",
    customer: "Kestrel Health",
    amount: 25600,
    status: "Paid",
    due: "2026-09-10",
  },
  {
    id: "INV-2045",
    customer: "Lumen Freight",
    amount: 7420.75,
    status: "Pending",
    due: "2026-10-04",
  },
  {
    id: "INV-2046",
    customer: "Arbor & Finch",
    amount: 1999,
    status: "Overdue",
    due: "2026-09-01",
  },
]

const statusVariant = {
  Paid: "success",
  Pending: "warning",
  Overdue: "destructive",
} as const
// The unit lives in the column header, so cells hold plain numbers. Grouping
// always keeps 4- and 5-digit amounts aligned in languages that leave 4-digit
// numbers ungrouped (Polish: 7 420,75 next to 25 600,00).
const amount = new Intl.NumberFormat("en-US", {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
  useGrouping: "always",
})
// Due dates are calendar days: formatted in UTC, the server and a browser in
// any time zone print the same day.
const date = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  timeZone: "UTC",
})

type SortKey = "customer" | "amount" | "due"
type Sort = { key: SortKey; dir: "asc" | "desc" }

function SortButton({
  column,
  sort,
  onSort,
  children,
}: {
  column: SortKey
  sort: Sort
  onSort: (sort: Sort) => void
  children: React.ReactNode
}) {
  const active = sort.key === column
  const Icon = !active
    ? ArrowUpDownIcon
    : sort.dir === "asc"
      ? ArrowUpIcon
      : ArrowDownIcon
  return (
    <button
      type="button"
      onClick={() =>
        onSort({
          key: column,
          dir: active && sort.dir === "asc" ? "desc" : "asc",
        })
      }
      className={cn(
        // A 24px target around the label (WCAG 2.5.8).
        "-mx-1.5 inline-flex h-6 items-center gap-1 rounded-md px-1.5 focus-ring-inset transition-colors duration-fast hover:text-foreground in-[.text-right]:flex-row-reverse",
        active && "text-foreground"
      )}
    >
      {children}
      <Icon className="size-3" aria-hidden="true" />
    </button>
  )
}

export default function DataTable() {
  const [query, setQuery] = React.useState("")
  const [status, setStatus] = React.useState<string[]>(["all"])
  const [sort, setSort] = React.useState<Sort>({ key: "due", dir: "asc" })
  const [selected, setSelected] = React.useState<Set<string>>(new Set())
  const [rows, setRows] = React.useState(invoices)

  const visible = React.useMemo(() => {
    const filter = status[0] ?? "all"
    return rows
      .filter((row) => filter === "all" || row.status === filter)
      .filter((row) =>
        `${row.id} ${row.customer}`.toLowerCase().includes(query.toLowerCase())
      )
      .sort((a, b) => {
        const result =
          sort.key === "amount"
            ? a.amount - b.amount
            : a[sort.key].localeCompare(b[sort.key])
        return sort.dir === "asc" ? result : -result
      })
  }, [rows, status, query, sort])

  // The selection bar acts on the rows in view: a selected row that the
  // filter or the search hides is neither counted nor deleted.
  const selectedRows = visible.filter((row) => selected.has(row.id))
  const allSelected =
    visible.length > 0 && selectedRows.length === visible.length
  const someSelected = selectedRows.length > 0

  function toggleAll() {
    setSelected(allSelected ? new Set() : new Set(visible.map((row) => row.id)))
  }

  function toggle(id: string) {
    setSelected((current) => {
      const next = new Set(current)
      if (next.has(id)) next.delete(id)
      else next.add(id)
      return next
    })
  }

  const ariaSort = (column: SortKey) =>
    sort.key === column
      ? sort.dir === "asc"
        ? "ascending"
        : "descending"
      : "none"

  return (
    <div className="@container flex w-full flex-col gap-3">
      <div className="flex flex-col gap-2 @lg:flex-row @lg:items-center">
        <InputGroup className="h-8 w-full @lg:w-64">
          <InputGroupAddon>
            <SearchIcon />
          </InputGroupAddon>
          <InputGroupInput
            placeholder="Search invoices"
            aria-label="Search invoices"
            value={query}
            onChange={(event) => setQuery(event.target.value)}
          />
        </InputGroup>
        {/* On narrow screens the filters share the full width equally. */}
        <ToggleGroup
          value={status}
          onValueChange={(value) => value.length && setStatus(value)}
          variant="outline"
          spacing={0}
          size="sm"
          aria-label="Filter by status"
          className="w-full @lg:w-auto"
        >
          {["all", "Paid", "Pending", "Overdue"].map((value) => (
            <ToggleGroupItem
              key={value}
              value={value}
              className="flex-1 px-2 capitalize @lg:flex-none @lg:px-2.5"
            >
              {value}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </div>

      <div className="relative overflow-hidden rounded-xl border bg-card shadow-raised">
        {/* Selection actions cover the header labels, next to the select-all
            checkbox, so the rows below never move. */}
        {someSelected && (
          <div className="absolute top-0 right-0 left-10 z-raised flex h-10 items-center gap-3 bg-card pr-4 pl-3 text-sm">
            <span className="font-medium numeric">
              {`Selected: ${selectedRows.length}`}
            </span>
            <Button
              variant="ghost"
              size="xs"
              className="text-danger-11 hover:bg-danger-3"
              onClick={() => {
                setRows((current) =>
                  current.filter((row) => !selectedRows.includes(row))
                )
                setSelected(new Set())
              }}
            >
              <Trash2Icon data-icon="inline-start" />
              Delete
            </Button>
            <Button
              variant="ghost"
              size="icon-xs"
              className="ml-auto"
              aria-label="Clear selection"
              onClick={() => setSelected(new Set())}
            >
              <XIcon />
            </Button>
          </div>
        )}
        {/* Fixed columns: filtering and sorting never shift them. Phones drop
            the invoice number and due date so the amount stays in view. */}
        <Table className="table-fixed">
          <TableHeader>
            <TableRow>
              <TableHead className="w-10">
                <Checkbox
                  aria-label="Select all invoices"
                  checked={allSelected}
                  indeterminate={someSelected && !allSelected}
                  onCheckedChange={toggleAll}
                />
              </TableHead>
              <TableHead className="w-28 @max-xl:hidden">Invoice</TableHead>
              <TableHead aria-sort={ariaSort("customer")}>
                <SortButton column="customer" sort={sort} onSort={setSort}>
                  Customer
                </SortButton>
              </TableHead>
              <TableHead className="w-32 @max-lg:hidden">Status</TableHead>
              <TableHead
                aria-sort={ariaSort("due")}
                className="w-32 @max-2xl:hidden"
              >
                <SortButton column="due" sort={sort} onSort={setSort}>
                  Due
                </SortButton>
              </TableHead>
              <TableHead
                aria-sort={ariaSort("amount")}
                className="w-32 text-right"
              >
                <SortButton column="amount" sort={sort} onSort={setSort}>
                  Amount (USD)
                </SortButton>
              </TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {visible.map((row) => (
              <TableRow
                key={row.id}
                data-state={selected.has(row.id) ? "selected" : undefined}
              >
                <TableCell>
                  <Checkbox
                    aria-label={`Select ${row.id}`}
                    checked={selected.has(row.id)}
                    onCheckedChange={() => toggle(row.id)}
                  />
                </TableCell>
                <TableCell className="font-mono text-xs text-muted-foreground @max-xl:hidden">
                  {row.id}
                </TableCell>
                <TableCell className="font-medium">
                  <span className="block truncate">{row.customer}</span>
                  {/* Narrow tables fold the status under the customer. */}
                  <span className="mt-1 block @lg:hidden">
                    <Badge variant={statusVariant[row.status]}>
                      <BadgeDot />
                      {row.status}
                    </Badge>
                  </span>
                </TableCell>
                <TableCell className="@max-lg:hidden">
                  <Badge variant={statusVariant[row.status]}>
                    <BadgeDot />
                    {row.status}
                  </Badge>
                </TableCell>
                <TableCell className="text-muted-foreground @max-2xl:hidden">
                  {date.format(new Date(`${row.due}T12:00:00Z`))}
                </TableCell>
                <TableCell className="text-right font-medium">
                  {amount.format(row.amount)}
                </TableCell>
              </TableRow>
            ))}
            {visible.length === 0 && (
              <TableRow>
                <TableCell
                  colSpan={6}
                  className="h-28 text-center whitespace-normal text-muted-foreground"
                >
                  No invoices match your filters.{" "}
                  <Button
                    variant="link"
                    onClick={() => {
                      setQuery("")
                      setStatus(["all"])
                    }}
                  >
                    Clear filters
                  </Button>
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
      </div>
      <p className="text-xs text-muted-foreground numeric">
        {`${visible.length} of ${rows.length} invoices`}
      </p>
    </div>
  )
}
app/invoices/page.tsx
import type { Metadata } from "next"

import Invoices from "@/components/invoices/invoices"

export const metadata: Metadata = {
  title: "Invoices",
  description: "A data table with search, status filters, sortable columns, row selection and bulk actions.",
}

export default function InvoicesPage() {
  return (
    <main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
      <Invoices />
    </main>
  )
}