Line Chart

Multi-series line chart with animated paths, highlight segments, and interactive tooltips

Installation

pnpm dlx shadcn@latest add "https://vritti.thesatyajit.com/r/line-chart"

Dependencies

pnpm add @visx/responsive @visx/scale @visx/shape @visx/group @visx/event @visx/grid d3-array motion react-use-measure @base-ui/react

Examples

Dashed

"use client"
import LineChart, {
  Line,
  Grid,
  XAxis,
  YAxis,
  ChartTooltip,
  useChart,
} from "@/components/vritti/line-chart"
import { LinePath } from "@visx/shape"
import { curveNatural } from "@visx/curve"

const data = [
  { date: new Date(2024, 0, 1), actual: 3200, projected: 3200 },
  { date: new Date(2024, 1, 1), actual: 3600, projected: 3500 },
  { date: new Date(2024, 2, 1), actual: 4100, projected: 3800 },
  { date: new Date(2024, 3, 1), actual: 3800, projected: 4100 },
  { date: new Date(2024, 4, 1), actual: 4500, projected: 4400 },
  { date: new Date(2024, 5, 1), actual: 5200, projected: 4700 },
  { date: new Date(2024, 6, 1), actual: 5000, projected: 5000 },
  { date: new Date(2024, 7, 1), actual: 5800, projected: 5300 },
]

function DashedLine() {
  const { data, xScale, yScale, xAccessor, isLoaded } = useChart()

  if (!isLoaded) return null

  return (
    <LinePath
      data={data}
      x={(d) => xScale(xAccessor(d)) ?? 0}
      y={(d) => {
        const val = d.projected
        return typeof val === "number" ? (yScale(val) ?? 0) : 0
      }}
      stroke="var(--chart-line-secondary)"
      strokeWidth={2.5}
      strokeLinecap="round"
      strokeDasharray="8,6"
      curve={curveNatural}
    />
  )
}

export function LineChartDashedDemo() {
  return (
    <div className="w-full p-4">
      <LineChart data={data}>
        <Grid horizontal />
        <Line dataKey="actual" stroke="var(--chart-line-primary)" />
        {/* Register projected for tooltip, render as dashed via custom component */}
        <Line dataKey="projected" stroke="var(--chart-line-secondary)" strokeWidth={0} fadeEdges={false} showHighlight={false} animate={false} />
        <DashedLine />
        <XAxis />
        <YAxis />
        <ChartTooltip
          rows={(point) => [
            {
              color: "var(--chart-line-primary)",
              label: "Actual",
              value: (point.actual as number)?.toLocaleString() ?? "0",
            },
            {
              color: "var(--chart-line-secondary)",
              label: "Projected",
              value: (point.projected as number)?.toLocaleString() ?? "0",
            },
          ]}
        />
      </LineChart>
    </div>
  )
}

Dots

"use client"
import LineChart, {
  Line,
  Grid,
  XAxis,
  YAxis,
  ChartTooltip,
  useChart,
  chartCssVars,
} from "@/components/vritti/line-chart"

const data = [
  { date: new Date(2024, 0, 1), revenue: 4200 },
  { date: new Date(2024, 1, 1), revenue: 5800 },
  { date: new Date(2024, 2, 1), revenue: 5100 },
  { date: new Date(2024, 3, 1), revenue: 7200 },
  { date: new Date(2024, 4, 1), revenue: 6400 },
  { date: new Date(2024, 5, 1), revenue: 8100 },
  { date: new Date(2024, 6, 1), revenue: 7600 },
  { date: new Date(2024, 7, 1), revenue: 9300 },
]

