Web InnoventixFreeCode

Chart Line

Original · free

SVG line chart with points and grid

byWeb InnoventixReact + Tailwind
chartlinecharts
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/chart-line.json
chart-line.tsx
"use client"

import { useMemo, useRef, useState } from "react"
import type { KeyboardEvent, PointerEvent } from "react"
import { useReducedMotion } from "motion/react"

const VIEW_W = 760
const VIEW_H = 340
const PAD = { top: 30, right: 26, bottom: 46, left: 54 }
const PLOT_W = VIEW_W - PAD.left - PAD.right
const PLOT_H = VIEW_H - PAD.top - PAD.bottom
const Y_MAX = 80
const Y_TICKS = [0, 20, 40, 60, 80] as const

const MONTHS = [
  "Jan", "Feb", "Mar", "Apr", "May", "Jun",
  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
] as const

const N = MONTHS.length

type SeriesId = "organic" | "direct"

interface Series {
  id: SeriesId
  label: string
  values: number[]
  strokeClass: string
  areaClass: string
  dotClass: string
  swatchClass: string
}

const SERIES: Series[] = [
  {
    id: "organic",
    label: "Organic search",
    values: [12, 18, 22, 29, 34, 41, 47, 54, 60, 57, 66, 73],
    strokeClass: "stroke-indigo-500 dark:stroke-indigo-400",
    areaClass: "fill-indigo-500/10 dark:fill-indigo-400/10",
    dotClass: "fill-indigo-500 dark:fill-indigo-400",
    swatchClass: "bg-indigo-500 dark:bg-indigo-400",
  },
  {
    id: "direct",
    label: "Direct & referral",
    values: [8, 9, 11, 14, 15, 19, 22, 24, 27, 26, 30, 33],
    strokeClass: "stroke-emerald-500 dark:stroke-emerald-400",
    areaClass: "fill-emerald-500/10 dark:fill-emerald-400/10",
    dotClass: "fill-emerald-500 dark:fill-emerald-400",
    swatchClass: "bg-emerald-500 dark:bg-emerald-400",
  },
]

const xFor = (i: number) => PAD.left + (i / (N - 1)) * PLOT_W
const yFor = (v: number) => PAD.top + PLOT_H - (v / Y_MAX) * PLOT_H

function linePath(values: number[]): string {
  return values
    .map((v, i) => `${i === 0 ? "M" : "L"} ${xFor(i).toFixed(2)} ${yFor(v).toFixed(2)}`)
    .join(" ")
}

function areaPath(values: number[]): string {
  const base = yFor(0).toFixed(2)
  const top = values
    .map((v, i) => `${i === 0 ? "M" : "L"} ${xFor(i).toFixed(2)} ${yFor(v).toFixed(2)}`)
    .join(" ")
  return `${top} L ${xFor(N - 1).toFixed(2)} ${base} L ${xFor(0).toFixed(2)} ${base} Z`
}

const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n))

const LATEST = SERIES[0].values[N - 1]
const MOM = ((SERIES[0].values[N - 1] - SERIES[0].values[N - 2]) / SERIES[0].values[N - 2]) * 100

