Przejdź do treści

Chart

Dostępne wykresy dopasowane do motywu, zbudowane na Recharts — ze sprawdzoną paletą kategorialną, stonowaną oprawą i podpowiedziami spójnymi z systemem.

Kod źródłowyRechartsRejestr
Odwiedzający w tym tygodniu27 390
"use client"

import { Area, AreaChart } from "recharts"

import { ChartContainer, type ChartConfig } from "@/components/ui/chart"

const data = [
  { day: "pon", visitors: 3120 },
  { day: "wt", visitors: 3480 },
  { day: "śr", visitors: 3290 },
  { day: "czw", visitors: 4010 },
  { day: "pt", visitors: 4620 },
  { day: "sob", visitors: 3890 },
  { day: "niedz", visitors: 4980 },
]

const config = {
  visitors: { label: "Odwiedzający", color: "var(--chart-1)" },
} satisfies ChartConfig

export default function ChartDemo() {
  return (
    <div className="flex w-full max-w-sm flex-col gap-4 rounded-xl border bg-card p-5 shadow-raised">
      <div className="flex flex-col gap-1">
        <span className="text-body-xs text-muted-foreground">
          Odwiedzający w tym tygodniu
        </span>
        <span className="text-heading-lg">27 390</span>
      </div>
      <ChartContainer config={config} className="aspect-auto h-20 w-full">
        <AreaChart
          data={data}
          margin={{ top: 4, right: 4, bottom: 4, left: 4 }}
        >
          <Area
            dataKey="visitors"
            type="monotone"
            stroke="var(--color-visitors)"
            strokeWidth={2}
            fill="var(--color-visitors)"
            fillOpacity={0.1}
            isAnimationActive={false}
          />
        </AreaChart>
      </ChartContainer>
    </div>
  )
}

Instalacja

$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/chart.json

Użycie

Wykres to Recharts wewnątrz ChartContainer. Kontener przyjmuje config, który nazywa każdą serię i przypisuje jej kolor; kolor każdej serii staje się zmienną CSS --color-<key> o zasięgu ograniczonym do tego wykresu.

import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"

import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"

const config = {
  visitors: { label: "Visitors", color: "var(--chart-1)" },
} satisfies ChartConfig

<ChartContainer config={config} className="aspect-auto h-64 w-full">
  <AreaChart data={data}>
    <CartesianGrid vertical={false} />
    <XAxis dataKey="day" tickLine={false} axisLine={false} />
    <ChartTooltip content={<ChartTooltipContent indicator="line" />} />
    <Area
      dataKey="visitors"
      stroke="var(--color-visitors)"
      strokeWidth={2}
      fill="var(--color-visitors)"
      fillOpacity={0.1}
    />
  </AreaChart>
</ChartContainer>

Nadaj kontenerowi jawną wysokość, która mieści etykiety osi — aspect-auto h-64 — żeby osie nigdy nie zostały przycięte ani nie trafiły do zagnieżdżonego obszaru przewijania.

Przykłady

Warstwowy

Jedna seria opowiada jedną historię, więc nie potrzebuje legendy — nazywa ją tytuł. Narysuj linię o grubości 2 px, a pod nią wypełnienie tym samym kolorem z kryciem 10% — spokojne tło, nigdy pełna plama koloru.

Żądania API
Wzrost o 108% rok do roku.
"use client"

import { TrendingUpIcon } from "lucide-react"
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from "recharts"

import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"

const data = [
  { month: "paź", requests: 1.82 },
  { month: "lis", requests: 1.94 },
  { month: "gru", requests: 2.11 },
  { month: "sty", requests: 2.03 },
  { month: "lut", requests: 2.38 },
  { month: "mar", requests: 2.56 },
  { month: "kwi", requests: 2.49 },
  { month: "maj", requests: 2.81 },
  { month: "cze", requests: 3.07 },
  { month: "lip", requests: 3.02 },
  { month: "sie", requests: 3.36 },
  { month: "wrz", requests: 3.78 },
]

const config = {
  requests: { label: "Żądania (mln)", color: "var(--chart-1)" },
} satisfies ChartConfig

export default function ChartArea() {
  return (
    <Card className="w-full max-w-2xl">
      <CardHeader>
        <CardTitle>Żądania API</CardTitle>
        <CardDescription>
          Liczba żądań miesięcznie, w milionach, z ostatnich 12 miesięcy.
        </CardDescription>
      </CardHeader>
      <CardContent>
        <ChartContainer config={config} className="aspect-auto h-64 w-full">
          <AreaChart data={data} margin={{ left: 0, right: 8, top: 8 }}>
            <CartesianGrid vertical={false} />
            <XAxis
              dataKey="month"
              tickLine={false}
              axisLine={false}
              tickMargin={8}
            />
            <YAxis
              tickLine={false}
              axisLine={false}
              tickMargin={8}
              width={48}
              domain={[0, 4]}
              ticks={[0, 1, 2, 3, 4]}
              tickFormatter={(value: number) => `${value} mln`}
            />
            <ChartTooltip
              cursor
              content={<ChartTooltipContent indicator="line" />}
            />
            <Area
              dataKey="requests"
              type="monotone"
              stroke="var(--color-requests)"
              strokeWidth={2}
              fill="var(--color-requests)"
              fillOpacity={0.1}
              activeDot={{ r: 4, strokeWidth: 2, stroke: "var(--card)" }}
            />
          </AreaChart>
        </ChartContainer>
      </CardContent>
      <CardFooter className="gap-2 text-sm">
        <TrendingUpIcon className="size-4 text-success-11" aria-hidden="true" />
        <span>
          <span className="font-medium">Wzrost o 108%</span>{" "}
          <span className="text-muted-foreground">rok do roku.</span>
        </span>
      </CardFooter>
    </Card>
  )
}

Liniowy

Przy dwóch lub więcej seriach legenda jest zawsze widoczna. Podpisz koniec każdej linii jej ostatnią wartością, zamiast umieszczać liczbę przy każdym punkcie.

Aktywni użytkownicy w tygodniu według platformyOstatnie 8 tygodni
"use client"

import {
  CartesianGrid,
  LabelList,
  Line,
  LineChart,
  XAxis,
  YAxis,
} from "recharts"

import {
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"

const data = [
  { week: "tydz. 1", "web": 4210, "ios": 2680, "android": 1940 },
  { week: "tydz. 2", "web": 4390, "ios": 2810, "android": 2120 },
  { week: "tydz. 3", "web": 4280, "ios": 3050, "android": 2260 },
  { week: "tydz. 4", "web": 4620, "ios": 3210, "android": 2410 },
  { week: "tydz. 5", "web": 4810, "ios": 3390, "android": 2690 },
  { week: "tydz. 6", "web": 4760, "ios": 3620, "android": 2880 },
  { week: "tydz. 7", "web": 5040, "ios": 3890, "android": 3170 },
  { week: "tydz. 8", "web": 5220, "ios": 4010, "android": 3420 },
]

const config = {
  "web": { label: "Web", color: "var(--chart-1)" },
  "ios": { label: "iOS", color: "var(--chart-2)" },
  "android": { label: "Android", color: "var(--chart-3)" },
} satisfies ChartConfig

const number = new Intl.NumberFormat("pl-PL")

/** Labels only the last point of each line — selective, never every point. */
function EndLabel(props: {
  x?: number | string
  y?: number | string
  index?: number
  value?: number | string
}) {
  if (props.index !== data.length - 1) return null
  return (
    <text
      x={Number(props.x) + 8}
      y={Number(props.y)}
      dy="0.32em"
      className="fill-foreground text-xs font-medium"
    >
      {number.format(Number(props.value))}
    </text>
  )
}

export default function ChartLine() {
  return (
    <div className="flex w-full max-w-2xl flex-col gap-2">
      <div className="flex flex-col gap-1">
        <span className="text-heading-sm">Aktywni użytkownicy w tygodniu według platformy</span>
        <span className="text-body-xs text-muted-foreground">Ostatnie 8 tygodni</span>
      </div>
      <ChartContainer config={config} className="aspect-auto h-72 w-full">
        <LineChart data={data} margin={{ left: 0, right: 48, top: 12 }}>
          <CartesianGrid vertical={false} />
          <XAxis
            dataKey="week"
            tickLine={false}
            axisLine={false}
            tickMargin={8}
          />
          <YAxis
            tickLine={false}
            axisLine={false}
            tickMargin={8}
            width={52}
            tickFormatter={(value: number) => number.format(value)}
          />
          <ChartTooltip
            cursor
            content={<ChartTooltipContent indicator="line" />}
          />
          <ChartLegend verticalAlign="top" content={<ChartLegendContent />} />
          {(["web", "ios", "android"] as const).map((key) => (
            <Line
              key={key}
              dataKey={key}
              type="monotone"
              stroke={`var(--color-${key})`}
              strokeWidth={2}
              strokeLinecap="round"
              dot={false}
              activeDot={{ r: 4, strokeWidth: 2, stroke: "var(--background)" }}
            >
              <LabelList dataKey={key} content={<EndLabel />} />
            </Line>
          ))}
        </LineChart>
      </ChartContainer>
    </div>
  )
}

Słupkowy

Słupki mają najwyżej 24 px grubości, wyrastają z jednej linii bazowej i są zaokrąglone tylko na końcu z danymi (4 px). Stykające się słupki rozdziela przerwa 2 px.

Rejestracje i aktywacjeKonta miesięcznie, 2026
"use client"

import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"

import {
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"

const data = [
  { month: "kwi", signups: 1840, activated: 1120 },
  { month: "maj", signups: 2130, activated: 1390 },
  { month: "cze", signups: 1960, activated: 1310 },
  { month: "lip", signups: 2480, activated: 1720 },
  { month: "sie", signups: 2710, activated: 1960 },
  { month: "wrz", signups: 3020, activated: 2290 },
]

const config = {
  signups: { label: "Rejestracje", color: "var(--chart-1)" },
  activated: { label: "Aktywacje", color: "var(--chart-2)" },
} satisfies ChartConfig

const number = new Intl.NumberFormat("pl-PL")

export default function ChartBar() {
  return (
    <div className="flex w-full max-w-2xl flex-col gap-2">
      <div className="flex flex-col gap-1">
        <span className="text-heading-sm">Rejestracje i aktywacje</span>
        <span className="text-body-xs text-muted-foreground">
          Konta miesięcznie, 2026
        </span>
      </div>
      <ChartContainer config={config} className="aspect-auto h-72 w-full">
        <BarChart
          data={data}
          barGap={2}
          barCategoryGap="28%"
          margin={{ left: 0, right: 8, top: 12 }}
        >
          <CartesianGrid vertical={false} />
          <XAxis
            dataKey="month"
            tickLine={false}
            axisLine={false}
            tickMargin={8}
          />
          <YAxis
            tickLine={false}
            axisLine={false}
            tickMargin={8}
            width={52}
            tickFormatter={(value: number) => number.format(value)}
          />
          <ChartTooltip content={<ChartTooltipContent />} />
          <ChartLegend verticalAlign="top" content={<ChartLegendContent />} />
          <Bar
            dataKey="signups"
            fill="var(--color-signups)"
            radius={[4, 4, 0, 0]}
            maxBarSize={24}
          />
          <Bar
            dataKey="activated"
            fill="var(--color-activated)"
            radius={[4, 4, 0, 0]}
            maxBarSize={24}
          />
        </BarChart>
      </ChartContainer>
    </div>
  )
}

Skumulowany

Do porównań części z całością w wielu kategoriach używaj poziomych słupków skumulowanych. Segmenty rozdziela 2-pikselowa linia w kolorze powierzchni — oddziela je przerwa, a nie obrys.

Minuty kompilacji według środowiskaWrzesień 2026, w podziale na zespoły
"use client"

import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"

import {
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"

const data = [
  { team: "Platforma", "production": 3120, "preview": 1840, "development": 620 },
  { team: "Web", "production": 2410, "preview": 2260, "development": 910 },
  { team: "Mobile", "production": 1680, "preview": 1190, "development": 1340 },
  { team: "Dane", "production": 2890, "preview": 640, "development": 480 },
  { team: "Growth", "production": 1240, "preview": 1580, "development": 390 },
]

const config = {
  "production": { label: "Produkcyjne", color: "var(--chart-1)" },
  "preview": { label: "Podglądowe", color: "var(--chart-2)" },
  "development": { label: "Deweloperskie", color: "var(--chart-3)" },
} satisfies ChartConfig

const number = new Intl.NumberFormat("pl-PL")
const keys = ["production", "preview", "development"] as const

export default function ChartStacked() {
  return (
    <div className="flex w-full max-w-2xl flex-col gap-2">
      <div className="flex flex-col gap-1">
        <span className="text-heading-sm">Minuty kompilacji według środowiska</span>
        <span className="text-body-xs text-muted-foreground">
          Wrzesień 2026, w podziale na zespoły
        </span>
      </div>
      <ChartContainer config={config} className="aspect-auto h-72 w-full">
        <BarChart
          data={data}
          layout="vertical"
          margin={{ left: 0, right: 12, top: 12 }}
        >
          <CartesianGrid horizontal={false} />
          <YAxis
            dataKey="team"
            type="category"
            tickLine={false}
            axisLine={false}
            tickMargin={8}
            width={68}
          />
          <XAxis
            type="number"
            tickLine={false}
            axisLine={false}
            tickMargin={8}
            tickFormatter={(value: number) => number.format(value)}
          />
          <ChartTooltip content={<ChartTooltipContent />} />
          <ChartLegend verticalAlign="top" content={<ChartLegendContent />} />
          {keys.map((key, index) => (
            <Bar
              key={key}
              dataKey={key}
              stackId="minutes"
              fill={`var(--color-${key})`}
              // The surface-colored stroke is the 2px gap between segments.
              stroke="var(--background)"
              strokeWidth={2}
              maxBarSize={24}
              radius={index === keys.length - 1 ? [0, 4, 4, 0] : 0}
            />
          ))}
        </BarChart>
      </ChartContainer>
    </div>
  )
}

Pierścieniowy

Wykres pierścieniowy na pierwszy rzut oka pokazuje udział części w całości — przy najwyżej sześciu segmentach. Do porównywania zbliżonych wartości użyj wykresu słupkowego — kąty trudno porównywać.

Ruch według źródła
"use client"

import { Label, Pie, PieChart } from "recharts"

import {
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"

const data = [
  { source: "organic", "visits": 18420, fill: "var(--color-organic)" },
  { source: "direct", "visits": 11260, fill: "var(--color-direct)" },
  { source: "referral", "visits": 6890, fill: "var(--color-referral)" },
  { source: "social", "visits": 4310, fill: "var(--color-social)" },
]

const config = {
  "visits": { label: "Wizyty" },
  organic: { label: "Wyszukiwanie organiczne", color: "var(--chart-1)" },
  direct: { label: "Bezpośrednie", color: "var(--chart-2)" },
  referral: { label: "Witryny odsyłające", color: "var(--chart-3)" },
  social: { label: "Media społecznościowe", color: "var(--chart-4)" },
} satisfies ChartConfig

const total = data.reduce((sum, item) => sum + item.visits, 0)
const totalLabel = "Wizyty łącznie"

export default function ChartDonut() {
  return (
    <div className="flex w-full max-w-sm flex-col items-center gap-2">
      <span className="text-heading-sm">Ruch według źródła</span>
      <ChartContainer config={config} className="aspect-square h-72 w-full">
        <PieChart>
          <ChartTooltip
            content={<ChartTooltipContent nameKey="source" hideLabel />}
          />
          <Pie
            data={data}
            dataKey="visits"
            nameKey="source"
            innerRadius="58%"
            outerRadius="82%"
            stroke="var(--background)"
            strokeWidth={2}
          >
            <Label
              content={({ viewBox }) => {
                if (!viewBox || !("cx" in viewBox)) return null
                return (
                  <text
                    x={viewBox.cx}
                    y={viewBox.cy}
                    textAnchor="middle"
                    dominantBaseline="middle"
                  >
                    <tspan
                      x={viewBox.cx}
                      y={viewBox.cy}
                      className="fill-foreground text-2xl font-semibold"
                    >
                      {total.toLocaleString("pl-PL")}
                    </tspan>
                    <tspan
                      x={viewBox.cx}
                      y={(viewBox.cy ?? 0) + 22}
                      className="fill-muted-foreground text-xs"
                    >
                      {totalLabel}
                    </tspan>
                  </text>
                )
              }}
            />
          </Pie>
          <ChartLegend
            content={
              <ChartLegendContent nameKey="source" className="flex-wrap" />
            }
          />
        </PieChart>
      </ChartContainer>
    </div>
  )
}

Statystyka z wykresem przebiegu

Często najlepszym wykresem jest po prostu liczba. Kafelek statystyki zaczyna od wartości, podaje zmianę względem nazwanego okresu i dodaje 12-punktowy wykres przebiegu (sparkline): historię w spokojnej szarości, bieżący okres w kolorze akcentu. Pokoloruj zmianę zależnie od tego, czy to dobra wiadomość, i dodaj do niej strzałkę, żeby kierunek nigdy nie zależał od koloru.

Przychody cykliczne84 320 $+8,2%wobec sierpnia
Opóźnienie p95182 ms−24 mswobec sierpnia
Odsetek błędów0,42%+0,09 p.p.wobec sierpnia
"use client"

import { ArrowDownRightIcon, ArrowUpRightIcon } from "lucide-react"
import { Line, LineChart, YAxis } from "recharts"

import { ChartContainer, type ChartConfig } from "@/components/ui/chart"
import { cn } from "@/lib/utils"

type Stat = {
  label: string
  value: string
  delta: string
  direction: "up" | "down"
  /** Whether the direction is good news — drives the delta color. */
  good: boolean
  trend: number[]
}

const stats: Stat[] = [
  {
    label: "Przychody cykliczne",
    value: "84 320 $",
    delta: "+8,2%",
    direction: "up",
    good: true,
    trend: [61, 63, 62, 66, 68, 67, 71, 73, 72, 77, 80, 84],
  },
  {
    label: "Opóźnienie p95",
    value: "182 ms",
    delta: "−24 ms",
    direction: "down",
    good: true,
    trend: [240, 236, 228, 231, 219, 214, 209, 206, 198, 195, 190, 182],
  },
  {
    label: "Odsetek błędów",
    value: "0,42%",
    delta: "+0,09 p.p.",
    direction: "up",
    good: false,
    trend: [
      0.3, 0.31, 0.29, 0.33, 0.32, 0.35, 0.34, 0.36, 0.33, 0.38, 0.4, 0.42,
    ],
  },
]

const config = {
  value: { label: "Wartość", color: "var(--gray-8)" },
} satisfies ChartConfig

function Sparkline({ trend }: { trend: number[] }) {
  const data = trend.map((value, index) => ({ index, value }))
  const last = data.length - 1
  return (
    <ChartContainer
      config={config}
      className="aspect-auto h-10 w-full"
      aria-hidden="true"
    >
      {/* Decorative: hidden from assistive technology, so it mustn't take focus either. */}
      <LineChart
        data={data}
        margin={{ top: 5, right: 5, bottom: 5, left: 5 }}
        accessibilityLayer={false}
      >
        {/* A sparkline shows shape, not magnitude: fit the range, not zero. */}
        <YAxis hide domain={["dataMin", "dataMax"]} />
        <Line
          dataKey="value"
          type="monotone"
          stroke="var(--color-value)"
          strokeWidth={2}
          strokeLinecap="round"
          isAnimationActive={false}
          dot={(props: { cx?: number; cy?: number; index?: number }) =>
            props.index === last ? (
              <circle
                key="current"
                cx={props.cx}
                cy={props.cy}
                r={4}
                fill="var(--chart-1)"
                stroke="var(--card)"
                strokeWidth={2}
              />
            ) : (
              <g key={props.index} />
            )
          }
        />
      </LineChart>
    </ChartContainer>
  )
}

export default function ChartStat() {
  return (
    <div className="grid w-full max-w-3xl gap-4 sm:grid-cols-3">
      {stats.map((stat) => {
        const Icon =
          stat.direction === "up" ? ArrowUpRightIcon : ArrowDownRightIcon
        return (
          <div
            key={stat.label}
            className="flex flex-col gap-2 rounded-xl border bg-card p-4 shadow-raised"
          >
            <span className="truncate text-body-xs text-muted-foreground">
              {stat.label}
            </span>
            {/* Proportional figures: tabular digits look loose at display size. */}
            <span className="text-heading-lg whitespace-nowrap">
              {stat.value}
            </span>
            <span
              className={cn(
                "flex items-center gap-1 text-xs font-medium whitespace-nowrap",
                stat.good ? "text-success-11" : "text-danger-11"
              )}
            >
              <Icon className="size-3.5" aria-hidden="true" />
              {stat.delta}
              <span className="font-normal text-muted-foreground">
                wobec sierpnia
              </span>
            </span>
            <Sparkline trend={stat.trend} />
          </div>
        )
      })}
    </div>
  )
}

Wyróżnienie

Gdy chodzi o jedną serię, powiedz to wprost: pokaż ją w kolorze akcentu, a całą resztę na szaro. To najbardziej niedoceniana forma wykresu i często uczciwa odpowiedź na prośbę „zrób ten wykres czytelniejszym”.

W Sydney czeka się 5,6× dłużej niż we FrankfurcieMediana opóźnienia z Krakowa, w milisekundach
"use client"

import { Bar, BarChart, Cell, LabelList, XAxis } from "recharts"

import { ChartContainer, type ChartConfig } from "@/components/ui/chart"

const data = [
  { region: "Frankfurt", ms: 38 },
  { region: "Londyn", ms: 41 },
  { region: "Wirginia", ms: 92 },
  { region: "São Paulo", ms: 141 },
  { region: "Singapur", ms: 188 },
  { region: "Sydney", ms: 214 },
]

const highlight = "Sydney"

const config = {
  ms: { label: "Mediana opóźnienia (ms)", color: "var(--gray-8)" },
} satisfies ChartConfig

export default function ChartEmphasis() {
  return (
    <div className="flex w-full max-w-2xl flex-col gap-2">
      <div className="flex flex-col gap-1">
        <span className="text-heading-sm">
          W Sydney czeka się 5,6× dłużej niż we Frankfurcie
        </span>
        <span className="text-body-xs text-muted-foreground">
          Mediana opóźnienia z Krakowa, w milisekundach
        </span>
      </div>
      <ChartContainer config={config} className="aspect-auto h-64 w-full">
        <BarChart data={data} margin={{ top: 24, left: 8, right: 8 }}>
          <XAxis
            dataKey="region"
            tickLine={false}
            axisLine={false}
            tickMargin={8}
          />
          <Bar
            dataKey="ms"
            radius={[4, 4, 0, 0]}
            maxBarSize={24}
            isAnimationActive={false}
          >
            {data.map((item) => (
              <Cell
                key={item.region}
                fill={
                  item.region === highlight
                    ? "var(--chart-1)"
                    : "var(--color-ms)"
                }
              />
            ))}
            <LabelList
              dataKey="ms"
              position="top"
              offset={8}
              className="fill-muted-foreground text-xs"
            />
          </Bar>
        </BarChart>
      </ChartContainer>
    </div>
  )
}

Z widokiem tabeli

Każdy wykres powinien mieć bliźniaczą tabelę. To najbardziej dostępna postać danych, to z niej kopiuje się liczby, a wartości można w niej odczytać bez najeżdżania kursorem.

Zgłoszenia do pomocy technicznej na kwartał
"use client"

import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts"

import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/chart"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"

const data = [
  { quarter: "Q4 2025", tickets: 1284 },
  { quarter: "Q1 2026", tickets: 1172 },
  { quarter: "Q2 2026", tickets: 968 },
  { quarter: "Q3 2026", tickets: 811 },
]

const config = {
  tickets: { label: "Zgłoszenia do pomocy technicznej", color: "var(--chart-1)" },
} satisfies ChartConfig

const number = new Intl.NumberFormat("pl-PL")

export default function ChartWithTable() {
  return (
    <Tabs defaultValue="chart" className="w-full max-w-xl">
      <div className="flex items-center justify-between gap-4">
        <span className="text-heading-sm">Zgłoszenia do pomocy technicznej na kwartał</span>
        <TabsList>
          <TabsTrigger value="chart">Wykres</TabsTrigger>
          <TabsTrigger value="table">Tabela</TabsTrigger>
        </TabsList>
      </div>
      <TabsContent value="chart">
        <ChartContainer config={config} className="aspect-auto h-56 w-full">
          <BarChart data={data} margin={{ left: 0, right: 8, top: 8 }}>
            <CartesianGrid vertical={false} />
            <XAxis
              dataKey="quarter"
              tickLine={false}
              axisLine={false}
              tickMargin={8}
            />
            <YAxis
              tickLine={false}
              axisLine={false}
              tickMargin={8}
              width={52}
              tickFormatter={(value: number) => number.format(value)}
            />
            <ChartTooltip content={<ChartTooltipContent hideIndicator />} />
            <Bar
              dataKey="tickets"
              fill="var(--color-tickets)"
              radius={[4, 4, 0, 0]}
              maxBarSize={24}
            />
          </BarChart>
        </ChartContainer>
      </TabsContent>
      <TabsContent value="table">
        <div className="rounded-xl border">
          <Table>
            <TableHeader>
              <TableRow>
                <TableHead scope="col">Kwartał</TableHead>
                <TableHead scope="col" className="text-right">
                  Zgłoszenia
                </TableHead>
              </TableRow>
            </TableHeader>
            <TableBody>
              {data.map((row) => (
                <TableRow key={row.quarter}>
                  <TableCell>{row.quarter}</TableCell>
                  <TableCell className="text-right">
                    {number.format(row.tickets)}
                  </TableCell>
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </div>
      </TabsContent>
    </Tabs>
  )
}

Kolor

O kolorze decydujesz na końcu: najpierw wybierz formę, a potem daj każdemu kolorowi dokładnie jedno zadanie.

KolorKodujeW prfct
KategorialnyKtóra to seriachart-1 … chart-5, zawsze przypisywane po kolei
SekwencyjnyIleJeden odcień, jasny → ciemny: stopnie marki 4 → 11
DywergentnyPowyżej czy poniżej wartości bazowejSkale niebieska i czerwona z szarym środkiem
StatusowyDobrze czy źlesuccess, warning, destructive — tylko gdy seria oznacza coś dobrego albo złego
WyróżniającyTo, co najważniejszechart-1 dla głównej serii, gray-8 dla kontekstu

Paleta kategorialna

chart-1
chart-2
chart-3
chart-4
chart-5

Kolejność — kolor marki, morski, fiolet, pomarańcz, róż — nie wynika z estetyki. Wybrano ją, sprawdzając wszystkie możliwe kolejności i zostawiając tę, w której różnica kolorów między sąsiadami jest największa przy symulowanej protanopii i deuteranopii. Cztery barwy mieszczą się w czytelnym zakresie jasności i przekraczają minimalne nasycenie; pierwsza seria ma kolor marki, domyślnie czerń lub biel, i odróżnia się od nich jasnością. Każda seria zachowuje kontrast co najmniej 3:1 z powierzchnią wykresu, w obu trybach. Wartości z tabeli przelicza z tokenów test tests/tokens.test.ts:

KryteriumJasnyCiemny
Najgorsza sąsiednia para, zaburzone widzenie barw (ΔE OKLab)17,616,0
Najgorsza sąsiednia para, prawidłowe widzenie barw (ΔE OKLab)18,716,9
Kontrast z powierzchnią≥ 3:1≥ 3:1

Wynikają z tego zasady:

  • Przypisuj po kolei, bez pomijania i bez zapętlania. Pierwsza seria to zawsze chart-1. Szósta seria trafia do kategorii Inne, na osobny mały wykres (small multiples) albo do tabeli — nigdy nie dostaje wygenerowanego szóstego odcienia.
  • Kolor należy do obiektu, a nie do jego pozycji. Gdy filtr usuwa serię, pozostałe zachowują swoje kolory.
  • Wykresy punktowe i mapy mają najwyżej trzy serie — tam dowolne dwa kolory mogą znaleźć się obok siebie.
  • Kolory statusów są zarezerwowane. Zieleń i czerwień oznaczają w całym prfct „dobrze” i „źle”; nigdy nie używaj ich dla „serii 4”.

Wytyczne

Najpierw wybierz formę

Co pokazujeszWybierz
Jedną bieżącą wartość, może z trendemKafelek statystyki — nie wykres z jednym słupkiem
Zmianę w czasieWykres liniowy; warstwowy dla jednej serii
Porównanie wielkościWykres słupkowy lub kolumnowy
Udział części w całościSkumulowany wykres słupkowy; pierścieniowy dla najwyżej sześciu części
Jedną ważną serię na tle pozostałychWyróżnienie
Ponad siedem kategorii, z których każda jest ważnaTabelę

Stonowana oprawa

Tylko dane mają prawo być głośne. Linie siatki są ciągłe i cienkie, o jeden stopień skali różne od powierzchni — nigdy przerywane. Etykiety osi używają muted-foreground. Usuń linie osi i kreski podziałki, a wartości na osiach niech będą okrągłymi liczbami z separatorem tysięcy.

DobrzeCienka siatka, wyciszone etykiety, smukłe znaczniki. Cały tusz należy do danych.
ŹleOsobny kolor dla każdego słupka marnuje kanał identyfikacji — wartość i tak pokazuje długość słupka.

Jedna oś

Nigdy nie pokazuj dwóch miar na dwóch osiach Y: wzajemne dopasowanie skal jest arbitralne, więc wykres zmyśla korelacje. Użyj dwóch wykresów, małych wykresów obok siebie albo przelicz obie serie na indeks równy 100 w punkcie startowym.

Etykiety i legendy

  • Przy dwóch lub więcej seriach legenda jest zawsze widoczna; pojedynczą serię nazywa tytuł.
  • Podpisuj wybiórczo — ostatnią wartość, wartość skrajną, tę jedną serię, o której opowiada wykres. Na wykresach liniowych i gęstych nigdy nie umieszczaj liczby przy każdym punkcie; kilka słupków bez osi wartości może mieć podpisaną każdą wartość, jak w przykładzie Wyróżnienie.
  • Tekst używa tokenów tekstu. Wartości, etykiety i legendy pozostają w foreground lub muted-foreground; o tożsamości serii mówi kolorowy znacznik obok nich.

Dostępność

  • Klawiatura. Warstwa dostępności Recharts jest domyślnie włączona: przejdź na wykres klawiszem Tab, co rysuje wokół niego pierścień fokusu, i poruszaj się między punktami danych strzałkami; podpowiedź podąża za fokusem dokładnie tak jak za kursorem.
  • Alternatywa tekstowa. Dołącz do każdego wykresu widok tabeli albo opisz główny wniosek w tytule i podpisie — „Liczba rejestracji wzrosła o 64% od kwietnia” mówi więcej niż „Rejestracje według miesięcy”.
  • Nigdy sam kolor. Serie identyfikują legendy i bezpośrednie etykiety; zmiany statusu łączą kolor z ikoną i słowami.
  • Kontrast. Każdy kolor kategorialny zachowuje w obu trybach kontrast co najmniej 3:1 z powierzchnią wykresu.
  • Ruch. Animacje Recharts mają domyślnie wartość "auto", która respektuje prefers-reduced-motion. Nie zmieniaj tego.
KlawiszDziałanie
Tab
Przenosi fokus na wykres.
←→
Przechodzi między punktami danych i aktualizuje podpowiedź.

Dokumentacja API

ChartContainer

Opakowuje wykres Recharts, ogranicza zasięg jego kolorów i sprawia, że jest responsywny. Przyjmuje wszystkie propsy div.

PropTypDomyślnie
config

Etykiety, ikony i kolory dla każdego klucza serii. Każdy kolor staje się wewnątrz wykresu zmienną --color-key.

ChartConfigBrak wartości domyślnej
initialDimension

Rozmiar używany, zanim kontener zostanie zmierzony, np. podczas renderowania na serwerze.

{ width: number; height: number }{ width: 320, height: 200 }
className

Tu ustaw wysokość, np. aspect-auto h-64. Domyślnie obowiązują proporcje 16:9.

stringBrak wartości domyślnej

ChartConfig

type ChartConfig = Record<
  string,
  {
    label?: React.ReactNode
    icon?: React.ComponentType
  } & (
    | { color?: string } // one color for both modes
    | { theme: { light: string; dark: string } } // a color per mode
  )
>

ChartTooltipContent

Używaj w ten sposób: <ChartTooltip content={<ChartTooltipContent />} />.

PropTypDomyślnie
indicator

Styl znacznika serii. Na wykresach liniowych i warstwowych wybieraj line.

"dot" | "line" | "dashed""dot"
hideLabel

Ukrywa etykietę kategorii u góry podpowiedzi.

booleanfalse
hideIndicator

Ukrywa znacznik serii, np. na wykresach z jedną serią.

booleanfalse
nameKey

Klucz z payloadu, według którego etykieta serii jest wyszukiwana w config.

stringBrak wartości domyślnej
labelKey

Klucz z payloadu używany jako etykieta podpowiedzi.

stringBrak wartości domyślnej
formatter

Zastępuje domyślne renderowanie wiersza.

(value, name, item, index, payload) => ReactNodeBrak wartości domyślnej
labelFormatter

Formatuje etykietę u góry podpowiedzi.

(label, payload) => ReactNodeBrak wartości domyślnej
labelClassName

Klasy tej etykiety.

stringBrak wartości domyślnej
color

Jeden kolor dla znaczników wszystkich serii zamiast własnego koloru każdej z nich.

stringBrak wartości domyślnej

ChartLegendContent

Używaj w ten sposób: <ChartLegend content={<ChartLegendContent />} />. ChartLegend wymienia serie w kolejności kluczy w config, czyli w tej samej, w której przypisujesz kolory; samo Recharts posortowałoby je alfabetycznie. Inną kolejność ustawisz propem itemSorter.

PropTypDomyślnie
nameKey

Klucz z payloadu, według którego wyszukiwane są etykiety, np. dla wycinków wykresu kołowego.

stringBrak wartości domyślnej
hideIcon

Ukrywa ikony z config i zamiast nich pokazuje znaczniki kolorów.

booleanfalse
verticalAlign

Dodaje odstęp po stronie zwróconej do wykresu.

"top" | "bottom""bottom"

ChartStyle

Wstrzykiwany przez ChartContainer; eksportowany na potrzeby własnych kontenerów, które potrzebują tych samych zmiennych kolorów o ograniczonym zasięgu.