function DataDots() {
  const { data, xScale, yScale, xAccessor, isLoaded } = useChart()

  if (!isLoaded) return null

  return (
    <g>
      {data.map((d, i) => {
        const x = xScale(xAccessor(d)) ?? 0
        const val = d.revenue
        if (typeof val !== "number") return null
        const y = yScale(val) ?? 0
        return (
          <circle
            key={i}
            cx={x}
            cy={y}
            r={4}
            fill="var(--chart-line-primary)"
            stroke={chartCssVars.background}
            strokeWidth={2}
          />
        )
      })}
    </g>
  )
}

export function LineChartDotsDemo() {
  return (
    <div className="w-full p-4">
      <LineChart data={data}>
        <Grid horizontal />
        <Line dataKey="revenue" stroke="var(--chart-line-primary)" showHighlight={false} />
        <DataDots />
        <XAxis />
        <YAxis />
        <ChartTooltip />
      </LineChart>
    </div>
  )
}

Gradient

"use client"
import { useId } from "react"
import LineChart, {
  Line,
  Grid,
  XAxis,
  YAxis,
  ChartTooltip,
  useChart,
} from "@/components/vritti/line-chart"
import { LinePath } from "@visx/shape"
import { curveNatural } from "@visx/curve"

const data = [
  { date: new Date(2024, 0, 1), temperature: 32 },
  { date: new Date(2024, 1, 1), temperature: 35 },
  { date: new Date(2024, 2, 1), temperature: 48 },
  { date: new Date(2024, 3, 1), temperature: 58 },
  { date: new Date(2024, 4, 1), temperature: 70 },
  { date: new Date(2024, 5, 1), temperature: 82 },
  { date: new Date(2024, 6, 1), temperature: 88 },
  { date: new Date(2024, 7, 1), temperature: 85 },
  { date: new Date(2024, 8, 1), temperature: 76 },
  { date: new Date(2024, 9, 1), temperature: 62 },
  { date: new Date(2024, 10, 1), temperature: 45 },
  { date: new Date(2024, 11, 1), temperature: 34 },
]

function GradientLine() {
  const { data, xScale, yScale, xAccessor, innerWidth, isLoaded } = useChart()
  const gradientId = useId()

  if (!isLoaded) return null

  return (
    <>
      <defs>
        <linearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="0%">
          <stop offset="0%" stopColor="hsl(210, 100%, 56%)" />
          <stop offset="35%" stopColor="hsl(280, 87%, 65%)" />
          <stop offset="65%" stopColor="hsl(340, 82%, 60%)" />
          <stop offset="100%" stopColor="hsl(30, 100%, 55%)" />
        </linearGradient>
      </defs>
      <LinePath
        data={data}
        x={(d) => xScale(xAccessor(d)) ?? 0}
        y={(d) => {
          const val = d.temperature
          return typeof val === "number" ? (yScale(val) ?? 0) : 0
        }}
        stroke={`url(#${gradientId})`}
        strokeWidth={3}
        strokeLinecap="round"
        curve={curveNatural}
      />
    </>
  )
}

export function LineChartGradientDemo() {
  return (
    <div className="w-full p-4">
      <LineChart data={data}>
        <Grid horizontal />
        {/* Hidden line for tooltip data registration */}
        <Line dataKey="temperature" stroke="hsl(280, 87%, 65%)" strokeWidth={0} fadeEdges={false} showHighlight={false} animate={false} />
        <GradientLine />
        <XAxis />
        <YAxis />
        <ChartTooltip />
      </LineChart>
    </div>
  )
}

Interactive

"use client"
import { useState } from "react"
import LineChart, {
  Line,
  Grid,
  XAxis,
  YAxis,
  ChartTooltip,
  useChart,
} from "@/components/vritti/line-chart"
import { LinePath } from "@visx/shape"
import { curveNatural } from "@visx/curve"
import { motion } from "motion/react"