export default function ChartLine() {
  const reduced = useReducedMotion()
  const animate = !reduced

  const containerRef = useRef<HTMLDivElement>(null)
  const [active, setActive] = useState<number | null>(null)
  const [visible, setVisible] = useState<Record<SeriesId, boolean>>({
    organic: true,
    direct: true,
  })

  const visibleSeries = useMemo(() => SERIES.filter((s) => visible[s.id]), [visible])

  const toggle = (id: SeriesId) =>
    setVisible((v) => {
      const next = { ...v, [id]: !v[id] }
      if (!next.organic && !next.direct) return v
      return next
    })

  const moveTo = (i: number) => setActive(clamp(i, 0, N - 1))

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    switch (e.key) {
      case "ArrowRight":
      case "ArrowUp":
        e.preventDefault()
        moveTo((active ?? -1) + 1)
        break
      case "ArrowLeft":
      case "ArrowDown":
        e.preventDefault()
        moveTo((active ?? N) - 1)
        break
      case "Home":
        e.preventDefault()
        moveTo(0)
        break
      case "End":
        e.preventDefault()
        moveTo(N - 1)
        break
      case "Escape":
        if (active !== null) {
          e.preventDefault()
          setActive(null)
        }
        break
    }
  }

  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
    const el = containerRef.current
    if (!el) return
    const rect = el.getBoundingClientRect()
    const px = ((e.clientX - rect.left) / rect.width) * VIEW_W
    let nearest = 0
    let best = Infinity
    for (let i = 0; i < N; i++) {
      const d = Math.abs(xFor(i) - px)
      if (d < best) {
        best = d
        nearest = i
      }
    }
    setActive(nearest)
  }

  const activeIdx = active
  const anchorY =
    activeIdx !== null && visibleSeries.length > 0
      ? Math.min(...visibleSeries.map((s) => yFor(s.values[activeIdx])))
      : 0

  return (
    <section className="relative w-full bg-white px-4 py-16 text-slate-900 dark:bg-slate-950 dark:text-slate-100 sm:px-6 sm:py-20 lg:px-8">
      <style>{`
        @keyframes chartline-draw {
          from { stroke-dashoffset: 1; }
          to { stroke-dashoffset: 0; }
        }
        @keyframes chartline-pop {
          from { opacity: 0; transform: scale(0.2); }
          to { opacity: 1; transform: scale(1); }
        }
        @keyframes chartline-rise {
          from { opacity: 0; }
          to { opacity: 1; }
        }
        .chartline-line {
          stroke-dasharray: 1;
          stroke-dashoffset: 1;
          animation: chartline-draw 1.15s cubic-bezier(0.65, 0, 0.35, 1) forwards;
        }
        .chartline-area {
          opacity: 0;
          animation: chartline-rise 0.9s ease-out 0.5s forwards;
        }
        .chartline-dot {
          transform-box: fill-box;
          transform-origin: center;
          animation: chartline-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
        }
        @media (prefers-reduced-motion: reduce) {
          .chartline-line, .chartline-area, .chartline-dot {
            animation: none;
            stroke-dashoffset: 0;
            opacity: 1;
          }
        }
      `}</style>

      <div className="mx-auto max-w-3xl">
        <div className="rounded-2xl border border-slate-200 bg-white p-5 shadow-sm dark:border-slate-800 dark:bg-slate-900 sm:p-7">
          {/* Header */}
          <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
            <div>
              <p className="text-xs font-semibold uppercase tracking-widest text-indigo-600 dark:text-indigo-400">
                Acquisition report
              </p>
              <h2 className="mt-1 text-xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-2xl">
                Sessions by channel
              </h2>
              <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
                Thousands of sessions per month, July 2025 &ndash; June 2026
              </p>
            </div>
            <div className="flex items-baseline gap-2 rounded-xl bg-slate-50 px-3.5 py-2.5 dark:bg-slate-800/60">
              <span className="text-2xl font-semibold tabular-nums text-slate-900 dark:text-white">
                {LATEST}k
              </span>
              <span className="inline-flex items-center gap-1 text-sm font-medium text-emerald-600 dark:text-emerald-400">
                <svg viewBox="0 0 12 12" className="h-3 w-3" aria-hidden="true" fill="none">
                  <path
                    d="M6 10V2M6 2L2.5 5.5M6 2l3.5 3.5"
                    stroke="currentColor"
                    strokeWidth="1.6"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  />
                </svg>
                {MOM.toFixed(1)}%
              </span>
            </div>
          </div>

          {/* Legend / series toggles */}
          <div className="mt-5 flex flex-wrap gap-2">
            {SERIES.map((s) => {
              const on = visible[s.id]
              return (
                <button
                  key={s.id}
                  type="button"
                  onClick={() => toggle(s.id)}
                  aria-pressed={on}
                  className={`inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900 ${
                    on
                      ? "border-slate-300 bg-slate-100 text-slate-800 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-100"
                      : "border-slate-200 bg-transparent text-slate-400 dark:border-slate-700 dark:text-slate-500"
                  }`}
                >
                  <span
                    className={`h-2.5 w-2.5 rounded-full transition-opacity ${s.swatchClass} ${
                      on ? "opacity-100" : "opacity-40"
                    }`}
                  />
                  <span className={on ? "" : "line-through decoration-slate-400/70"}>
                    {s.label}
                  </span>
                </button>
              )
            })}
          </div>

          {/* Chart */}
          <div
            ref={containerRef}
            role="group"
            tabIndex={0}
            aria-label="Sessions by channel line chart. Use the left and right arrow keys to inspect each month."
            aria-describedby="chartline-readout"
            onKeyDown={onKeyDown}
            onPointerMove={onPointerMove}
            onPointerLeave={() => setActive(null)}
            className="relative mt-4 w-full rounded-xl focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900"
            style={{ aspectRatio: `${VIEW_W} / ${VIEW_H}`, touchAction: "none" }}
          >
            <svg
              viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
              className="h-full w-full overflow-visible"
              role="presentation"
            >
              {/* Horizontal grid + y labels */}
              {Y_TICKS.map((t) => {
                const y = yFor(t)
                return (
                  <g key={t}>
                    <line
                      x1={PAD.left}
                      y1={y}
                      x2={VIEW_W - PAD.right}
                      y2={y}
                      className="stroke-slate-200 dark:stroke-slate-700/60"
                      strokeWidth={1}
                    />
                    <text
                      x={PAD.left - 12}
                      y={y}
                      textAnchor="end"
                      dominantBaseline="middle"
                      className="fill-slate-400 dark:fill-slate-500"
                      fontSize={11}
                    >
                      {t}k
                    </text>
                  </g>
                )
              })}

              {/* Vertical crosshair */}
              {activeIdx !== null && (
                <line
                  x1={xFor(activeIdx)}
                  y1={PAD.top - 4}
                  x2={xFor(activeIdx)}
                  y2={VIEW_H - PAD.bottom}
                  className="stroke-slate-300 dark:stroke-slate-600"
                  strokeWidth={1}
                  strokeDasharray="4 4"
                />
              )}

              {/* Series: area + line + points */}
              {visibleSeries.map((s) => (
                <g key={s.id}>
                  <path
                    d={areaPath(s.values)}
                    className={`${s.areaClass} ${animate ? "chartline-area" : ""}`}
                  />
                  <path
                    d={linePath(s.values)}
                    fill="none"
                    pathLength={1}
                    strokeWidth={2.5}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    className={`${s.strokeClass} ${animate ? "chartline-line" : ""}`}
                  />
                  {s.values.map((v, i) => {
                    const on = activeIdx === i
                    return (
                      <circle
                        key={i}
                        cx={xFor(i)}
                        cy={yFor(v)}
                        r={on ? 5 : 3.5}
                        strokeWidth={on ? 2.5 : 1.5}
                        className={`${s.dotClass} stroke-white transition-[r] dark:stroke-slate-900 ${
                          animate ? "chartline-dot" : ""
                        }`}
                        style={animate ? { animationDelay: `${0.45 + i * 0.05}s` } : undefined}
                      />
                    )
                  })}
                </g>
              ))}

              {/* X labels */}
              {MONTHS.map((m, i) => (
                <text
                  key={m}
                  x={xFor(i)}
                  y={VIEW_H - PAD.bottom + 22}
                  textAnchor="middle"
                  fontSize={11}
                  className={
                    activeIdx === i
                      ? "fill-slate-900 font-semibold dark:fill-slate-100"
                      : "fill-slate-400 dark:fill-slate-500"
                  }
                >
                  {m}
                </text>
              ))}
            </svg>

            {/* Tooltip */}
            {activeIdx !== null && visibleSeries.length > 0 && (
              <div
                className="pointer-events-none absolute z-10 -translate-x-1/2 -translate-y-full"
                style={{
                  left: `${clamp((xFor(activeIdx) / VIEW_W) * 100, 9, 91)}%`,
                  top: `${(anchorY / VIEW_H) * 100}%`,
                }}
              >
                <div className="mb-3 min-w-[9.5rem] rounded-xl border border-slate-200 bg-white/95 px-3 py-2.5 shadow-lg backdrop-blur dark:border-slate-700 dark:bg-slate-800/95">
                  <p className="text-xs font-semibold text-slate-500 dark:text-slate-400">
                    {MONTHS[activeIdx]}{" "}
                    {activeIdx >= 6 ? "2025" : "2026"}
                  </p>
                  <ul className="mt-1.5 space-y-1">
                    {visibleSeries.map((s) => (
                      <li key={s.id} className="flex items-center gap-2 text-sm">
                        <span className={`h-2 w-2 shrink-0 rounded-full ${s.swatchClass}`} />
                        <span className="text-slate-600 dark:text-slate-300">{s.label}</span>
                        <span className="ml-auto font-semibold tabular-nums text-slate-900 dark:text-white">
                          {s.values[activeIdx]}k
                        </span>
                      </li>
                    ))}
                  </ul>
                </div>
              </div>
            )}
          </div>

          {/* Live readout */}
          <p
            id="chartline-readout"
            aria-live="polite"
            className="mt-3 text-center text-sm text-slate-500 dark:text-slate-400"
          >
            {activeIdx === null
              ? "Hover the chart or use the arrow keys to inspect a month."
              : `${MONTHS[activeIdx]} ${activeIdx >= 6 ? "2025" : "2026"} — ${visibleSeries
                  .map((s) => `${s.label}: ${s.values[activeIdx]}k sessions`)
                  .join(", ")}.`}
          </p>

          {/* Screen-reader data table */}
          <table className="sr-only">
            <caption>Monthly sessions by channel, in thousands.</caption>
            <thead>
              <tr>
                <th scope="col">Month</th>
                {SERIES.map((s) => (
                  <th key={s.id} scope="col">
                    {s.label}
                  </th>
                ))}
              </tr>
            </thead>
            <tbody>
              {MONTHS.map((m, i) => (
                <tr key={m}>
                  <th scope="row">
                    {m} {i >= 6 ? "2025" : "2026"}
                  </th>
                  {SERIES.map((s) => (
                    <td key={s.id}>{s.values[i]}k</td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </div>
      </div>
    </section>
  )
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →