Shadcn Interactive Area Chart

A stacked daily chart with range buttons (7 / 30 / 90 days / All) and a drag-to-zoom brush, the classic analytics dashboard view.

CSV, TSV from Excel/Sheets, a Markdown table, or JSON.

CSV
Default range
Zoom
Tooltip
Code
Image
chart.tsx
"use client"

import { useId, useState } from "react"
import { Area, AreaChart, Brush, CartesianGrid, XAxis, YAxis } from "recharts"
import {
  type ChartConfig,
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
} from "@/components/ui/chart"

const data = [
  { Date: "2024-04-01", Desktop: 320, Mobile: 189 },
  { Date: "2024-04-02", Desktop: 350, Mobile: 182 },
  { Date: "2024-04-03", Desktop: 336, Mobile: 187 },
  { Date: "2024-04-04", Desktop: 332, Mobile: 190 },
  { Date: "2024-04-05", Desktop: 368, Mobile: 181 },
  { Date: "2024-04-06", Desktop: 236, Mobile: 248 },
  { Date: "2024-04-07", Desktop: 221, Mobile: 261 },
  { Date: "2024-04-08", Desktop: 360, Mobile: 188 },
  { Date: "2024-04-09", Desktop: 390, Mobile: 181 },
  { Date: "2024-04-10", Desktop: 379, Mobile: 186 },
  { Date: "2024-04-11", Desktop: 348, Mobile: 197 },
  { Date: "2024-04-12", Desktop: 359, Mobile: 195 },
  { Date: "2024-04-13", Desktop: 233, Mobile: 266 },
  { Date: "2024-04-14", Desktop: 214, Mobile: 280 },
  { Date: "2024-04-15", Desktop: 320, Mobile: 211 },
  { Date: "2024-04-16", Desktop: 340, Mobile: 207 },
  { Date: "2024-04-17", Desktop: 344, Mobile: 207 },
  { Date: "2024-04-18", Desktop: 307, Mobile: 219 },
  { Date: "2024-04-19", Desktop: 300, Mobile: 223 },
  { Date: "2024-04-20", Desktop: 204, Mobile: 300 },
  { Date: "2024-04-21", Desktop: 201, Mobile: 305 },
  { Date: "2024-04-22", Desktop: 296, Mobile: 229 },
  { Date: "2024-04-23", Desktop: 313, Mobile: 226 },
  { Date: "2024-04-24", Desktop: 347, Mobile: 217 },
  { Date: "2024-04-25", Desktop: 335, Mobile: 222 },
  { Date: "2024-04-26", Desktop: 323, Mobile: 228 },
  { Date: "2024-04-27", Desktop: 224, Mobile: 303 },
  { Date: "2024-04-28", Desktop: 241, Mobile: 293 },
  { Date: "2024-04-29", Desktop: 370, Mobile: 218 },
  { Date: "2024-04-30", Desktop: 373, Mobile: 219 },
  { Date: "2024-05-01", Desktop: 417, Mobile: 208 },
  { Date: "2024-05-02", Desktop: 426, Mobile: 207 },
  { Date: "2024-05-03", Desktop: 399, Mobile: 216 },
  { Date: "2024-05-04", Desktop: 256, Mobile: 296 },
  { Date: "2024-05-05", Desktop: 278, Mobile: 284 },
  { Date: "2024-05-06", Desktop: 431, Mobile: 211 },
  { Date: "2024-05-07", Desktop: 401, Mobile: 222 },
  { Date: "2024-05-08", Desktop: 421, Mobile: 217 },
  { Date: "2024-05-09", Desktop: 436, Mobile: 215 },
  { Date: "2024-05-10", Desktop: 398, Mobile: 227 },
  { Date: "2024-05-11", Desktop: 233, Mobile: 327 },
  { Date: "2024-05-12", Desktop: 248, Mobile: 319 },
  { Date: "2024-05-13", Desktop: 397, Mobile: 233 },
  { Date: "2024-05-14", Desktop: 353, Mobile: 247 },
  { Date: "2024-05-15", Desktop: 350, Mobile: 249 },
  { Date: "2024-05-16", Desktop: 380, Mobile: 242 },
  { Date: "2024-05-17", Desktop: 364, Mobile: 249 },
  { Date: "2024-05-18", Desktop: 206, Mobile: 361 },
  { Date: "2024-05-19", Desktop: 220, Mobile: 353 },
  { Date: "2024-05-20", Desktop: 387, Mobile: 247 },
  { Date: "2024-05-21", Desktop: 365, Mobile: 254 },
  { Date: "2024-05-22", Desktop: 355, Mobile: 259 },
  { Date: "2024-05-23", Desktop: 400, Mobile: 247 },
  { Date: "2024-05-24", Desktop: 423, Mobile: 242 },
  { Date: "2024-05-25", Desktop: 248, Mobile: 348 },
  { Date: "2024-05-26", Desktop: 255, Mobile: 345 },
  { Date: "2024-05-27", Desktop: 462, Mobile: 235 },
  { Date: "2024-05-28", Desktop: 465, Mobile: 236 },
  { Date: "2024-05-29", Desktop: 440, Mobile: 245 },
  { Date: "2024-05-30", Desktop: 466, Mobile: 239 },
  { Date: "2024-05-31", Desktop: 502, Mobile: 230 },
  { Date: "2024-06-01", Desktop: 297, Mobile: 331 },
  { Date: "2024-06-02", Desktop: 282, Mobile: 343 },
  { Date: "2024-06-03", Desktop: 484, Mobile: 240 },
  { Date: "2024-06-04", Desktop: 496, Mobile: 238 },
  { Date: "2024-06-05", Desktop: 451, Mobile: 253 },
  { Date: "2024-06-06", Desktop: 435, Mobile: 259 },
  { Date: "2024-06-07", Desktop: 465, Mobile: 252 },
  { Date: "2024-06-08", Desktop: 280, Mobile: 357 },
  { Date: "2024-06-09", Desktop: 249, Mobile: 380 },
  { Date: "2024-06-10", Desktop: 406, Mobile: 274 },
  { Date: "2024-06-11", Desktop: 434, Mobile: 267 },
  { Date: "2024-06-12", Desktop: 405, Mobile: 277 },
  { Date: "2024-06-13", Desktop: 371, Mobile: 289 },
  { Date: "2024-06-14", Desktop: 401, Mobile: 282 },
  { Date: "2024-06-15", Desktop: 264, Mobile: 383 },
  { Date: "2024-06-16", Desktop: 244, Mobile: 398 },
  { Date: "2024-06-17", Desktop: 389, Mobile: 290 },
  { Date: "2024-06-18", Desktop: 440, Mobile: 277 },
  { Date: "2024-06-19", Desktop: 455, Mobile: 274 },
  { Date: "2024-06-20", Desktop: 427, Mobile: 283 },
  { Date: "2024-06-21", Desktop: 449, Mobile: 278 },
  { Date: "2024-06-22", Desktop: 312, Mobile: 367 },
  { Date: "2024-06-23", Desktop: 310, Mobile: 370 },
  { Date: "2024-06-24", Desktop: 477, Mobile: 275 },
  { Date: "2024-06-25", Desktop: 516, Mobile: 265 },
  { Date: "2024-06-26", Desktop: 553, Mobile: 256 },
  { Date: "2024-06-27", Desktop: 524, Mobile: 266 },
  { Date: "2024-06-28", Desktop: 507, Mobile: 272 },
  { Date: "2024-06-29", Desktop: 339, Mobile: 364 },
]