const data = [
  { date: new Date(2024, 0, 1), downloads: 12000, stars: 3200, forks: 1100 },
  { date: new Date(2024, 1, 1), downloads: 14500, stars: 3800, forks: 1300 },
  { date: new Date(2024, 2, 1), downloads: 13200, stars: 4100, forks: 1250 },
  { date: new Date(2024, 3, 1), downloads: 16800, stars: 4600, forks: 1500 },
  { date: new Date(2024, 4, 1), downloads: 18200, stars: 5200, forks: 1700 },
  { date: new Date(2024, 5, 1), downloads: 17500, stars: 5800, forks: 1900 },
  { date: new Date(2024, 6, 1), downloads: 21000, stars: 6400, forks: 2100 },
  { date: new Date(2024, 7, 1), downloads: 19800, stars: 7000, forks: 2300 },
  { date: new Date(2024, 8, 1), downloads: 23500, stars: 7500, forks: 2500 },
  { date: new Date(2024, 9, 1), downloads: 22100, stars: 8200, forks: 2800 },
]

const series = [
  { dataKey: "downloads", stroke: "hsl(217, 91%, 60%)", label: "Downloads" },
  { dataKey: "stars", stroke: "hsl(45, 93%, 58%)", label: "Stars" },
  { dataKey: "forks", stroke: "hsl(160, 84%, 39%)", label: "Forks" },
]

function InteractiveLines({ hoveredKey }: { hoveredKey: string | null }) {
  const { data, xScale, yScale, xAccessor, isLoaded } = useChart()

  if (!isLoaded) return null

  return (
    <>
      {series.map((s) => {
        const isActive = hoveredKey === null || hoveredKey === s.dataKey
        return (
          <motion.g
            key={s.dataKey}
            animate={{ opacity: isActive ? 1 : 0.15 }}
            transition={{ duration: 0.3 }}
          >
            <LinePath
              data={data}
              x={(d) => xScale(xAccessor(d)) ?? 0}
              y={(d) => {
                const val = d[s.dataKey]
                return typeof val === "number" ? (yScale(val) ?? 0) : 0
              }}
              stroke={s.stroke}
              strokeWidth={hoveredKey === s.dataKey ? 3.5 : 2.5}
              strokeLinecap="round"
              curve={curveNatural}
            />
          </motion.g>
        )
      })}
    </>
  )
}

export function LineChartInteractiveDemo() {
  const [hoveredKey, setHoveredKey] = useState<string | null>(null)

  return (
    <div className="w-full p-4">
      <LineChart data={data}>
        <Grid horizontal />
        {/* Register lines for tooltip data extraction (rendered invisible) */}
        {series.map((s) => (
          <Line
            key={s.dataKey}
            dataKey={s.dataKey}
            stroke={s.stroke}
            strokeWidth={0}
            fadeEdges={false}
            showHighlight={false}
            animate={false}
          />
        ))}
        <InteractiveLines hoveredKey={hoveredKey} />
        <XAxis />
        <YAxis />
        <ChartTooltip
          rows={(point) =>
            series.map((s) => ({
              color: s.stroke,
              label: s.label,
              value: (point[s.dataKey] as number)?.toLocaleString() ?? "0",
            }))
          }
        />
      </LineChart>

      {/* Interactive legend for hover targeting */}
      <div className="mt-4 flex items-center justify-center gap-6">
        {series.map((s) => (
          <button
            key={s.dataKey}
            type="button"
            className="flex items-center gap-2 rounded-md px-2 py-1 text-sm transition-opacity"
            style={{
              opacity: hoveredKey === null || hoveredKey === s.dataKey ? 1 : 0.3,
            }}
            onMouseEnter={() => setHoveredKey(s.dataKey)}
            onMouseLeave={() => setHoveredKey(null)}
          >
            <span
              className="h-2.5 w-2.5 rounded-full"
              style={{ backgroundColor: s.stroke }}
            />
            <span>{s.label}</span>
          </button>
        ))}
      </div>
    </div>
  )
}

Legend

"use client"
import LineChart, {
  Line,
  Grid,
  XAxis,
  YAxis,
  ChartTooltip,
  Legend,
  LegendItem,
  LegendMarker,
  LegendLabel,
  LegendValue,
} from "@/components/vritti/line-chart"

