Dashboard 2
Analytics dashboard with a date-range select, four sparkline KPI tiles, a layered two-series area chart and top-pages and channel-share side cards. Deltas carry their own unit (percent vs percentage points) and the direction that counts as good, and the area chart ships a screen-reader data table of the same numbers.
Preview
Installation
npx shadcn@latest add https://hirael.com/r/dashboard-02.jsonCode
components/blocks/dashboard-02.tsx
'use client';
import * as React from 'react';
import { ArrowDownRight, ArrowUpRight, Minus, Share2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Badge } from '@/registry/hirael/bases/radix/ui/badge';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/registry/hirael/bases/radix/ui/card';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/registry/hirael/bases/radix/ui/select';
import { Separator } from '@/registry/hirael/bases/radix/ui/separator';
type Range = '7d' | '14d' | '28d';
const RANGES: { value: Range; label: string }[] = [
{ value: '7d', label: 'Last 7 days' },
{ value: '14d', label: 'Last 14 days' },
{ value: '28d', label: 'Last 28 days' },
];
interface Kpi {
label: string;
value: string;
/** Change against the previous period; the sign carries the direction. */
delta: number;
/** `pp` is percentage points, so a rate change never reads as a percentage. */
unit: '%' | 'pp' | 's';
/** Which way this metric has to move to be good news. Bounce rate falls. */
goodWhen: 'up' | 'down';
spark: readonly number[];
}
const KPIS_BY_RANGE: Record<Range, readonly Kpi[]> = {
'7d': [
{
label: 'Visitors',
value: '24,310',
delta: 12.4,
unit: '%',
goodWhen: 'up',
spark: [12, 18, 14, 22, 19, 26, 31],
},
{
label: 'Page views',
value: '96,482',
delta: 8.1,
unit: '%',
goodWhen: 'up',
spark: [40, 52, 47, 58, 54, 66, 72],
},
{
label: 'Avg. time',
value: '3m 42s',
delta: 9,
unit: 's',
goodWhen: 'up',
spark: [30, 28, 33, 31, 36, 34, 39],
},
{
label: 'Bounce rate',
value: '38.2%',
delta: -1.8,
unit: 'pp',
goodWhen: 'down',
spark: [46, 44, 45, 42, 43, 40, 38],
},
],
'14d': [
{
label: 'Visitors',
value: '46,920',
delta: 9.6,
unit: '%',
goodWhen: 'up',
spark: [14, 16, 13, 19, 18, 24, 27],
},
{
label: 'Page views',
value: '182,104',
delta: 6.4,
unit: '%',
goodWhen: 'up',
spark: [44, 49, 46, 55, 52, 61, 67],
},
{
label: 'Avg. time',
value: '3m 31s',
delta: 0,
unit: 's',
goodWhen: 'up',
spark: [32, 31, 33, 32, 33, 32, 33],
},
{
label: 'Bounce rate',
value: '39.6%',
delta: -0.9,
unit: 'pp',
goodWhen: 'down',
spark: [45, 44, 46, 43, 44, 41, 40],
},
],
'28d': [
{
label: 'Visitors',
value: '88,475',
delta: 21.2,
unit: '%',
goodWhen: 'up',
spark: [10, 13, 12, 17, 16, 22, 28],
},
{
label: 'Page views',
value: '351,889',
delta: 17.8,
unit: '%',
goodWhen: 'up',
spark: [36, 42, 40, 51, 49, 60, 71],
},
{
label: 'Avg. time',
value: '3m 24s',
delta: 18,
unit: 's',
goodWhen: 'up',
spark: [27, 29, 28, 32, 31, 35, 38],
},
{
label: 'Bounce rate',
value: '40.4%',
delta: 0.6,
unit: 'pp',
goodWhen: 'down',
spark: [39, 40, 39, 41, 40, 42, 41],
},
],
};
interface ChartBucket {
label: string;
visitors: number;
views: number;
}
const CHART_BY_RANGE: Record<Range, readonly ChartBucket[]> = {
'7d': [
{ label: 'Mon', visitors: 2840, views: 11200 },
{ label: 'Tue', visitors: 3120, views: 12400 },
{ label: 'Wed', visitors: 2960, views: 11900 },
{ label: 'Thu', visitors: 3480, views: 13800 },
{ label: 'Fri', visitors: 3940, views: 15600 },
{ label: 'Sat', visitors: 3260, views: 13100 },
{ label: 'Sun', visitors: 4710, views: 18482 },
],
'14d': [
{ label: 'D2', visitors: 5410, views: 21400 },
{ label: 'D4', visitors: 6180, views: 24300 },
{ label: 'D6', visitors: 5890, views: 23200 },
{ label: 'D8', visitors: 6720, views: 26800 },
{ label: 'D10', visitors: 7240, views: 28900 },
{ label: 'D12', visitors: 6950, views: 27600 },
{ label: 'D14', visitors: 8530, views: 29904 },
],
'28d': [
{ label: 'D4', visitors: 10240, views: 40100 },
{ label: 'D8', visitors: 11820, views: 46400 },
{ label: 'D12', visitors: 11260, views: 44800 },
{ label: 'D16', visitors: 12980, views: 52200 },
{ label: 'D20', visitors: 13710, views: 55900 },
{ label: 'D24', visitors: 13180, views: 53600 },
{ label: 'D28', visitors: 15285, views: 58889 },
],
};
const TOP_PAGES = [
{ path: '/blocks', views: 18204 },
{ path: '/components', views: 14911 },
{ path: '/blocks/hero-01', views: 9482 },
{ path: '/theme', views: 6150 },
{ path: '/blocks/faq-02', views: 4067 },
] as const;
const TOP_PAGE_PEAK = Math.max(...TOP_PAGES.map((p) => p.views));
const compact = new Intl.NumberFormat('en-US');
const CHANNELS = [
{ label: 'Organic search', share: 44 },
{ label: 'Direct', share: 31 },
{ label: 'Referral', share: 16 },
{ label: 'Social', share: 9 },
] as const;
/** Tone follows intent, not sign: a falling bounce rate is good news. */
const deltaTone = ({ delta, goodWhen }: Kpi) => {
if (delta === 0) return 'bg-accent text-muted-foreground';
const improving = delta > 0 === (goodWhen === 'up');
return improving ? 'bg-success/10 text-success' : 'bg-destructive/10 text-destructive';
};
const UNIT_WORD: Record<Kpi['unit'], string> = {
'%': 'percent',
pp: 'percentage points',
s: 'seconds',
};
const DeltaChip = ({ kpi }: { kpi: Kpi }) => {
const { delta, unit, label } = kpi;
const Icon = delta > 0 ? ArrowUpRight : delta < 0 ? ArrowDownRight : Minus;
const direction = delta > 0 ? 'up' : delta < 0 ? 'down' : 'unchanged';
const sign = delta > 0 ? '+' : delta < 0 ? '\u2212' : '';
return (
<span
dir="ltr"
aria-label={`${label} ${direction} ${Math.abs(delta)} ${UNIT_WORD[unit]} against the previous period`}
className={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 font-mono text-[11px] leading-none tabular-nums',
deltaTone(kpi),
)}
>
<Icon className="size-3" aria-hidden />
{sign}
{Math.abs(delta)}
{unit === 'pp' ? 'pp' : unit}
</span>
);
};
const Sparkline = ({ points }: { points: readonly number[] }) => {
const max = Math.max(...points);
const min = Math.min(...points);
const span = max - min || 1;
const step = 100 / (points.length - 1);
const coords = points
.map((p, i) => `${(i * step).toFixed(1)},${(26 - ((p - min) / span) * 20).toFixed(1)}`)
.join(' ');
return (
<svg viewBox="0 0 100 30" preserveAspectRatio="none" aria-hidden className="h-7 w-full">
<polyline
points={coords}
fill="none"
vectorEffect="non-scaling-stroke"
strokeWidth="1.5"
className="stroke-foreground/40"
/>
</svg>
);
};
const linePath = (values: number[], max: number) => {
// A single bucket would divide by zero; draw it flat across instead.
const step = values.length > 1 ? 100 / (values.length - 1) : 100;
return values
.map((v, i) => `${i === 0 ? 'M' : 'L'}${(i * step).toFixed(2)} ${(44 - (v / max) * 38).toFixed(2)}`)
.join(' ');
};
const Dashboard02 = () => {
const [range, setRange] = React.useState<Range>('7d');
const kpis = KPIS_BY_RANGE[range];
const chart = CHART_BY_RANGE[range];
const max = Math.max(...chart.map((b) => b.views));
const viewsLine = linePath(
chart.map((b) => b.views),
max,
);
const visitorsLine = linePath(
chart.map((b) => b.visitors),
max,
);
return (
<section className="bg-background py-20 sm:py-28">
<div className="container w-full">
<div className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
<div className="flex max-w-xl flex-col gap-3">
<Badge variant="outline" className="w-fit">
analytics
</Badge>
<h2 className="font-serif text-4xl font-medium tracking-tight sm:text-5xl">Traffic, end to end.</h2>
</div>
<div className="flex items-center gap-2">
<Select value={range} onValueChange={(v) => setRange(v as Range)}>
<SelectTrigger size="sm" className="w-[150px]" aria-label="Date range">
<SelectValue />
</SelectTrigger>
<SelectContent>
{RANGES.map((r) => (
<SelectItem key={r.value} value={r.value}>
{r.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm">
<Share2 className="size-3.5" aria-hidden />
<span className="hidden sm:inline">Share report</span>
</Button>
</div>
</div>
<div className="mt-10 grid grid-cols-2 gap-px overflow-hidden rounded-md border border-border bg-border lg:grid-cols-4">
{kpis.map((k) => (
<div key={k.label} className="flex flex-col gap-2 bg-card p-5">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
{k.label}
</span>
<DeltaChip kpi={k} />
</div>
<span className="text-3xl font-semibold tracking-[-0.035em] tabular-nums">{k.value}</span>
<Sparkline points={k.spark} />
</div>
))}
</div>
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-3 lg:items-start">
<Card className="lg:col-span-2">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-1">
<CardDescription className="font-mono text-[10px] uppercase tracking-[0.12em]">
page views vs visitors
</CardDescription>
<CardTitle className="text-lg">{RANGES.find((r) => r.value === range)?.label}</CardTitle>
</div>
<div className="flex items-center gap-4">
<span className="inline-flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
<span aria-hidden className="size-2 rounded-xs bg-foreground/85" />
Views
</span>
<span className="inline-flex items-center gap-1.5 font-mono text-[10px] uppercase tracking-[0.12em] text-muted-foreground">
<span aria-hidden className="size-2 rounded-xs bg-muted-foreground/45" />
Visitors
</span>
</div>
</div>
</CardHeader>
<CardContent>
<div>
<svg viewBox="0 0 100 46" preserveAspectRatio="none" aria-hidden className="h-56 w-full">
{[11, 22, 33].map((y) => (
<line
key={y}
x1="0"
x2="100"
y1={y}
y2={y}
vectorEffect="non-scaling-stroke"
className="stroke-border"
strokeDasharray="2 3"
/>
))}
<path d={`${viewsLine} L100 46 L0 46 Z`} className="fill-foreground/10" />
<path d={`${visitorsLine} L100 46 L0 46 Z`} className="fill-foreground/5" />
<path
d={viewsLine}
fill="none"
vectorEffect="non-scaling-stroke"
strokeWidth="1.5"
className="stroke-foreground/85"
/>
<path
d={visitorsLine}
fill="none"
vectorEffect="non-scaling-stroke"
strokeWidth="1.5"
strokeDasharray="4 3"
className="stroke-muted-foreground/60"
/>
</svg>
<div aria-hidden className="mt-2 flex justify-between">
{chart.map((b) => (
<span
key={b.label}
className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground"
>
{b.label}
</span>
))}
</div>
{/* The numbers behind the line, for screen readers and
anyone who would rather read than squint. */}
<table className="sr-only">
<caption>
Page views and visitors per bucket, {RANGES.find((r) => r.value === range)?.label.toLowerCase()}
</caption>
<thead>
<tr>
<th scope="col">Bucket</th>
<th scope="col">Page views</th>
<th scope="col">Visitors</th>
</tr>
</thead>
<tbody>
{chart.map((b) => (
<tr key={b.label}>
<th scope="row">{b.label}</th>
<td>{b.views.toLocaleString('en-US')}</td>
<td>{b.visitors.toLocaleString('en-US')}</td>
</tr>
))}
</tbody>
</table>
</div>
<Separator className="my-4" />
<p className="font-mono text-[10px] uppercase tracking-[0.1em] text-muted-foreground">
Peak · {chart.reduce((a, b) => (b.views > a.views ? b : a)).label} · {max.toLocaleString('en-US')} views
</p>
</CardContent>
</Card>
<div className="flex flex-col gap-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardDescription className="font-mono text-[10px] uppercase tracking-[0.12em]">
top pages
</CardDescription>
<Button variant="link" size="sm" className="h-auto p-0" asChild>
<a href="#">View all</a>
</Button>
</div>
<CardTitle className="sr-only">Top pages</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3.5">
{TOP_PAGES.map((p) => (
<div key={p.path} className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-3">
<span className="truncate font-mono text-xs text-foreground">{p.path}</span>
<span className="shrink-0 font-mono text-xs tabular-nums text-muted-foreground">
{compact.format(p.views)}
</span>
</div>
<div aria-hidden className="h-1 w-full overflow-hidden rounded-full bg-accent">
<div
className="h-full rounded-full bg-foreground/70"
style={{
width: `${Math.round((p.views / TOP_PAGE_PEAK) * 100)}%`,
}}
/>
</div>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardDescription className="font-mono text-[10px] uppercase tracking-[0.12em]">
channels
</CardDescription>
<CardTitle className="sr-only">Channels</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
{CHANNELS.map((c) => (
<div key={c.label} className="flex items-center gap-3">
<span className="w-28 shrink-0 text-xs text-muted-foreground">{c.label}</span>
<div aria-hidden className="h-1 flex-1 overflow-hidden rounded-full bg-accent">
<div className="h-full rounded-full bg-foreground/70" style={{ width: `${c.share}%` }} />
</div>
<span className="w-9 shrink-0 text-end font-mono text-xs tabular-nums text-muted-foreground">
{c.share}%
</span>
</div>
))}
</CardContent>
</Card>
</div>
</div>
</div>
</section>
);
};
export default Dashboard02;
Dependencies
shadcn registry
badgebuttoncardselectseparator
npm
lucide-react