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.

Preview

Installation

npx shadcn@latest add https://hirael.com/r/dashboard-02.json

Code

components/blocks/dashboard-02.tsx
"use client";

import * as React from "react";
import { ArrowDownRight, ArrowUpRight, Minus, Share2 } from "lucide-react";

import { Badge } from "@/registry/hirael/ui/badge";
import { Button } from "@/registry/hirael/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/registry/hirael/ui/card";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/registry/hirael/ui/select";
import { Separator } from "@/registry/hirael/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" },
];

type Kpi = {
  label: string;
  value: string;
  delta: string;
  trend: "up" | "down" | "flat";
  spark: readonly number[];
};

const KPIS_BY_RANGE: Record<Range, readonly Kpi[]> = {
  "7d": [
    {
      label: "Visitors",
      value: "24,310",
      delta: "+12.4%",
      trend: "up",
      spark: [12, 18, 14, 22, 19, 26, 31],
    },
    {
      label: "Page views",
      value: "96,482",
      delta: "+8.1%",
      trend: "up",
      spark: [40, 52, 47, 58, 54, 66, 72],
    },
    {
      label: "Avg. time",
      value: "3m 42s",
      delta: "+9s",
      trend: "up",
      spark: [30, 28, 33, 31, 36, 34, 39],
    },
    {
      label: "Bounce rate",
      value: "38.2%",
      delta: "-1.8%",
      trend: "down",
      spark: [46, 44, 45, 42, 43, 40, 38],
    },
  ],
  "14d": [
    {
      label: "Visitors",
      value: "46,920",
      delta: "+9.6%",
      trend: "up",
      spark: [14, 16, 13, 19, 18, 24, 27],
    },
    {
      label: "Page views",
      value: "182,104",
      delta: "+6.4%",
      trend: "up",
      spark: [44, 49, 46, 55, 52, 61, 67],
    },
    {
      label: "Avg. time",
      value: "3m 31s",
      delta: "0s",
      trend: "flat",
      spark: [32, 31, 33, 32, 33, 32, 33],
    },
    {
      label: "Bounce rate",
      value: "39.6%",
      delta: "-0.9%",
      trend: "down",
      spark: [45, 44, 46, 43, 44, 41, 40],
    },
  ],
  "28d": [
    {
      label: "Visitors",
      value: "88,475",
      delta: "+21.2%",
      trend: "up",
      spark: [10, 13, 12, 17, 16, 22, 28],
    },
    {
      label: "Page views",
      value: "351,889",
      delta: "+17.8%",
      trend: "up",
      spark: [36, 42, 40, 51, 49, 60, 71],
    },
    {
      label: "Avg. time",
      value: "3m 24s",
      delta: "+18s",
      trend: "up",
      spark: [27, 29, 28, 32, 31, 35, 38],
    },
    {
      label: "Bounce rate",
      value: "40.4%",
      delta: "+0.6%",
      trend: "up",
      spark: [39, 40, 39, 41, 40, 42, 41],
    },
  ],
};

type 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: "18,204", share: 86 },
  { path: "/components", views: "14,911", share: 70 },
  { path: "/blocks/hero-01", views: "9,482", share: 45 },
  { path: "/theme", views: "6,150", share: 29 },
  { path: "/blocks/faq-02", views: "4,067", share: 19 },
] as const;

const CHANNELS = [
  { label: "Organic search", share: 44 },
  { label: "Direct", share: 31 },
  { label: "Referral", share: 16 },
  { label: "Social", share: 9 },
] as const;

function TrendIcon({ trend }: { trend: Kpi["trend"] }) {
  if (trend === "up") return <ArrowUpRight className="size-3" />;
  if (trend === "down") return <ArrowDownRight className="size-3" />;
  return <Minus className="size-3" />;
}

function deltaTone(kpi: Kpi) {
  const improving =
    kpi.label === "Bounce rate" ? kpi.trend === "down" : kpi.trend === "up";
  if (kpi.trend === "flat") return "bg-accent text-muted-foreground";
  return improving
    ? "bg-success/10 text-success"
    : "bg-destructive/10 text-destructive";
}

function 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>
  );
}

function linePath(values: number[], max: number) {
  const step = 100 / (values.length - 1);
  return values
    .map(
      (v, i) =>
        `${i === 0 ? "M" : "L"}${(i * step).toFixed(2)} ${(44 - (v / max) * 38).toFixed(2)}`,
    )
    .join(" ");
}

export default function 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" />
              <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>
                <span
                  className={`inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 font-mono text-[11px] leading-none ${deltaTone(k)}`}
                >
                  <TrendIcon trend={k.trend} />
                  {k.delta}
                </span>
              </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">
          <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 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 className="size-2 rounded-xs bg-muted-foreground/45" />
                    Visitors
                  </span>
                </div>
              </div>
            </CardHeader>
            <CardContent>
              <div
                role="img"
                aria-label={`Page views and visitors across ${chart.length} buckets`}
              >
                <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 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>
              </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">
                        {p.views}
                      </span>
                    </div>
                    <div className="h-1 w-full overflow-hidden rounded-full bg-accent">
                      <div
                        className="h-full rounded-full bg-foreground/70"
                        style={{ width: `${p.share}%` }}
                      />
                    </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 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>
  );
}

Dependencies

shadcn registry

badgebuttoncardselectseparator

npm

lucide-react