const data = [
  { date: new Date(2024, 0, 1), organic: 4200, paid: 2400, referral: 1800 },
  { date: new Date(2024, 1, 1), organic: 4800, paid: 2900, referral: 2100 },
  { date: new Date(2024, 2, 1), organic: 5100, paid: 3100, referral: 1900 },
  { date: new Date(2024, 3, 1), organic: 4600, paid: 3500, referral: 2400 },
  { date: new Date(2024, 4, 1), organic: 5800, paid: 3200, referral: 2700 },
  { date: new Date(2024, 5, 1), organic: 6200, paid: 3800, referral: 2500 },
  { date: new Date(2024, 6, 1), organic: 5900, paid: 4100, referral: 3100 },
  { date: new Date(2024, 7, 1), organic: 6800, paid: 4500, referral: 2900 },
]

const legendItems = [
  { label: "Organic", value: 6800, color: "hsl(217, 91%, 60%)" },
  { label: "Paid", value: 4500, color: "hsl(160, 84%, 39%)" },
  { label: "Referral", value: 2900, color: "hsl(280, 87%, 65%)" },
]

export function LineChartLegendDemo() {
  return (
    <div className="w-full p-4">
      <LineChart data={data}>
        <Grid horizontal />
        <Line dataKey="organic" stroke="hsl(217, 91%, 60%)" />
        <Line dataKey="paid" stroke="hsl(160, 84%, 39%)" />
        <Line dataKey="referral" stroke="hsl(280, 87%, 65%)" />
        <XAxis />
        <YAxis />
        <ChartTooltip
          rows={(point) => [
            {
              color: "hsl(217, 91%, 60%)",
              label: "Organic",
              value: (point.organic as number)?.toLocaleString() ?? "0",
            },
            {
              color: "hsl(160, 84%, 39%)",
              label: "Paid",
              value: (point.paid as number)?.toLocaleString() ?? "0",
            },
            {
              color: "hsl(280, 87%, 65%)",
              label: "Referral",
              value: (point.referral as number)?.toLocaleString() ?? "0",
            },
          ]}
        />
      </LineChart>

      <div className="mt-4">
        <Legend items={legendItems}>
          <LegendItem className="flex items-center justify-between">
            <div className="flex items-center gap-2">
              <LegendMarker />
              <LegendLabel />
            </div>
            <LegendValue />
          </LegendItem>
        </Legend>
      </div>
    </div>
  )
}

Multi

"use client"
import LineChart, { Line, Grid, XAxis, YAxis, ChartTooltip } from "@/components/vritti/line-chart"

const data = [
  { date: new Date(2024, 0, 1), desktop: 186, mobile: 80 },
  { date: new Date(2024, 1, 1), desktop: 305, mobile: 200 },
  { date: new Date(2024, 2, 1), desktop: 237, mobile: 120 },
  { date: new Date(2024, 3, 1), desktop: 73, mobile: 190 },
  { date: new Date(2024, 4, 1), desktop: 209, mobile: 130 },
  { date: new Date(2024, 5, 1), desktop: 214, mobile: 140 },
]

export function LineChartMultiDemo() {
  return (
    <div className="w-full p-4">
      <LineChart data={data}>
        <Grid horizontal />
        <Line dataKey="desktop" stroke="hsl(217, 91%, 60%)" />
        <Line dataKey="mobile" stroke="hsl(280, 87%, 65%)" />
        <XAxis />
        <YAxis />
        <ChartTooltip />
      </LineChart>
    </div>
  )
}

Segment

"use client"
import { useCallback, useEffect, useState } from "react"
import LineChart, {
  ChartTooltip,
  Grid,
  Line,
  SegmentBackground,
  SegmentLineFrom,
  SegmentLineTo,
  XAxis,
  useChart,
} from "@/components/vritti/line-chart"

