Search for pages, actions, and quick links.
Ranked lists, compact tables and league tables. 9 blocks.
Top pages, top products, top anything — share shown inline.
src/components/blocks/tables/list-ranked.tsx"use client";
const ROWS = [
{ label: "/pricing", value: 18420 },
{ label: "/docs/getting-started", value: 14210 },
{ label: "/blog/whats-new", value: 11890 },
{ label: "/changelog", value: 8340 },
{ label: "/integrations", value: 6120 },
];
/**
* Top pages, top products, top anything.
*
* The bar is a background on the row rather than a separate element, so the label sits *on* its
* own share and the list reads as one column instead of two. Widths are relative to the largest
* value, not to the total: with a long tail, share-of-total makes every bar after the second one
* invisible.
*/
export function ListRanked() {
const max = Math.max(...ROWS.map((row) => row.value));
return (
<ol className="space-y-1">
{ROWS.map((row, index) => (
<li key={row.label} className="relative overflow-hidden rounded-md">
<div
className="absolute inset-y-0 start-0 bg-primary/10"
style={{ width: `${(row.value / max) * 100}%` }}
aria-hidden="true"
/>
<div className="relative flex items-center gap-3 px-3 py-2 text-sm">
<span className="w-4 shrink-0 text-xs tabular-nums text-muted-foreground">
{index + 1}
</span>
<span className="min-w-0 flex-1 truncate font-medium">{row.label}</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{row.value.toLocaleString("en-US")}
</span>
</div>
</li>
))}
</ol>
);
}A read-only table for a dashboard panel, not a CRUD screen.
src/components/blocks/tables/table-compact.tsx"use client";
import { Badge } from "@dashboardpack/core/components/ui/badge";
const ROWS = [
{ id: "INV-2043", client: "Northwind Ltd", due: "Aug 12", amount: 4200, status: "Paid" },
{ id: "INV-2042", client: "Globex", due: "Aug 08", amount: 1850, status: "Pending" },
{ id: "INV-2041", client: "Initech", due: "Jul 30", amount: 9600, status: "Overdue" },
{ id: "INV-2040", client: "Umbrella Co", due: "Jul 28", amount: 2750, status: "Paid" },
];
const tone: Record<string, "success" | "warning" | "destructive"> = {
Paid: "success",
Pending: "warning",
Overdue: "destructive",
};
/**
* A read-only table for a dashboard panel.
*
* Deliberately not the DataTable: this has no sorting, filtering, selection or pagination, and
* bringing in a table library for four rows would ship a great deal of machinery to render a
* caption and a `<tbody>`. Reach for DataTable when the data needs operating on, not displaying.
*
* The numeric column is `text-end` and `tabular-nums`, so the digits line up in a column — the
* one formatting detail that makes a figures table readable.
*/
export function TableCompact() {
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<caption className="sr-only">Recent invoices</caption>
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th scope="col" className="px-2 py-2 text-start font-medium">Invoice</th>
<th scope="col" className="px-2 py-2 text-start font-medium">Client</th>
<th scope="col" className="px-2 py-2 text-start font-medium">Due</th>
<th scope="col" className="px-2 py-2 text-end font-medium">Amount</th>
<th scope="col" className="px-2 py-2 text-end font-medium">Status</th>
</tr>
</thead>
<tbody>
{ROWS.map((row) => (
<tr key={row.id} className="border-b border-border/60 last:border-0">
<td className="px-2 py-2.5 font-mono text-xs">{row.id}</td>
<td className="px-2 py-2.5 font-medium">{row.client}</td>
<td className="px-2 py-2.5 text-muted-foreground">{row.due}</td>
<td className="px-2 py-2.5 text-end tabular-nums">
${row.amount.toLocaleString("en-US")}
</td>
<td className="px-2 py-2.5 text-end">
<Badge variant={tone[row.status]} className="text-[11px]">
{row.status}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}Rows with a position, a delta and a figure.
src/components/blocks/tables/table-league.tsx"use client";
import { ArrowDown, ArrowUp, Minus } from "lucide-react";
import { cn } from "@dashboardpack/core/lib/utils";
const ROWS = [
{ name: "Sofia Garcia", team: "Enterprise", value: 184200, move: 2 },
{ name: "James Chen", team: "Mid-market", value: 162800, move: 0 },
{ name: "Emma Wilson", team: "Enterprise", value: 148400, move: -1 },
{ name: "Alex Thompson", team: "SMB", value: 121900, move: 3 },
{ name: "Maria Santos", team: "Mid-market", value: 98600, move: -2 },
];
/**
* Rows with a position, a delta and a figure.
*
* The movement arrow needs its direction in text as well as in colour and glyph — `sr-only`
* below — because "up two places" is the entire content of that cell and an icon conveys none of
* it to a screen reader. A zero move renders as a dash rather than an arrow: there is no
* direction to show.
*/
export function TableLeague() {
return (
<ol className="divide-y divide-border">
{ROWS.map((row, index) => (
<li key={row.name} className="flex items-center gap-3 py-2.5">
<span className="w-5 shrink-0 text-center text-sm font-semibold tabular-nums text-muted-foreground">
{index + 1}
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{row.name}</p>
<p className="truncate text-xs text-muted-foreground">{row.team}</p>
</div>
<span
className={cn(
"flex shrink-0 items-center gap-0.5 text-xs font-medium tabular-nums",
row.move > 0 && "text-success",
row.move < 0 && "text-destructive",
row.move === 0 && "text-muted-foreground",
)}
>
{row.move > 0 && <ArrowUp className="size-3" aria-hidden="true" />}
{row.move < 0 && <ArrowDown className="size-3" aria-hidden="true" />}
{row.move === 0 && <Minus className="size-3" aria-hidden="true" />}
{row.move !== 0 && Math.abs(row.move)}
<span className="sr-only">
{row.move === 0
? "no change in position"
: `${row.move > 0 ? "up" : "down"} ${Math.abs(row.move)} places`}
</span>
</span>
<span className="w-20 shrink-0 text-end text-sm font-semibold tabular-nums">
${(row.value / 1000).toFixed(1)}K
</span>
</li>
))}
</ol>
);
}A label and its context, with a value on the end.
src/components/blocks/tables/list-two-line.tsx"use client";
import { Box, CreditCard, Globe, Server } from "lucide-react";
const ROWS = [
{ icon: Server, name: "Compute", detail: "12 instances · eu-west-1", value: "$1,284.00" },
{ icon: Box, name: "Object storage", detail: "4.2 TB stored", value: "$96.40" },
{ icon: Globe, name: "Bandwidth", detail: "18.7 TB egress", value: "$412.00" },
{ icon: CreditCard, name: "Support plan", detail: "Business, annual", value: "$250.00" },
];
/**
* A label and its context, with a value on the end.
*
* The second line is what makes this worth having over a plain list: "Compute · $1,284" invites
* the question the detail line answers. Icons are `aria-hidden` — each one duplicates the label
* beside it, and announcing both would just double the row.
*/
export function ListTwoLine() {
return (
<ul className="divide-y divide-border">
{ROWS.map((row) => {
const Icon = row.icon;
return (
<li key={row.name} className="flex items-center gap-3 py-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-muted">
<Icon className="size-4 text-muted-foreground" aria-hidden="true" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{row.name}</p>
<p className="truncate text-xs text-muted-foreground">{row.detail}</p>
</div>
<span className="shrink-0 text-sm font-semibold tabular-nums">{row.value}</span>
</li>
);
})}
</ul>
);
}The attribute hand-rolled tables omit, so sort direction is announced.
src/components/blocks/tables/table-sortable-header.tsx"use client";
import { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@dashboardpack/core/components/ui/table";
import { ArrowDown, ArrowUp, ChevronsUpDown } from "lucide-react";
import { cn } from "@dashboardpack/core/lib/utils";
/**
* A sortable table whose sort state is exposed to assistive tech.
*
* `aria-sort` on the `<th>` is the whole point, and it is the attribute hand-rolled tables almost
* always omit. Without it the sort direction lives only in an icon, so a screen-reader user can
* activate the column header and get no confirmation that anything happened — or which way.
*
* Only the active column carries `aria-sort`; the spec allows exactly one sorted column per table,
* and setting `"none"` on the rest is legal but noisy. The unsorted columns get the neutral
* double-chevron so they still read as sortable.
*
* The header button spans the cell and carries its own accessible name including the next action —
* "Total, sorted descending, activate to sort ascending" — because the icon alone does not say what
* clicking will do.
*/
export function TableSortableHeader() {
const columns = [
{ id: "order", label: "Order", numeric: false },
{ id: "customer", label: "Customer", numeric: false },
{ id: "total", label: "Total", numeric: true },
] as const;
const rows = [
{ order: "ORD-7891", customer: "Ana Whitfield", total: 1248 },
{ order: "ORD-7890", customer: "Marcus Oyelaran", total: 842.5 },
{ order: "ORD-7889", customer: "Priya Raman", total: 318.9 },
{ order: "ORD-7888", customer: "Tomas Berg", total: 96 },
];
const [sort, setSort] = useState<{ id: string; desc: boolean }>({ id: "total", desc: true });
const sorted = [...rows].sort((a, b) => {
const key = sort.id as keyof typeof a;
const left = a[key];
const right = b[key];
const cmp =
typeof left === "number" && typeof right === "number"
? left - right
: String(left).localeCompare(String(right));
return sort.desc ? -cmp : cmp;
});
return (
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
{columns.map((column) => {
const active = sort.id === column.id;
const Icon = !active ? ChevronsUpDown : sort.desc ? ArrowDown : ArrowUp;
const direction = sort.desc ? "descending" : "ascending";
return (
<TableHead
key={column.id}
// Exactly one column carries aria-sort, per the ARIA spec.
aria-sort={active ? (sort.desc ? "descending" : "ascending") : undefined}
className={column.numeric ? "text-end" : undefined}
>
<button
type="button"
onClick={() =>
setSort((prev) =>
prev.id === column.id
? { id: column.id, desc: !prev.desc }
: { id: column.id, desc: true },
)
}
aria-label={
active
? `${column.label}, sorted ${direction}. Activate to sort ${
sort.desc ? "ascending" : "descending"
}.`
: `${column.label}, not sorted. Activate to sort descending.`
}
className={cn(
"inline-flex items-center gap-1.5 text-xs font-medium transition-colors hover:text-foreground",
active ? "text-foreground" : "text-muted-foreground",
column.numeric && "flex-row-reverse",
)}
>
{column.label}
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</TableHead>
);
})}
</TableRow>
</TableHeader>
<TableBody>
{sorted.map((row) => (
<TableRow key={row.order}>
<TableCell className="font-mono text-xs">{row.order}</TableCell>
<TableCell className="text-sm">{row.customer}</TableCell>
<TableCell className="text-end text-sm tabular-nums">
${row.total.toLocaleString("en-US", { minimumFractionDigits: 2 })}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}Never-had-data and filtered-to-nothing need different copy and actions.
src/components/blocks/tables/table-empty-filtered.tsx"use client";
import { useState } from "react";
import { Button } from "@dashboardpack/core/components/ui/button";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { SearchX, Inbox } from "lucide-react";
/**
* The two empty states a table has, which are not the same state.
*
* "No orders yet" and "no orders match your filters" need different copy and different actions. The
* first is an onboarding moment and should offer creation; the second is a dead end caused by the
* user's own filters and should offer to clear them. Shipping one message for both is why users
* click "Create your first order" on an account with 4,000 orders.
*
* The filtered state names the filters it is blaming, so the way out is obvious without reopening a
* filter panel to discover what is set.
*/
export function TableEmptyFiltered() {
const [filtered, setFiltered] = useState(true);
return (
<div className="space-y-3">
<Button variant="outline" size="sm" onClick={() => setFiltered((prev) => !prev)}>
{filtered ? "Show the never-had-data state" : "Show the filtered-to-nothing state"}
</Button>
<div className="rounded-lg border">
<div className="border-b px-4 py-2">
<p className="text-sm font-medium">Orders</p>
</div>
<div className="flex flex-col items-center justify-center gap-3 p-10 text-center">
<span className="flex h-11 w-11 items-center justify-center rounded-full bg-muted text-muted-foreground">
{filtered ? (
<SearchX className="h-5 w-5" aria-hidden="true" />
) : (
<Inbox className="h-5 w-5" aria-hidden="true" />
)}
</span>
{filtered ? (
<>
<div className="space-y-1">
<p className="text-sm font-medium">No orders match these filters</p>
<p className="text-sm text-muted-foreground">
1,284 orders exist, but none are both of these:
</p>
</div>
<div className="flex flex-wrap justify-center gap-2">
<Badge variant="secondary">Status is Refunded</Badge>
<Badge variant="secondary">Channel is In-store</Badge>
</div>
<Button variant="outline" size="sm">
Clear filters
</Button>
</>
) : (
<>
<div className="space-y-1">
<p className="text-sm font-medium">No orders yet</p>
<p className="text-sm text-muted-foreground">
Orders will appear here as soon as your first sale comes in.
</p>
</div>
<Button size="sm">Create an order</Button>
</>
)}
</div>
</div>
</div>
);
}indeterminate is a DOM property, so it needs an effect.
src/components/blocks/tables/table-row-select.tsx"use client";
import { useRef, useEffect, useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@dashboardpack/core/components/ui/table";
/**
* Row selection with a genuinely indeterminate header checkbox.
*
* `indeterminate` is not an attribute — it exists only as a DOM property, so it cannot be set in
* JSX and has to be written in an effect. Skipping it leaves the header showing *unchecked* when
* some rows are selected, which reads as "nothing selected" while a bulk action would apply to
* four rows.
*
* The header checkbox's accessible name changes with its meaning: "Select all" versus "Clear
* selection". A control whose single label is "Select all" but which sometimes deselects is
* mislabelled half the time.
*/
export function TableRowSelect() {
const rows = [
{ id: "ORD-7891", customer: "Ana Whitfield", total: "$1,248.00" },
{ id: "ORD-7890", customer: "Marcus Oyelaran", total: "$842.50" },
{ id: "ORD-7889", customer: "Priya Raman", total: "$318.90" },
{ id: "ORD-7888", customer: "Tomas Berg", total: "$96.00" },
];
const [selected, setSelected] = useState<string[]>(["ORD-7890", "ORD-7889"]);
const headerRef = useRef<HTMLInputElement>(null);
const all = selected.length === rows.length;
const some = selected.length > 0 && !all;
useEffect(() => {
// Property, not attribute: there is no way to express this in JSX.
if (headerRef.current) headerRef.current.indeterminate = some;
}, [some]);
return (
<div className="space-y-2">
<p role="status" className="text-xs tabular-nums text-muted-foreground">
{selected.length} of {rows.length} selected
</p>
<div className="overflow-x-auto rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<input
ref={headerRef}
type="checkbox"
checked={all}
onChange={() => setSelected(all ? [] : rows.map((row) => row.id))}
aria-label={selected.length > 0 ? "Clear selection" : "Select all rows"}
className="size-4 accent-primary"
/>
</TableHead>
<TableHead>Order</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="text-end">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => {
const checked = selected.includes(row.id);
return (
<TableRow key={row.id} data-state={checked ? "selected" : undefined}>
<TableCell>
<input
type="checkbox"
checked={checked}
onChange={() =>
setSelected((previous) =>
checked
? previous.filter((id) => id !== row.id)
: [...previous, row.id],
)
}
aria-label={`Select ${row.id}`}
className="size-4 accent-primary"
/>
</TableCell>
<TableCell className="font-mono text-xs">{row.id}</TableCell>
<TableCell className="text-sm">{row.customer}</TableCell>
<TableCell className="text-end text-sm tabular-nums">{row.total}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
);
}A second tr, because a div inside tbody is invalid HTML.
src/components/blocks/tables/table-expandable-rows.tsx"use client";
import { Fragment, useState } from "react";
import { Badge } from "@dashboardpack/core/components/ui/badge";
import { cn } from "@dashboardpack/core/lib/utils";
import { ChevronRight } from "lucide-react";
/**
* Expandable rows, built as real table markup.
*
* The detail is a second `<tr>` with a `colSpan` cell, not a `<div>` after the table. Nesting a
* div inside `<tbody>` is invalid HTML that browsers silently hoist out of the table, which
* scrambles the layout in ways that look like a CSS bug.
*
* The trigger carries `aria-expanded` and `aria-controls` pointing at the detail row's id, so the
* relationship is announced. The chevron rotation is `rtl:rotate-180` on top of the open rotation,
* because a collapsed chevron should point along the reading direction.
*/
export function TableExpandableRows() {
const orders = [
{
id: "ORD-7891",
customer: "Ana Whitfield",
total: "$1,248.00",
status: "Paid",
items: ["Platform licence × 1", "Additional seats × 12", "Onboarding × 1"],
},
{
id: "ORD-7890",
customer: "Marcus Oyelaran",
total: "$842.50",
status: "Pending",
items: ["Platform licence × 1", "Additional seats × 4"],
},
];
const [open, setOpen] = useState<string[]>(["ORD-7891"]);
return (
<div className="overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th scope="col" className="w-10" />
<th scope="col" className="px-4 py-2 text-start font-medium">Order</th>
<th scope="col" className="px-4 py-2 text-start font-medium">Customer</th>
<th scope="col" className="px-4 py-2 text-start font-medium">Status</th>
<th scope="col" className="px-4 py-2 text-end font-medium">Total</th>
</tr>
</thead>
<tbody>
{orders.map((order) => {
const expanded = open.includes(order.id);
return (
/* The key belongs on the Fragment, not on its children: React keys the element
returned by the map callback, so keying the <tr>s leaves the Fragment unkeyed. */
<Fragment key={order.id}>
<tr className="border-b">
<td className="ps-2">
<button
type="button"
aria-expanded={expanded}
aria-controls={`detail-${order.id}`}
aria-label={`${expanded ? "Collapse" : "Expand"} ${order.id}`}
onClick={() =>
setOpen((previous) =>
expanded
? previous.filter((id) => id !== order.id)
: [...previous, order.id],
)
}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<ChevronRight
aria-hidden="true"
className={cn(
"h-4 w-4 transition-transform",
expanded ? "rotate-90" : "rtl:rotate-180",
)}
/>
</button>
</td>
<td className="px-4 py-2 font-mono text-xs">{order.id}</td>
<td className="px-4 py-2">{order.customer}</td>
<td className="px-4 py-2">
<Badge variant={order.status === "Paid" ? "success" : "secondary"}>
{order.status}
</Badge>
</td>
<td className="px-4 py-2 text-end tabular-nums">{order.total}</td>
</tr>
{expanded && (
/* A real <tr>, not a div after the table — a div inside <tbody> is invalid
and browsers hoist it out, breaking the layout in confusing ways. */
<tr id={`detail-${order.id}`} className="border-b bg-muted/30">
<td colSpan={5} className="px-4 py-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Line items
</p>
<ul className="space-y-1">
{order.items.map((item) => (
<li key={item} className="text-xs text-muted-foreground">
{item}
</li>
))}
</ul>
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
);
}