const chartConfig = {
  Desktop: { label: "Desktop", color: "var(--chart-1)" },
  Mobile: { label: "Mobile", color: "var(--chart-2)" },
} satisfies ChartConfig

const RANGES = [
  { value: "7d", label: "7 days", days: 7 },
  { value: "30d", label: "30 days", days: 30 },
  { value: "90d", label: "90 days", days: 90 },
  { value: "all", label: "All", days: null },
] as const

/** Tooltip for multi-series charts: the row total plus each series' share. */
function ChartBreakdownTooltip({
  active,
  payload,
  label,
  labelFormatter,
  config,
}: {
  active?: boolean
  payload?: ReadonlyArray<{ dataKey?: unknown; value?: unknown }>
  label?: string | number
  labelFormatter?: (label: unknown) => string
  config: ChartConfig
}) {
  if (!active || !payload?.length) return null

  const entries = payload
    .map((item) => {
      const key = String(item.dataKey)
      const value = Math.abs(Number(item.value))
      return {
        key,
        label: config[key]?.label ?? key,
        color: config[key]?.color ?? "var(--chart-1)",
        value: Number.isFinite(value) ? value : 0,
      }
    })
    .filter((entry) => entry.value > 0)
  if (entries.length === 0) return null

  const total = entries.reduce((sum, entry) => sum + entry.value, 0)
  const stops = entries.map((entry, index) => {
    const before = entries.slice(0, index).reduce((sum, e) => sum + e.value, 0)
    return `${entry.color} ${(before / total) * 100}% ${((before + entry.value) / total) * 100}%`
  })

  return (
    <div className="min-w-52 rounded-xl border border-border bg-card p-3 text-xs shadow-lg">
      <div className="mb-2 flex items-center justify-between gap-3">
        <span className="font-medium text-foreground">
          {label !== undefined && labelFormatter ? labelFormatter(label) : label}
        </span>
        <span className="font-mono font-semibold text-foreground">
          {new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(total)}
        </span>
      </div>
      <div className="flex items-center gap-3">
        <div
          className="relative size-14 shrink-0 rounded-full"
          style={{ background: `conic-gradient(${stops.join(", ")})` }}
        >
          <div className="absolute inset-1.5 rounded-full bg-card" />
        </div>
        <div className="flex flex-1 flex-col gap-1">
          {entries.map((entry) => (
            <div key={entry.key} className="flex items-center justify-between gap-3">
              <span className="flex items-center gap-1.5 text-muted-foreground">
                <span className="size-1.5 shrink-0 rounded-full" style={{ backgroundColor: entry.color }} />
                <span className="truncate">{entry.label}</span>
              </span>
              <span className="shrink-0 font-medium text-foreground">
                {Math.round((entry.value / total) * 100)}%
              </span>
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

export function Chart() {
  const uid = useId().replace(/[^\w-]/g, "")
  const [range, setRange] = useState<(typeof RANGES)[number]["value"]>("90d")
  const days = RANGES.find((option) => option.value === range)?.days ?? null
  const newest = new Date(String(data[data.length - 1]?.Date ?? "")).getTime()
  const chartData =
    days === null
      ? data
      : data.filter((row) => new Date(String(row.Date)).getTime() >= newest - (days - 1) * 86_400_000)

  return (
    <div className="flex w-full flex-col gap-3">
      <div className="flex justify-end">
        <div role="group" aria-label="Time range" className="inline-flex rounded-lg border border-border bg-muted/40 p-0.5">
          {RANGES.map((option) => (
            <button
              key={option.value}
              type="button"
              aria-pressed={range === option.value}
              onClick={() => setRange(option.value)}
              className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
                range === option.value
                  ? "bg-background text-foreground shadow-sm"
                  : "text-muted-foreground hover:text-foreground"
              }`}
            >
              {option.label}
            </button>
          ))}
        </div>
      </div>
      <ChartContainer config={chartConfig} className="aspect-auto h-[320px] w-full">
        <AreaChart accessibilityLayer data={chartData}>
          <defs>
            <linearGradient id={`${uid}-fill-0`} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor="var(--color-Desktop)" stopOpacity={0.8} />
              <stop offset="100%" stopColor="var(--color-Desktop)" stopOpacity={0.05} />
            </linearGradient>
            <linearGradient id={`${uid}-fill-1`} x1="0" y1="0" x2="0" y2="1">
              <stop offset="0%" stopColor="var(--color-Mobile)" stopOpacity={0.8} />
              <stop offset="100%" stopColor="var(--color-Mobile)" stopOpacity={0.05} />
            </linearGradient>
          </defs>
          <CartesianGrid vertical={false} strokeDasharray="3 5" />
          <XAxis dataKey="Date" tickLine={false} axisLine={false} tickMargin={8} minTickGap={32} tickFormatter={(value) => new Date(value).toLocaleDateString("en-US", { month: "short", day: "numeric" })} />
          <YAxis tickLine={false} axisLine={false} tickMargin={8} width={40} tickFormatter={(value) => new Intl.NumberFormat("en-US", { notation: "compact", maximumFractionDigits: 1 }).format(value)} />
          <ChartTooltip content={<ChartBreakdownTooltip config={chartConfig} labelFormatter={(value) => new Date(String(value)).toLocaleDateString("en-US", { month: "short", day: "numeric" })} />} />
          <ChartLegend content={<ChartLegendContent />} itemSorter={null} />
          <Area dataKey="Desktop" type="natural" stackId="stack" fill={`url(#${uid}-fill-0)`} fillOpacity={1} stroke="var(--color-Desktop)" strokeWidth={2} />
          <Area dataKey="Mobile" type="natural" stackId="stack" fill={`url(#${uid}-fill-1)`} fillOpacity={1} stroke="var(--color-Mobile)" strokeWidth={2} animationBegin={120} />
          {chartData.length > 2 && (
            <Brush key={range} dataKey="Date" height={28} travellerWidth={8} stroke="var(--border)" fill="var(--card)" tickFormatter={(value) => new Date(value).toLocaleDateString("en-US", { month: "short", day: "numeric" })} />
          )}
        </AreaChart>
      </ChartContainer>
    </div>
  )
}

When to use an interactive area chart

Use it for daily metrics where people want both the big picture and a close look at recent days.

  • Website visitors by device
  • Daily revenue or orders
  • API requests by region

Data format

  • Column 1 should be dates (2024-04-01); ranges then filter by days back from your newest date.
  • Without dates, the range buttons keep the last 7 / 30 / 90 rows instead.
  • Paste CSV, JSON from your API, TSV copied from Excel or Google Sheets, or a Markdown table. The generated component keeps your column names as its data keys.
CSV (first 6 of 90 rows)
Date,Desktop,Mobile
2024-04-01,320,189
2024-04-02,350,182
2024-04-03,336,187
2024-04-04,332,190
2024-04-05,368,181
2024-04-06,236,248
Same data as JSON
[
  {"date": "2024-04-01", "desktop": 320, "mobile": 189},
  {"date": "2024-04-02", "desktop": 350, "mobile": 182},
  {"date": "2024-04-03", "desktop": 336, "mobile": 187},
  …
]

Options

Default range
Which range button is selected when the chart loads.
Brush
Show or hide the drag-to-zoom brush under the chart.
Tooltip
Breakdown tooltip or shadcn's default.

How to add it to your project

  1. 1. Add the shadcn/ui chart component (it installs Recharts):
    npx shadcn@latest add chart
  2. 2. Paste your data above, pick the options, and copy the generated chart.tsx.
  3. 3. Save it in your project (e.g. components/interactive-chart.tsx) and render <Chart />, or <Chart data={rows} /> in Data as prop mode.

Interactive Area Chart FAQ