const chartData = [
  { date: new Date(Date.now() - 29 * 24 * 60 * 60 * 1000), sessions: 3200 },
  { date: new Date(Date.now() - 27 * 24 * 60 * 60 * 1000), sessions: 2900 },
  { date: new Date(Date.now() - 25 * 24 * 60 * 60 * 1000), sessions: 3550 },
  { date: new Date(Date.now() - 23 * 24 * 60 * 60 * 1000), sessions: 4100 },
  { date: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000), sessions: 3900 },
  { date: new Date(Date.now() - 19 * 24 * 60 * 60 * 1000), sessions: 4350 },
  { date: new Date(Date.now() - 17 * 24 * 60 * 60 * 1000), sessions: 4200 },
  { date: new Date(Date.now() - 15 * 24 * 60 * 60 * 1000), sessions: 4600 },
  { date: new Date(Date.now() - 13 * 24 * 60 * 60 * 1000), sessions: 4800 },
  { date: new Date(Date.now() - 11 * 24 * 60 * 60 * 1000), sessions: 5100 },
  { date: new Date(Date.now() - 9 * 24 * 60 * 60 * 1000), sessions: 4750 },
  { date: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), sessions: 5400 },
  { date: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000), sessions: 5600 },
  { date: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000), sessions: 5300 },
  { date: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000), sessions: 6100 },
  { date: new Date(), sessions: 6400 },
]

interface SegmentStats {
  value: number
  change: number
  changePct: number
  startDate: Date
  endDate: Date
}

function SegmentBridge({
  onSegmentChange,
}: {
  onSegmentChange: (stats: SegmentStats | null) => void
}) {
  const { selection, data, xAccessor } = useChart()

  useEffect(() => {
    if (!selection?.active) {
      onSegmentChange(null)
      return
    }

    const startIdx = Math.max(0, selection.startIndex)
    const endIdx = Math.min(data.length - 1, selection.endIndex)
    if (startIdx >= endIdx) {
      onSegmentChange(null)
      return
    }

    const startPoint = data[startIdx] as { sessions?: number }
    const endPoint = data[endIdx] as { sessions?: number }
    if (!(startPoint && endPoint)) {
      onSegmentChange(null)
      return
    }

    const startVal = startPoint.sessions
    const endVal = endPoint.sessions
    if (typeof startVal !== "number" || typeof endVal !== "number") {
      onSegmentChange(null)
      return
    }

    onSegmentChange({
      value: endVal,
      change: endVal - startVal,
      changePct: startVal !== 0 ? ((endVal - startVal) / startVal) * 100 : 0,
      startDate: xAccessor(data[startIdx]),
      endDate: xAccessor(data[endIdx]),
    })
  }, [selection, data, xAccessor, onSegmentChange])

  return null
}

export function LineChartSegmentDemo() {
  const [stats, setStats] = useState<SegmentStats | null>(null)
  const handleSegmentChange = useCallback(
    (s: SegmentStats | null) => setStats(s),
    []
  )

  const firstVal = chartData[0]?.sessions ?? 0
  const lastVal = chartData.at(-1)?.sessions ?? 0

  const displayValue = stats?.value ?? lastVal
  const displayChange = stats?.change ?? lastVal - firstVal
  const displayPct =
    stats?.changePct ??
    (firstVal > 0 ? ((lastVal - firstVal) / firstVal) * 100 : 0)
  const isPositive = displayChange >= 0

  const startLabel = (
    stats?.startDate ?? chartData[0]?.date
  )?.toLocaleDateString("en-US", { month: "short", day: "numeric" })
  const endLabel = (
    stats?.endDate ?? chartData.at(-1)?.date
  )?.toLocaleDateString("en-US", { month: "short", day: "numeric" })

  return (
    <div className="w-full p-4">
      <div className="mb-4 space-y-1">
        <div className="flex items-baseline gap-2">
          <span className="font-semibold text-2xl tabular-nums">
            {displayValue.toLocaleString()}
          </span>
          <span className="text-muted-foreground text-sm">sessions</span>
        </div>
        <div className="flex items-baseline gap-2 text-sm">
          <span className={isPositive ? "text-emerald-500" : "text-red-500"}>
            {isPositive ? "+" : ""}
            {displayChange.toLocaleString()} ({isPositive ? "+" : ""}
            {displayPct.toFixed(1)}%)
          </span>
          <span className="text-muted-foreground">
            {startLabel} &ndash; {endLabel}
          </span>
        </div>
      </div>

      <LineChart data={chartData}>
        <Grid horizontal />
        <Line dataKey="sessions" stroke="var(--chart-line-primary)" />
        <SegmentBackground />
        <SegmentLineFrom />
        <SegmentLineTo />
        <XAxis />
        <ChartTooltip />
        <SegmentBridge onSegmentChange={handleSegmentChange} />
      </LineChart>
    </div>
  )
}

