# Revenue Dashboard with Line Charts

> Client revenue dashboard with a 30-day, 90-day or 12-month toggle and four metric cards, each with a total, change badge and recharts line with tooltip. Requires recharts 3.8.0.

A shadcn/ui block from the [@01orbital](https://01orbital.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/01orbital/revenue-dashboard-01).

- Page, with a live preview: https://shadcn.exchange/r/01orbital/revenue-dashboard-01
- Screenshot: https://shadcn.exchange/shot/01orbital/revenue-dashboard-01/light.png
- Registry item: https://01orbital.dev/r/revenue-dashboard-01.json
- Categories: stats
- Listed: 2026-11-01, source last changed 2026-11-01

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/01orbital/revenue-dashboard-01.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@phosphor-icons/react`, `recharts`
- Registry items: `@01orbital/badge`, `card`, `@01orbital/chart`, `@01orbital/font-geist-mono`, `@01orbital/toggle-group`

## Files

### `blocks/stats/revenue-dashboard-01/index.tsx`

```tsx
"use client"

import { useState } from "react"
import { TrendDownIcon, TrendUpIcon } from "@phosphor-icons/react"
import { Line, LineChart, XAxis } from "recharts"

import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

// The copy: change text here, not in the markup.
const COPY = {
  title: "Revenue",
  range: "Range",
  period: (range: Range) => `${range.period}, against ${range.before}`,
  signed: (up: boolean, percent: string) => `${up ? "+" : "−"}${percent}`,
  change: (up: boolean, percent: string, range: Range) =>
    `${up ? "Up" : "Down"} ${percent} on ${range.previous}`,
  chart: (metric: string, range: Range) => `${metric}, ${range.period}`,
  status: (range: Range) =>
    `Showing ${range.label}: ${range.period}, against ${range.before}.`,
} as const

type Range = (typeof RANGES)[number]
type RangeValue = Range["value"]

const RANGES = [
  {
    value: "30d",
    label: "30 days",
    period: "Sep 27 – Oct 26",
    before: "the 30 days before",
    previous: "the previous 30 days",
  },
  {
    value: "90d",
    label: "90 days",
    period: "Jul 29 – Oct 26",
    before: "the 90 days before",
    previous: "the previous 90 days",
  },
  {
    value: "12m",
    label: "12 months",
    period: "Nov 1, 2025 – Oct 26, 2026",
    before: "the 12 months before",
    previous: "the previous 12 months",
  },
] as const

type MetricKey = (typeof METRICS)[number]["key"]
type Measured = Exclude<MetricKey, "net">

const METRICS = [
  { key: "gross", label: "Gross revenue", money: true },
  { key: "net", label: "Net revenue", money: true },
  { key: "customers", label: "New customers", money: false },
  { key: "refunds", label: "Refunds", money: true },
] as const

// Net is what gross keeps after the 8% payment and platform fee.
const NET_SHARE = 0.92

// Daily figures, Jul 29 – Oct 26. The 30-day range reads the last 30 days.
const DAILY: Record<Measured, number[]> = {
  gross: [
    612, 518, 572, 441, 469, 607, 608, 604, 523, 573, 500, 513, 610, 628, 564,
    567, 551, 453, 444, 542, 623, 608, 534, 579, 486, 526, 541, 584, 637, 555,
    577, 486, 493, 560, 589, 553, 523, 500, 439, 503, 613, 550, 621, 625, 606,
    494, 478, 561, 597, 608, 553, 536, 496, 439, 603, 579, 556, 639, 564, 447,
    474, 612, 590, 652, 635, 596, 493, 509, 649, 613, 643, 622, 525, 502, 506,
    652, 1247, 823, 745, 558, 486, 528, 599, 684, 677, 546, 574, 548, 529, 603,
  ],
  customers: [
    8, 6, 7, 4, 4, 6, 7, 7, 7, 7, 6, 7, 7, 8, 8, 7, 5, 5, 4, 5, 7, 6, 6, 6, 6,
    6, 7, 7, 7, 5, 8, 6, 5, 6, 6, 8, 6, 6, 5, 7, 6, 5, 9, 9, 7, 5, 6, 5, 9, 8,
    7, 8, 7, 6, 6, 7, 5, 8, 6, 4, 5, 8, 8, 8, 7, 6, 5, 5, 8, 7, 6, 9, 8, 7, 7,
    8, 14, 11, 10, 7, 5, 7, 6, 6, 6, 8, 5, 6, 5, 6,
  ],
  refunds: [
    44, 53, 52, 22, 0, 0, 0, 44, 43, 38, 35, 17, 21, 0, 21, 0, 0, 0, 0, 26, 0,
    0, 0, 0, 0, 21, 21, 44, 88, 0, 0, 0, 23, 0, 57, 0, 0, 28, 0, 18, 0, 47, 0,
    28, 23, 47, 103, 0, 18, 56, 0, 0, 28, 28, 0, 41, 28, 28, 37, 0, 0, 18, 37,
    37, 37, 0, 12, 49, 0, 37, 52, 37, 0, 31, 30, 0, 18, 0, 18, 24, 24, 0, 0, 42,
    42, 15, 15, 0, 37, 0,
  ],
}

// Monthly figures, Nov 2025 – Oct 2026. October runs to the 26th.
const MONTHLY: Record<Measured, number[]> = {
  gross: [
    18201, 19902, 15300, 14098, 15799, 15941, 15882, 15308, 16791, 16986, 16600,
    16092,
  ],
  customers: [214, 236, 176, 162, 186, 192, 190, 180, 197, 192, 200, 185],
  refunds: [560, 610, 720, 450, 470, 480, 640, 610, 789, 464, 707, 520],
}

// Totals for the span just before each range: the change compares to these.
const PREVIOUS: Record<RangeValue, Record<Measured, number>> = {
  "30d": { gross: 16388, customers: 196, refunds: 638 },
  "90d": { gross: 46794, customers: 552, refunds: 1905 },
  "12m": { gross: 154330, customers: 1880, refunds: 6430 },
}

type Point = { label: string } & Record<MetricKey, number>

const DAY = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  timeZone: "UTC",
})
const MONTH = new Intl.DateTimeFormat("en-US", {
  month: "short",
  year: "numeric",
  timeZone: "UTC",
})
const CURRENCY = new Intl.NumberFormat("en-US", {
  style: "currency",
  currency: "USD",
  maximumFractionDigits: 0,
})
const COUNT = new Intl.NumberFormat("en-US")

