Dashboard
Sidebar shell with stat cards, an interactive area chart and a sortable, selectable data table.
"use client"
/*
* Adapted from shadcn/ui (https://github.com/shadcn-ui/ui).
*
* MIT License
*
* Copyright (c) 2023 shadcn
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import * as React from "react"
import { SidebarInset, SidebarProvider } from "@/registry/ward/ui/sidebar"
import { AppSidebar } from "./app-sidebar"
import { ChartArea } from "./chart-area"
import { DataTable } from "./data-table"
import { cards, chartConfig, chartData, columns, rows } from "./data"
import { SectionCards } from "./section-cards"
import { SiteHeader } from "./site-header"
export default function Dashboard() {
return (
<SidebarProvider style={{ "--sidebar-width": "15rem" } as React.CSSProperties}>
<AppSidebar variant="inset" />
<SidebarInset>
<SiteHeader title="Overview" />
<div className="flex flex-col gap-4 p-4 md:gap-6 md:p-6">
<SectionCards cards={cards} />
<ChartArea data={chartData} config={chartConfig} title="Clicks by kind" />
<DataTable columns={columns} data={rows} searchKey="title" />
</div>
</SidebarInset>
</SidebarProvider>
)
}import { Separator } from "@/registry/ward/ui/separator"
import { SidebarTrigger } from "@/registry/ward/ui/sidebar"
/*
* Adapted from shadcn/ui (https://github.com/shadcn-ui/ui).
*
* MIT License
*
* Copyright (c) 2023 shadcn
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/** The bar above the page: the sidebar toggle and the page title. */
export function SiteHeader({ title }: { title: string }) {
return (
<header className="flex h-12 shrink-0 items-center gap-2 border-b border-border px-4 lg:px-6">
<SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4"
/>
<h1 className="text-base font-medium">{title}</h1>
</header>
)
}import { TrendingDownIcon, TrendingUpIcon } from "lucide-react"
import { Badge } from "@/registry/ward/ui/badge"
import {
Card,
CardAction,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/registry/ward/ui/card"
/*
* Adapted from shadcn/ui (https://github.com/shadcn-ui/ui).
*
* MIT License
*
* Copyright (c) 2023 shadcn
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
/** A row of stat cards. A `change` that starts with a minus sign shows a falling trend, any other a rising one. */
export function SectionCards({
cards,
}: {
cards: { label: string; value: string; change?: string; note?: string }[]
}) {
// The row sizes itself to its own width, so it needs no help from the page around it.
return (
<div className="@container/cards">
<div className="grid grid-cols-1 gap-4 @md/cards:grid-cols-2 @4xl/cards:grid-cols-4">
{cards.map((card) => {
const TrendIcon = /^[-−]/.test(card.change ?? "")
? TrendingDownIcon
: TrendingUpIcon
return (
<Card key={card.label} className="@container/card">
<CardHeader>
<CardDescription>{card.label}</CardDescription>
<CardTitle className="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
{card.value}
</CardTitle>
{card.change && (
<CardAction>
<Badge variant="outline">
<TrendIcon />
{card.change}
</Badge>
</CardAction>
)}
</CardHeader>
{card.note && (
<CardFooter className="text-sm text-muted-foreground">
{card.note}
</CardFooter>
)}
</Card>
)
})}
</div>
</div>
)
}"use client"
/*
* Adapted from shadcn/ui (https://github.com/shadcn-ui/ui).
*
* MIT License
*
* Copyright (c) 2023 shadcn
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import * as React from "react"
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/ward/ui/card"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
type ChartConfig,
} from "@/registry/ward/ui/chart"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/ward/ui/select"
import { Tabs, TabsList, TabsTrigger } from "@/registry/ward/ui/tabs"
const DAY = 24 * 60 * 60 * 1000
const ranges = [
{ value: "90d", days: 90, label: "90 days" },
{ value: "30d", days: 30, label: "30 days" },
{ value: "7d", days: 7, label: "7 days" },
]
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
// Turns an ISO day such as 2026-09-28 into "28 Sep". Read in UTC and spelt out here, not by Intl,
// so the server and every browser print the same text.
export function formatDate(value: string) {
const date = new Date(value)
return `${date.getUTCDate()} ${MONTHS[date.getUTCMonth()]}`
}
/**
* A stacked area chart with a date range switch. Each key in `config` is a series in `data`,
* drawn in the order given, and its colour comes from the config.
*/
export function ChartArea({
data,
config,
title = "Activity",
description,
}: {
data: { date: string; [series: string]: number | string }[]
config: ChartConfig
title?: string
description?: string
}) {
const [range, setRange] = React.useState("90d")
const chartId = React.useId().replace(/[^a-zA-Z0-9_-]/g, "")
const series = Object.keys(config)
// Ranges count back from the newest row, so the chart works with any data, not only today's.
const days = (ranges.find((item) => item.value === range) ?? ranges[0]).days
const newest = Date.parse(data[data.length - 1]?.date ?? "")
const visible = data.filter(
(row) => Date.parse(row.date) > newest - days * DAY
)
return (
<Card className="@container/card">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description ?? `Last ${days} days`}</CardDescription>
<CardAction>
<Tabs value={range} onValueChange={setRange}>
<TabsList
aria-label="Date range"
className="hidden @[540px]/card:inline-flex"
>
{ranges.map((item) => (
<TabsTrigger key={item.value} value={item.value}>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Select value={range} onValueChange={setRange}>
<SelectTrigger
size="sm"
aria-label="Date range"
className="w-28 @[540px]/card:hidden"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
{ranges.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardAction>
</CardHeader>
<CardContent className="px-2 sm:px-6">
<ChartContainer config={config} className="aspect-auto h-[250px] w-full">
<AreaChart data={visible}>
<defs>
{series.map((key) => (
<linearGradient
key={key}
id={`${chartId}-${key}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="5%"
stopColor={`var(--color-${key})`}
stopOpacity={0.6}
/>
<stop
offset="95%"
stopColor={`var(--color-${key})`}
stopOpacity={0.05}
/>
</linearGradient>
))}
</defs>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={formatDate}
/>
<ChartTooltip
cursor={false}
content={
<ChartTooltipContent
labelFormatter={(value) => formatDate(String(value))}
indicator="dot"
/>
}
/>
{series.map((key) => (
<Area
key={key}
dataKey={key}
type="monotone"
stackId="a"
stroke={`var(--color-${key})`}
fill={`url(#${chartId}-${key})`}
/>
))}
<ChartLegend content={<ChartLegendContent />} />
</AreaChart>
</ChartContainer>
</CardContent>
</Card>
)
}"use client"
/*
* Adapted from shadcn/ui (https://github.com/shadcn-ui/ui).
*
* MIT License
*
* Copyright (c) 2023 shadcn
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
import * as React from "react"
import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable,
type Column,
type ColumnDef,
type ColumnFiltersState,
type SortingState,
type VisibilityState,
} from "@tanstack/react-table"
import {
ArrowDownIcon,
ArrowUpDownIcon,
ArrowUpIcon,
ChevronDownIcon,
Columns3Icon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/registry/ward/ui/button"
import { Checkbox } from "@/registry/ward/ui/checkbox"
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/registry/ward/ui/dropdown-menu"
import { Input } from "@/registry/ward/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/registry/ward/ui/table"
const PAGE_SIZE = 10
/** A header button that sorts its column. Use it as a column's `header`. */
export function SortableHeader<T>({
column,
className,
children,
}: {
column: Column<T, unknown>
className?: string
children: React.ReactNode
}) {
const sorted = column.getIsSorted()
const Icon =
sorted === "asc"
? ArrowUpIcon
: sorted === "desc"
? ArrowDownIcon
: ArrowUpDownIcon
return (
<Button
variant="ghost"
size="sm"
className={cn("-ml-2.5", className)}
onClick={() => column.toggleSorting(sorted === "asc")}
>
{children}
<Icon className={sorted ? undefined : "opacity-50"} />
</Button>
)
}
// The checkbox column comes with the table, so a caller's columns describe only its own data.
function selectColumn<T>(): ColumnDef<T> {
return {
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all rows on this page"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
}
}
export function DataTable<T>({
columns,
data,
searchKey,
}: {
columns: ColumnDef<T>[]
data: T[]
/** The id of the column the search box filters. Leave it out for no search box. */
searchKey?: string
}) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState({})
const allColumns = React.useMemo(
() => [selectColumn<T>(), ...columns],
[columns]
)
// React Compiler skips this component by itself, since TanStack Table returns functions it can't memoize safely.
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data,
columns: allColumns,
state: { sorting, columnFilters, columnVisibility, rowSelection },
initialState: { pagination: { pageSize: PAGE_SIZE } },
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getPaginationRowModel: getPaginationRowModel(),
})
const searchColumn = searchKey ? table.getColumn(searchKey) : undefined
const { pageIndex } = table.getState().pagination
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
{searchColumn && (
<Input
placeholder={`Search by ${searchColumn.id}…`}
aria-label={`Search by ${searchColumn.id}`}
value={(searchColumn.getFilterValue() as string | undefined) ?? ""}
onChange={(event) => searchColumn.setFilterValue(event.target.value)}
className="max-w-sm"
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="ml-auto">
<Columns3Icon />
Columns
<ChevronDownIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(value)}
>
{column.id}
</DropdownMenuCheckboxItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="overflow-hidden rounded-lg border border-border">
<Table>
<TableHeader className="bg-muted/50">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
const sorted = header.column.getIsSorted()
return (
<TableHead
key={header.id}
colSpan={header.colSpan}
aria-sort={
sorted
? sorted === "asc"
? "ascending"
: "descending"
: undefined
}
className={header.column.id === "select" ? "w-10" : undefined}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() ? "selected" : undefined}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length}
className="h-24 text-center text-muted-foreground"
>
No results.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
{table.getFilteredSelectedRowModel().rows.length} of{" "}
{table.getFilteredRowModel().rows.length} selected
</p>
<div className="flex items-center gap-2">
<span className="mr-2 text-sm font-medium">
Page {pageIndex + 1} of {Math.max(table.getPageCount(), 1)}
</span>
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
)
}import type { ColumnDef } from "@tanstack/react-table"
import { Badge } from "@/registry/ward/ui/badge"
import type { ChartConfig } from "@/registry/ward/ui/chart"
import { formatDate } from "./chart-area"
import { SortableHeader } from "./data-table"
/*
* Adapted from shadcn/ui (https://github.com/shadcn-ui/ui).
*
* MIT License
*
* Copyright (c) 2023 shadcn
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
// Sample content for the block. Replace all of it with your own data.
export const cards = [
{ label: "Items published", value: "1,284", change: "+12.5%", note: "More than the last 30 days" },
{ label: "Clicks", value: "48,210", change: "+8.2%", note: "Across websites and apps" },
{ label: "New members", value: "326", change: "-4.1%", note: "Fewer sign-ups than last month" },
{ label: "In review", value: "17", note: "Waiting for a curator" },
]
// Greyscale like the rest of Ward: the two series differ in lightness, and the legend names them.
export const chartConfig = {
websites: { label: "Websites", color: "var(--chart-1)" },
apps: { label: "Apps", color: "var(--chart-3)" },
} satisfies ChartConfig
// 90 days of made-up clicks. Computed from the day number, so the server and the browser draw the same chart.
const DAY = 24 * 60 * 60 * 1000
const FIRST_DAY = Date.UTC(2026, 6, 1)
const week = (day: number, shift = 0) => Math.sin((day * 2 * Math.PI) / 7 + shift)
export const chartData = Array.from({ length: 90 }, (_, day) => ({
date: new Date(FIRST_DAY + day * DAY).toISOString().slice(0, 10),
websites: Math.round(240 + day * 1.6 + 45 * Math.sin(day / 6) + 22 * week(day)),
apps: Math.round(120 + day * 0.8 + 20 * Math.sin(day / 5 + 1) + 12 * week(day, 1)),
}))
export type Row = {
id: string
title: string
kind: "Website" | "App" | "Component" | "Design" | "Tool" | "Skill"
status: "Live" | "In review" | "Draft"
clicks: number
updated: string
}
export const rows: Row[] = [
{ id: "1", title: "Onboarding checklist", kind: "Component", status: "Live", clicks: 4210, updated: "2026-09-27" },
{ id: "2", title: "Pricing page with a billing toggle", kind: "Website", status: "Live", clicks: 3894, updated: "2026-09-26" },
{ id: "3", title: "Habit tracker", kind: "App", status: "Live", clicks: 3502, updated: "2026-09-25" },
{ id: "4", title: "Command palette", kind: "Component", status: "Live", clicks: 3188, updated: "2026-09-24" },
{ id: "5", title: "Portfolio with a full-bleed hero", kind: "Website", status: "In review", clicks: 0, updated: "2026-09-28" },
{ id: "6", title: "Empty states in monochrome", kind: "Design", status: "Live", clicks: 2764, updated: "2026-09-22" },
{ id: "7", title: "Recipe planner", kind: "App", status: "Live", clicks: 2430, updated: "2026-09-21" },
{ id: "8", title: "Changelog feed", kind: "Website", status: "Live", clicks: 2217, updated: "2026-09-20" },
{ id: "9", title: "Code review helper", kind: "Skill", status: "Draft", clicks: 0, updated: "2026-09-28" },
{ id: "10", title: "Settings with grouped sections", kind: "Design", status: "Live", clicks: 1986, updated: "2026-09-19" },
{ id: "11", title: "Screenshot compressor", kind: "Tool", status: "Live", clicks: 1873, updated: "2026-09-18" },
{ id: "12", title: "Date range picker", kind: "Component", status: "Live", clicks: 1655, updated: "2026-09-17" },
{ id: "13", title: "Budget dashboard", kind: "App", status: "In review", clicks: 0, updated: "2026-09-27" },
{ id: "14", title: "Documentation landing page", kind: "Website", status: "Live", clicks: 1421, updated: "2026-09-15" },
{ id: "15", title: "Migration guide writer", kind: "Skill", status: "Live", clicks: 1302, updated: "2026-09-14" },
{ id: "16", title: "Notification centre", kind: "Design", status: "Draft", clicks: 0, updated: "2026-09-26" },
{ id: "17", title: "Colour contrast checker", kind: "Tool", status: "Live", clicks: 1196, updated: "2026-09-12" },
{ id: "18", title: "Data table with filters", kind: "Component", status: "Live", clicks: 1088, updated: "2026-09-11" },
{ id: "19", title: "Meditation timer", kind: "App", status: "Live", clicks: 964, updated: "2026-09-09" },
{ id: "20", title: "Event ticketing site", kind: "Website", status: "In review", clicks: 0, updated: "2026-09-25" },
]
const statusVariant = { Live: "default", "In review": "secondary", Draft: "outline" } as const
export const columns: ColumnDef<Row>[] = [
{
accessorKey: "title",
header: ({ column }) => <SortableHeader column={column}>Title</SortableHeader>,
cell: ({ row }) => <span className="font-medium">{row.original.title}</span>,
enableHiding: false,
},
{
accessorKey: "kind",
header: "Kind",
cell: ({ row }) => <Badge variant="outline">{row.original.kind}</Badge>,
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => <Badge variant={statusVariant[row.original.status]}>{row.original.status}</Badge>,
},
{
accessorKey: "clicks",
header: ({ column }) => (
<div className="flex justify-end">
<SortableHeader column={column} className="-mr-2.5 ml-0">Clicks</SortableHeader>
</div>
),
cell: ({ row }) => <div className="text-right tabular-nums">{row.original.clicks.toLocaleString("en-GB")}</div>,
},
{
accessorKey: "updated",
header: ({ column }) => <SortableHeader column={column}>Updated</SortableHeader>,
cell: ({ row }) => <span className="text-muted-foreground">{formatDate(row.original.updated)}</span>,
},
]Installation
pnpm dlx shadcn@latest add @ward/dashboardnpx shadcn@latest add @ward/dashboardyarn dlx shadcn@latest add @ward/dashboardbunx --bun shadcn@latest add @ward/dashboardAdd the Ward registry to components.json once:
"registries": {
"@ward": "https://ward.so/r/{name}.json"
}Or install from the URL with no configuration: npx shadcn@latest add https://ward.so/r/dashboard.json
Install the dependencies.
npm install @tanstack/react-table@8.21.3 lucide-react@1.47.0 recharts@3.10.1Add the Ward items it builds on.
npx shadcn@latest add @ward/badge @ward/button @ward/card @ward/chart @ward/checkbox @ward/dropdown-menu @ward/input @ward/select @ward/separator @ward/sidebar @ward/table @ward/tabsCopy each file from the Code tab into:
components/ward/dashboard/dashboard.tsxcomponents/ward/dashboard/app-sidebar.tsxcomponents/ward/dashboard/site-header.tsxcomponents/ward/dashboard/section-cards.tsxcomponents/ward/dashboard/chart-area.tsxcomponents/ward/dashboard/data-table.tsxcomponents/ward/dashboard/data.tsx
Update the import paths to match your project.
Usage
import Dashboard from "@/components/ward/dashboard/dashboard";<Dashboard />Props
| Prop | Type | Default | Description |
|---|---|---|---|
SiteHeader title | string | - | Page title shown beside the sidebar toggle. |
SectionCards cards | { label: string; value: string; change?: string; note?: string }[] | - | One card per stat. A change that starts with a minus sign shows a falling trend, any other change a rising one. |
ChartArea data | { date: string; [series: string]: number | string }[] | - | One row per day, with an ISO date and a number for each series. The 7, 30 and 90 day ranges count back from the newest row. |
ChartArea config | ChartConfig | - | A label and colour for each series key in data. The series stack in the order given. |
ChartArea title | string | "Activity" | Heading of the chart card. |
ChartArea description | string | - | Line under the heading. Without one, the card names the range picked, such as Last 30 days. |
DataTable columns | ColumnDef<T>[] | - | TanStack Table column definitions for your rows. The table adds the row checkbox column itself; use SortableHeader as a column's header to make it sortable. |
DataTable data | T[] | - | The rows, shown 10 to a page. |
DataTable searchKey | string | - | Id of the column the search box filters. Leave it out to hide the search box. |
Ward · Version 1.0.0 · Registry manifest