Step

"use client"
import LineChart, { Line, Grid, XAxis, YAxis, ChartTooltip } from "@/components/vritti/line-chart"
import { curveStepAfter } from "@visx/curve"

const data = [
  { date: new Date(2024, 0, 1), price: 29 },
  { date: new Date(2024, 1, 15), price: 29 },
  { date: new Date(2024, 3, 1), price: 39 },
  { date: new Date(2024, 4, 15), price: 39 },
  { date: new Date(2024, 6, 1), price: 49 },
  { date: new Date(2024, 7, 15), price: 49 },
  { date: new Date(2024, 9, 1), price: 59 },
  { date: new Date(2024, 10, 1), price: 59 },
  { date: new Date(2024, 11, 1), price: 69 },
]

export function LineChartStepDemo() {
  return (
    <div className="w-full p-4">
      <LineChart data={data}>
        <Grid horizontal />
        <Line
          dataKey="price"
          stroke="var(--chart-line-primary)"
          curve={curveStepAfter}
          fadeEdges={false}
        />
        <XAxis />
        <YAxis formatValue={(v) => `$${v}`} />
        <ChartTooltip
          rows={(point) => [
            {
              color: "var(--chart-line-primary)",
              label: "Price",
              value: `$${(point.price as number) ?? 0}`,
            },
          ]}
        />
      </LineChart>
    </div>
  )
}

Tooltip

"use client"
import LineChart, { ChartTooltip, Grid, Line, XAxis, YAxis } from "@/components/vritti/line-chart"

const data = [
  { date: new Date(2024, 0, 1), pageviews: 28000, bounces: 8400 },
  { date: new Date(2024, 1, 1), pageviews: 35000, bounces: 10500 },
  { date: new Date(2024, 2, 1), pageviews: 29000, bounces: 7250 },
  { date: new Date(2024, 3, 1), pageviews: 42000, bounces: 12600 },
  { date: new Date(2024, 4, 1), pageviews: 38500, bounces: 11550 },
  { date: new Date(2024, 5, 1), pageviews: 47000, bounces: 14100 },
]

export function LineChartTooltipDemo() {
  return (
    <div className="w-full p-4">
      <LineChart data={data}>
        <Grid horizontal />
        <Line dataKey="pageviews" stroke="var(--chart-line-primary)" />
        <Line dataKey="bounces" stroke="var(--chart-line-secondary)" />
        <XAxis />
        <YAxis />
        <ChartTooltip
          rows={(point) => [
            {
              color: "var(--chart-line-primary)",
              label: "Page Views",
              value: (point.pageviews as number)?.toLocaleString() ?? "0",
            },
            {
              color: "var(--chart-line-secondary)",
              label: "Bounces",
              value: (point.bounces as number)?.toLocaleString() ?? "0",
            },
          ]}
        />
      </LineChart>
    </div>
  )
}