Dashboard
Navigation that slides in from the side on a phone, an environment switcher, a traffic chart for three periods, a searchable list of deployments and empty states for sections still to come.
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/dashboard.json"use client"
import * as React from "react"
import {
ActivityIcon,
BarChart3Icon,
BellIcon,
BoxIcon,
ChevronsUpDownIcon,
CircleXIcon,
GitBranchIcon,
GlobeIcon,
LayoutGridIcon,
MenuIcon,
SearchIcon,
ZapIcon,
type LucideIcon,
} from "lucide-react"
import { Avatar, AvatarFallback, AvatarGroup } from "@/components/ui/avatar"
import { Badge, BadgeDot } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import { Progress } from "@/components/ui/progress"
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { cn } from "@/lib/utils"
const traffic = {
"24h": {
caption: "Requests per hour, last 24 hours",
data: [
42, 48, 45, 52, 58, 55, 61, 67, 64, 72, 70, 78, 84, 80, 88, 94, 91, 97,
104, 99, 108, 115, 111, 120,
],
},
"7d": {
caption: "Requests per day, last 7 days",
data: [77, 82, 56, 53, 79, 86, 91],
},
"30d": {
caption: "Requests per day, last 30 days",
data: [
52, 58, 61, 60, 63, 44, 41, 59, 64, 66, 65, 69, 47, 45, 64, 70, 72, 71,
75, 51, 49, 70, 76, 79, 77, 82, 56, 53, 79, 86,
],
},
}
type Period = keyof typeof traffic
const stats = [
{
label: "Requests",
value: "12.4M",
delta: "+18.2%",
tone: "success",
data: [3, 4, 3.6, 5, 5.4, 6, 5.8, 7, 7.6],
},
{
label: "p95 latency",
value: "84 ms",
delta: "−6.1%",
tone: "success",
data: [9, 8.6, 8.9, 8.1, 7.9, 8.2, 7.4, 7.2, 7],
},
{
label: "Error rate",
value: "0.12%",
delta: "+0.02",
tone: "danger",
data: [2, 2.1, 1.8, 2.4, 2.2, 2.6, 2.3, 2.8, 2.7],
},
{
label: "Uptime",
value: "99.99%",
delta: "30 days",
tone: "neutral",
data: [5, 5, 5, 5, 4.9, 5, 5, 5, 5],
},
] as const
const toneText = {
success: "text-success-11",
danger: "text-danger-11",
neutral: "text-muted-foreground",
}
const deployments = [
{
id: "dpl_8f2c",
branch: "main",
message: "Ship the new billing page",
status: "Ready",
author: "AL",
time: "2m",
},
{
id: "dpl_7a91",
branch: "feat/search",
message: "Command palette with fuzzy search",
status: "Building",
author: "GH",
time: "6m",
},
{
id: "dpl_6d04",
branch: "fix/tz",
message: "Format dates in the viewer's zone",
status: "Ready",
author: "KJ",
time: "1h",
},
{
id: "dpl_5be3",
branch: "exp/edge",
message: "Try streaming for the dashboard",
status: "Failed",
author: "MS",
time: "3h",
},
] as const
const statusBadge = {
Ready: "success",
Building: "warning",
Failed: "destructive",
} as const
type NavItem = {
label: string
icon: LucideIcon
/** Sections without a page in this example say what would live there. */
title?: string
description?: string
}
const nav = [
{ label: "Overview", icon: LayoutGridIcon },
{ label: "Deployments", icon: BoxIcon },
{
label: "Analytics",
icon: BarChart3Icon,
title: "No events yet",
description:
"Add the tracking snippet and page views show up here within a minute.",
},
{
label: "Domains",
icon: GlobeIcon,
title: "No custom domains",
description: "meridian.app already works. Add your own domain when ready.",
},
{
label: "Activity",
icon: ActivityIcon,
title: "Audit log is off",
description:
"Turn it on to keep a record of deploys, rollbacks and changed settings.",
},
] as const satisfies readonly NavItem[]
type Section = (typeof nav)[number]["label"]
const environments = ["Production", "Preview", "Development"]
function points(
data: readonly number[],
width: number,
height: number,
pad = 2
) {
const max = Math.max(...data)
const min = Math.min(...data)
return data.map((value, i) => [
(i / (data.length - 1)) * width,
pad + (1 - (value - min) / (max - min || 1)) * (height - pad * 2),
])
}
const path = (pts: number[][]) =>
pts
.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`)
.join(" ")
function Sparkline({
data,
className,
}: {
data: readonly number[]
className?: string
}) {
return (
<svg
viewBox="0 0 96 28"
className={cn("h-7 w-24 overflow-visible", className)}
aria-hidden="true"
>
<path
d={path(points(data, 96, 28))}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
)
}
function AreaChart({ data }: { data: readonly number[] }) {
const width = 560
const height = 150
const pts = points(data, width, height, 8)
const line = path(pts)
const [lastX, lastY] = pts[pts.length - 1]
return (
// The chart stretches to fit, so the end marker is HTML: a circle in
// the stretched SVG would turn into an ellipse.
<div className="relative" aria-hidden="true">
<svg
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
className="block h-40 w-full"
>
{[0.25, 0.5, 0.75].map((f) => (
<line
key={f}
x1="0"
x2={width}
y1={height * f}
y2={height * f}
stroke="var(--border)"
strokeDasharray="3 4"
vectorEffect="non-scaling-stroke"
/>
))}
<path
d={`${line} L${width} ${height} L0 ${height} Z`}
fill="var(--brand-9)"
fillOpacity="0.08"
/>
<path
d={line}
fill="none"
stroke="var(--brand-9)"
strokeWidth="2"
vectorEffect="non-scaling-stroke"
strokeLinejoin="round"
/>
</svg>
<span
className="absolute size-2.5 -translate-1/2 rounded-full bg-brand-9 ring-2 ring-card"
style={{
left: `${(lastX / width) * 100}%`,
top: `${(lastY / height) * 100}%`,
}}
/>
</div>
)
}
function SidebarContent({
section,
onSectionChange,
environment,
onEnvironmentChange,
}: {
section: Section
onSectionChange: (section: Section) => void
environment: string
onEnvironmentChange: (environment: string) => void
}) {
return (
<>
<DropdownMenu>
<DropdownMenuTrigger className="flex items-center gap-2.5 rounded-lg p-1.5 text-left focus-ring transition-colors duration-fast hover:bg-accent data-popup-open:bg-accent">
<span className="flex size-7 items-center justify-center rounded-md bg-foreground text-background">
<ZapIcon className="size-3.5" />
</span>
<span className="flex flex-1 flex-col leading-tight">
<span className="text-sm font-semibold">Meridian</span>
<span className="text-xs text-muted-foreground">{environment}</span>
</span>
<ChevronsUpDownIcon className="size-3.5 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuGroup>
<DropdownMenuLabel>Environment</DropdownMenuLabel>
{/* Radio items tell assistive technology which one is chosen. */}
<DropdownMenuRadioGroup
value={environment}
onValueChange={onEnvironmentChange}
>
{environments.map((name) => (
<DropdownMenuRadioItem key={name} value={name} closeOnClick>
{name}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<nav className="flex flex-col gap-px">
{nav.map(({ label, icon: Icon }) => (
<button
key={label}
type="button"
aria-current={label === section ? "page" : undefined}
onClick={() => onSectionChange(label)}
className="flex h-8 items-center gap-2.5 rounded-lg px-2 text-sm text-muted-foreground focus-ring transition-colors duration-fast hover:bg-accent hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:font-medium aria-[current=page]:text-foreground"
>
<Icon className="size-4" />
{label}
</button>
))}
</nav>
<div className="mt-auto flex flex-col gap-3 rounded-xl border bg-card p-3 shadow-raised">
<div className="flex flex-col gap-0.5">
<span className="text-label-sm">Build minutes</span>
<span className="text-xs text-muted-foreground">
5,420 of 6,000 used
</span>
</div>
<Progress value={90} size="sm" aria-label="Build minutes used" />
<Button size="xs" variant="outline">
Upgrade plan
</Button>
</div>
</>
)
}
function DeploymentsTable({ query }: { query: string }) {
const needle = query.trim().toLowerCase()
const rows = deployments.filter((row) =>
[row.message, row.branch, row.id].some((field) =>
field.toLowerCase().includes(needle)
)
)
return (
<Card size="sm" className="gap-0 py-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Deployment</TableHead>
<TableHead className="hidden @2xl:table-cell">Branch</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Age</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
{/* Takes the remaining width and truncates, so status and age
stay in view on narrow screens. */}
<TableCell className="w-full max-w-0">
<div className="flex min-w-0 items-center gap-3">
<Avatar size="xs">
<AvatarFallback>{row.author}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{row.message}</span>
<span className="font-mono text-xs text-muted-foreground">
{row.id}
</span>
</div>
</div>
</TableCell>
<TableCell className="hidden @2xl:table-cell">
<span className="inline-flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
<GitBranchIcon className="size-3.5" />
{row.branch}
</span>
</TableCell>
<TableCell>
<Badge variant={statusBadge[row.status]}>
<BadgeDot live={row.status === "Building"} />
{row.status}
</Badge>
</TableCell>
<TableCell className="text-right text-muted-foreground">
{row.time}
</TableCell>
</TableRow>
))}
{rows.length === 0 && (
<TableRow>
<TableCell
colSpan={4}
className="h-24 text-center whitespace-normal text-muted-foreground"
>
No deployments match your search.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
)
}
function Overview({ query }: { query: string }) {
const [period, setPeriod] = React.useState<Period>("24h")
return (
<>
<div className="grid grid-cols-2 gap-4 @5xl:grid-cols-4">
{stats.map((stat) => (
<div
key={stat.label}
className="@container flex flex-col gap-2 rounded-xl border bg-card p-4 shadow-raised"
>
<span className="text-xs text-muted-foreground">{stat.label}</span>
<div className="flex items-end justify-between gap-2">
<span className="text-2xl font-semibold tracking-tight whitespace-nowrap numeric">
{stat.value}
</span>
{/* Too narrow for both: the figure stays, the trend goes. */}
<Sparkline
data={stat.data}
className={cn("@max-[12rem]:hidden", toneText[stat.tone])}
/>
</div>
<span
className={cn("text-xs font-medium numeric", toneText[stat.tone])}
>
{stat.delta}
</span>
</div>
))}
</div>
<div className="grid gap-4 @5xl:grid-cols-[1fr_17rem]">
{/* In a narrow card the period switch drops below the caption. */}
<Card size="sm" className="@container/traffic">
<CardHeader className="@max-md/traffic:grid-cols-1!">
<CardTitle>Traffic</CardTitle>
<CardDescription>{traffic[period].caption}</CardDescription>
<CardAction className="@max-md/traffic:col-start-1 @max-md/traffic:row-span-1 @max-md/traffic:row-start-3 @max-md/traffic:mt-1.5 @max-md/traffic:justify-self-start">
<ToggleGroup
variant="outline"
size="sm"
spacing={0}
value={[period]}
// Pressing the active period again would leave none chosen.
onValueChange={([next]) => next && setPeriod(next as Period)}
aria-label="Period"
>
{(Object.keys(traffic) as Period[]).map((key) => (
<ToggleGroupItem key={key} value={key} className="text-xs">
{key}
</ToggleGroupItem>
))}
</ToggleGroup>
</CardAction>
</CardHeader>
<CardContent>
<AreaChart data={traffic[period].data} />
</CardContent>
</Card>
<Card size="sm">
<CardHeader>
<CardTitle>Team</CardTitle>
<CardDescription>4 people deployed today</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<AvatarGroup>
{["AL", "GH", "KJ", "MS"].map((initials) => (
<Avatar key={initials}>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
))}
</AvatarGroup>
<div className="flex flex-col gap-2.5">
{[
["Ada merged", "#482", "2m"],
["Grace opened", "#487", "14m"],
["Katherine fixed", "#479", "1h"],
].map(([action, pr, time]) => (
<div key={pr} className="flex items-center gap-2 text-body-xs">
<GitBranchIcon className="size-3.5 text-muted-foreground" />
<span className="text-foreground">{action}</span>
<span className="font-mono text-xs text-muted-foreground">
{pr}
</span>
<span className="ml-auto text-xs text-muted-foreground">
{time}
</span>
</div>
))}
</div>
</CardContent>
</Card>
</div>
<DeploymentsTable query={query} />
</>
)
}
export default function DashboardExample() {
const [section, setSection] = React.useState<Section>("Overview")
const [environment, setEnvironment] = React.useState("Production")
const [query, setQuery] = React.useState("")
const [menuOpen, setMenuOpen] = React.useState(false)
const current: NavItem = nav.find((item) => item.label === section) ?? nav[0]
const sidebar = (
<SidebarContent
section={section}
onSectionChange={(next) => {
setSection(next)
setMenuOpen(false)
}}
environment={environment}
onEnvironmentChange={setEnvironment}
/>
)
return (
<div className="@container overflow-hidden rounded-xl border bg-background">
<div className="flex min-h-[38rem]">
<aside className="hidden w-56 shrink-0 flex-col gap-6 border-r bg-surface p-3 @3xl:flex">
{sidebar}
</aside>
{/* Below the sidebar's breakpoint the same navigation opens in a sheet. */}
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetContent
side="left"
className="w-64 gap-6 p-3"
showCloseButton={false}
>
<SheetTitle className="sr-only">Navigation</SheetTitle>
{sidebar}
</SheetContent>
</Sheet>
<div className="flex min-w-0 flex-1 flex-col">
<header className="flex h-14 items-center gap-2 border-b px-3 @lg:gap-3 @lg:px-5">
<Button
variant="ghost"
size="icon-sm"
aria-label="Open navigation"
onClick={() => setMenuOpen(true)}
className="@3xl:hidden"
>
<MenuIcon />
</Button>
<h2 className="truncate text-heading-sm">{section}</h2>
<Badge
variant="success"
size="sm"
className="hidden @xl:inline-flex"
>
<BadgeDot />
All systems normal
</Badge>
<div className="ml-auto flex items-center gap-2">
<InputGroup className="hidden h-8 w-56 @5xl:flex">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
type="search"
placeholder="Search deployments…"
aria-label="Search deployments"
value={query}
onChange={(event) => {
setQuery(event.target.value)
// Results live in the deployments table.
if (current.title) setSection("Deployments")
}}
/>
</InputGroup>
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="Notifications"
/>
}
>
<BellIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-72">
<DropdownMenuGroup>
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
<DropdownMenuItem onClick={() => setSection("Deployments")}>
<CircleXIcon className="text-danger-11!" />
<span className="flex-1">Build failed on exp/edge</span>
<span className="text-xs text-muted-foreground">3h</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setSection("Overview")}>
<GitBranchIcon />
<span className="flex-1">Grace opened #487</span>
<span className="text-xs text-muted-foreground">14m</span>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
<Avatar size="sm">
<AvatarFallback>AL</AvatarFallback>
</Avatar>
</div>
</header>
<div className="flex flex-1 flex-col gap-4 bg-surface/40 p-3 @lg:p-5">
{section === "Overview" && <Overview query={query} />}
{section === "Deployments" && <DeploymentsTable query={query} />}
{current.title && (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<current.icon />
</EmptyMedia>
<EmptyTitle>{current.title}</EmptyTitle>
<EmptyDescription>{current.description}</EmptyDescription>
</EmptyHeader>
</Empty>
)}
</div>
</div>
</div>
</div>
)
}import type { Metadata } from "next"
import Dashboard from "@/components/dashboard/dashboard"
export const metadata: Metadata = {
title: "Dashboard",
description: "A deployment console: navigation, stats with sparklines, a traffic chart, team activity and a deployments table.",
}
export default function DashboardPage() {
return (
<main className="mx-auto min-h-dvh w-full max-w-[90rem] p-4 sm:p-8">
<Dashboard />
</main>
)
}