const dayLabel = (index: number) => DAY.format(Date.UTC(2026, 6, 29 + index))
const monthLabel = (index: number) => MONTH.format(Date.UTC(2025, 10 + index))

function toPoints(
  series: Record<Measured, number[]>,
  label: (index: number) => string,
  from = 0
): Point[] {
  return series.gross.slice(from).map((gross, offset) => {
    const index = from + offset
    return {
      label: label(index),
      gross,
      net: Math.round(gross * NET_SHARE),
      customers: series.customers[index],
      refunds: series.refunds[index],
    }
  })
}

const POINTS: Record<RangeValue, Point[]> = {
  "30d": toPoints(DAILY, dayLabel, 60),
  "90d": toPoints(DAILY, dayLabel),
  "12m": toPoints(MONTHLY, monthLabel),
}

// One ink for every line: the tile's label already names the series.
const CHART_CONFIG = Object.fromEntries(
  METRICS.map((metric) => [
    metric.key,
    { label: metric.label, color: "var(--chart-2)" },
  ])
) satisfies ChartConfig

const total = (points: Point[], key: MetricKey) =>
  points.reduce((sum, point) => sum + point[key], 0)

const previousTotal = (range: RangeValue, key: MetricKey) =>
  key === "net"
    ? Math.round(PREVIOUS[range].gross * NET_SHARE)
    : PREVIOUS[range][key]

const format = (value: number, money: boolean) =>
  money ? CURRENCY.format(value) : COUNT.format(value)

// Stock ChartContainer's label color misses recharts 3's label layer, so
// the tick paints its own.
function EdgeTick({
  x = 0,
  y = 0,
  index = 0,
  payload,
}: {
  x?: number
  y?: number
  index?: number
  payload?: { value?: string }
}) {
  return (
    <text
      x={x}
      y={y}
      dy="0.71em"
      textAnchor={index === 0 ? "start" : "end"}
      fill="var(--muted-foreground)"
    >
      {payload?.value}
    </text>
  )
}

export default function RevenueDashboard01() {
  const [rangeValue, setRangeValue] = useState<RangeValue>("30d")
  const [status, setStatus] = useState("")
  const range =
    RANGES.find((option) => option.value === rangeValue) ?? RANGES[0]
  const points = POINTS[range.value]
  // Only the first and the last point get a date under the line.
  const ticks = [points[0].label, points[points.length - 1].label]

  return (
    <section className="@container/revenue mx-auto flex w-full max-w-4xl flex-col gap-6 px-4 sm:px-6">
      <div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
        <div className="grid gap-1">
          <h2 className="text-xl font-medium tracking-tight">{COPY.title}</h2>
          <p className="text-sm text-muted-foreground">{COPY.period(range)}</p>
        </div>
        <ToggleGroup
          aria-label={COPY.range}
          value={[range.value]}
          variant="outline"
          size="sm"
          spacing={0}
          className="max-w-full flex-wrap"
          onValueChange={(values) => {
            const next = RANGES.find((option) => option.value === values[0])
            if (!next) return
            setRangeValue(next.value)
            setStatus(COPY.status(next))
          }}
        >
          {RANGES.map((option) => (
            <ToggleGroupItem
              key={option.value}
              value={option.value}
              className="text-muted-foreground aria-pressed:text-foreground"
            >
              {option.label}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </div>

      <ul className="grid gap-4 @min-[40rem]/revenue:grid-cols-2">
        {METRICS.map((metric) => {
          const value = total(points, metric.key)
          const before = previousTotal(range.value, metric.key)
          const change = ((value - before) / before) * 100
          const up = change >= 0
          const percent = `${Math.abs(change).toFixed(1)}%`

          return (
            <li key={metric.key} className="grid">
              <Card size="sm">
                <CardHeader>
                  <h3 className="text-sm text-muted-foreground">
                    {metric.label}
                  </h3>
                  <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
                    <p className="text-2xl font-medium tabular-nums">
                      {format(value, metric.money)}
                    </p>
                    <Badge variant="outline" className="tabular-nums">
                      {up ? (
                        <TrendUpIcon
                          weight="light"
                          aria-hidden="true"
                          data-icon="inline-start"
                        />
                      ) : (
                        <TrendDownIcon
                          weight="light"
                          aria-hidden="true"
                          data-icon="inline-start"
                        />
                      )}
                      <span aria-hidden="true">{COPY.signed(up, percent)}</span>
                      <span className="sr-only">
                        {COPY.change(up, percent, range)}
                      </span>
                    </Badge>
                  </div>
                </CardHeader>
                <CardContent>
                  <ChartContainer
                    config={CHART_CONFIG}
                    className="aspect-auto h-24 w-full"
                  >
                    <LineChart
                      data={points}
                      aria-label={COPY.chart(metric.label, range)}
                      margin={{ top: 4, right: 2, bottom: 0, left: 2 }}
                    >
                      {/* The first date starts on the card's text edge and the
                          last ends on it, so the line can run the full width. */}
                      <XAxis
                        dataKey="label"
                        ticks={ticks}
                        interval={0}
                        tick={<EdgeTick />}
                        tickLine={false}
                        axisLine={false}
                        tickMargin={8}
                      />
                      <ChartTooltip
                        content={
                          <ChartTooltipContent
                            formatter={(amount) => (
                              <span className="flex w-full items-center justify-between gap-4">
                                <span className="text-muted-foreground">
                                  {metric.label}
                                </span>
                                <span className="font-mono font-medium text-foreground tabular-nums">
                                  {format(Number(amount), metric.money)}
                                </span>
                              </span>
                            )}
                          />
                        }
                      />
                      <Line
                        dataKey={metric.key}
                        type="monotone"
                        stroke={`var(--color-${metric.key})`}
                        strokeWidth={2}
                        dot={false}
                        isAnimationActive={false}
                      />
                    </LineChart>
                  </ChartContainer>
                </CardContent>
              </Card>
            </li>
          )
        })}
      </ul>

      <p role="status" className="sr-only">
        {status}
      </p>
    </section>
  )
}
```
