Aura Design System

Pie Chart

Pie chart family — pie, joined slices, labels, legend, donut, active slice, center total, and stacked rings.

Preview

Loading...
import { PieChart } from "@/components/ui/PieChart";

export function PieChartDemo() {
  return (
  <div className="flex w-full flex-col gap-2">
    {variants.map((variant) => (
      <PieChart key={variant} variant={variant} />
    ))}
  </div>
)

Installation

Make sure that namespace is set in your component.json file. Namespace docs: Learn more about namespaces

pnpm dlx shadcn@latest add @aura/pie-chart

Manual

Install the following dependencies:

pnpm install @radix-ui/react-icons recharts@2.15.4

Copy and paste the card component into your components/ui/Card.tsx file.

/**
 * @description Displays a card with header, content, and footer sections.
 */
import * as React from "react";

import { cn } from "@/utils/class-names";

function Card({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card"
      className={cn(
        "bg-gray-2 border border-gray-6 rounded-md overflow-hidden",
        className
      )}
      {...props}
    />
  );
}

function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-header"
      className={cn("flex flex-col gap-0.5 p-1 pb-0", className)}
      {...props}
    />
  );
}

function CardTitle({ className, ...props }: React.ComponentProps<"h3">) {
  return (
    <h3
      data-slot="card-title"
      className={cn("text-gray-12 font-semibold leading-none", className)}
      {...props}
    />
  );
}

function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
  return (
    <p
      data-slot="card-description"
      className={cn("text-gray-11", className)}
      {...props}
    />
  );
}

function CardAction({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-action"
      className={cn("flex items-center gap-0.5", className)}
      {...props}
    />
  );
}

function CardContent({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div data-slot="card-content" className={cn("p-1 pt-0", className)} {...props} />
  );
}

function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
  return (
    <div
      data-slot="card-footer"
      className={cn(
        "flex items-center gap-0.5 p-1 border-t border-gray-6",
        className
      )}
      {...props}
    />
  );
}

export {
  Card,
  CardHeader,
  CardFooter,
  CardTitle,
  CardAction,
  CardDescription,
  CardContent,
};

Copy and paste the chart component into your components/ui/Chart.tsx file.

"use client";
/**
 * @description Chart container, tooltip, legend, and series wrappers for Recharts, colored with Aura series tokens.
 */
import * as React from "react";
import {
  Area,
  Bar,
  Legend,
  Line,
  Pie,
  Radar,
  RadialBar,
  ResponsiveContainer,
  Tooltip,
} from "recharts";

import { cn } from "@/utils/class-names";

const THEMES = { light: "", dark: ".dark" } as const;

type SeriesColor = {
  color?: string;
  theme?: never;
} | {
  color?: never;
  theme: Partial<Record<keyof typeof THEMES, string>>;
};

export type ChartConfig = {
  [key: string]: {
    label?: React.ReactNode;
    icon?: React.ComponentType<{ className?: string }>;
  } & SeriesColor;
};

type ChartTooltipItem = {
  type?: string;
  dataKey?: string | number;
  name?: string;
  value?: number | string;
  color?: string;
  payload?: Record<string, unknown> & { fill?: string };
  fill?: string;
};

type ChartContextValue = {
  config: ChartConfig;
  reducedMotion: boolean;
};

const ChartContext = React.createContext<ChartContextValue | null>(null);

function usePrefersReducedMotion() {
  return React.useSyncExternalStore(
    (onStoreChange) => {
      const media = window.matchMedia("(prefers-reduced-motion: reduce)");
      media.addEventListener("change", onStoreChange);
      return () => media.removeEventListener("change", onStoreChange);
    },
    () => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
    () => false
  );
}

function useChart() {
  const context = React.useContext(ChartContext);
  if (!context) {
    throw new Error("useChart must be used within a <ChartContainer />");
  }
  return context;
}

function tokenColor(color: string | undefined) {
  if (!color) return undefined;
  const value = color.trim();
  if (/^var\(--[a-zA-Z0-9-_]+\)$/.test(value)) return value;
  return undefined;
}

function ChartContainer({
  id,
  className,
  children,
  config,
  label,
  ...props
}: React.ComponentProps<"div"> & {
  config: ChartConfig;
  children: React.ComponentProps<typeof ResponsiveContainer>["children"];
  label?: string;
}) {
  const uniqueId = React.useId();
  const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
  const reducedMotion = usePrefersReducedMotion();

  return (
    <ChartContext.Provider value={{ config, reducedMotion }}>
      <div
        data-slot="chart"
        data-chart={chartId}
        className={cn(
          "flex aspect-video w-full justify-center text-xs text-gray-11",
          className
        )}
        aria-label={label}
        {...props}
      >
        <ChartStyle id={chartId} config={config} />
        <ResponsiveContainer minWidth={0}>{children}</ResponsiveContainer>
      </div>
    </ChartContext.Provider>
  );
}

function ChartStyle({ id, config }: { id: string; config: ChartConfig }) {
  const colorConfig = Object.entries(config).filter(
    ([, item]) => item.theme || item.color
  );

  if (!colorConfig.length) return null;

  const css = Object.entries(THEMES)
    .map(([theme, prefix]) => {
      const lines = colorConfig
        .map(([key, item]) => {
          const color =
            tokenColor(
              item.theme?.[theme as keyof typeof item.theme] || item.color
            ) ?? undefined;
          return color ? `  --color-${key}: ${color};` : null;
        })
        .filter(Boolean)
        .join("\n");
      if (!lines) return "";
      return `${prefix} [data-chart=${id}] {\n${lines}\n}`;
    })
    .filter(Boolean)
    .join("\n");

  if (!css) return null;

  return <style dangerouslySetInnerHTML={{ __html: css }} />;
}

const ChartTooltip = Tooltip;

function formatChartValue(value: number | string | undefined) {
  if (value == null || value === "") return null;
  const numeric = typeof value === "number" ? value : Number(value);
  if (Number.isFinite(numeric)) return numeric.toLocaleString();
  return String(value);
}

function ChartTooltipContent({
  active,
  payload,
  className,
  indicator = "dot",
  hideLabel = false,
  hideIndicator = false,
  label,
  labelFormatter,
  labelClassName,
  formatter,
  color,
  nameKey,
  labelKey,
}: {
  active?: boolean;
  payload?: ChartTooltipItem[];
  className?: string;
  indicator?: "line" | "dot" | "dashed";
  hideLabel?: boolean;
  hideIndicator?: boolean;
  label?: React.ReactNode;
  labelFormatter?: (
    value: React.ReactNode,
    payload: ChartTooltipItem[]
  ) => React.ReactNode;
  labelClassName?: string;
  formatter?: (
    value: number | string,
    name: string,
    item: ChartTooltipItem,
    index: number,
    payload: Record<string, unknown>
  ) => React.ReactNode;
  color?: string;
  nameKey?: string;
  labelKey?: string;
}) {
  const { config } = useChart();

  const tooltipLabel = React.useMemo(() => {
    if (hideLabel || !payload?.length) return null;

    const [item] = payload;
    const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
    const itemConfig = getPayloadConfigFromPayload(config, item, key);
    const value =
      !labelKey && typeof label === "string"
        ? config[label]?.label || label
        : itemConfig?.label;

    if (labelFormatter) {
      return (
        <div className={cn("font-medium text-gray-12", labelClassName)}>
          {labelFormatter(value, payload)}
        </div>
      );
    }

    if (!value) return null;

    return <div className={cn("font-medium text-gray-12", labelClassName)}>{value}</div>;
  }, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey]);

  if (!active || !payload?.length) return null;

  const nestLabel = payload.length === 1 && indicator !== "dot";

  return (
    <div
      className={cn(
        "grid min-w-10 items-start gap-0.5 rounded-sm border border-gray-6 bg-gray-1 px-1 py-0.5 text-xs text-gray-12",
        className
      )}
    >
      {!nestLabel ? tooltipLabel : null}
      <div className="grid gap-0.5">
        {payload
          .filter((item) => item.type !== "none")
          .map((item, index) => {
            const key = `${nameKey || item.name || item.dataKey || "value"}`;
            const itemConfig = getPayloadConfigFromPayload(config, item, key);
            const indicatorColor = color || item.payload?.fill || item.color;
            const Icon = itemConfig?.icon;
            const formatted = formatChartValue(item.value);

            return (
              <div
                key={String(item.dataKey)}
                className={cn(
                  "flex w-full items-center gap-0.5",
                  indicator === "dot" && "items-center"
                )}
              >
                {formatter && item.value != null && item.name ? (
                  formatter(
                    item.value,
                    item.name,
                    item,
                    index,
                    item.payload ?? {}
                  )
                ) : (
                  <>
                    {Icon ? (
                      <Icon className="icon" />
                    ) : (
                      !hideIndicator && (
                        <div
                          className={cn(
                            "shrink-0 rounded-sm",
                            indicator === "dot" && "size-0.5",
                            indicator === "line" && "h-1 w-px",
                            indicator === "dashed" &&
                              "h-1 w-0 border border-dashed bg-transparent"
                          )}
                          style={
                            {
                              backgroundColor:
                                indicator === "dashed" ? "transparent" : indicatorColor,
                              borderColor: indicatorColor,
                            } as React.CSSProperties
                          }
                        />
                      )
                    )}
                    <div
                      className={cn(
                        "flex flex-1 justify-between gap-1 leading-none",
                        nestLabel ? "items-end" : "items-center"
                      )}
                    >
                      <div className="grid gap-0.5">
                        {nestLabel ? tooltipLabel : null}
                        <span className="text-gray-11">
                          {itemConfig?.label || item.name}
                        </span>
                      </div>
                      {formatted ? (
                        <span className="font-mono font-medium tabular-nums text-gray-12">
                          {formatted}
                        </span>
                      ) : null}
                    </div>
                  </>
                )}
              </div>
            );
          })}
      </div>
    </div>
  );
}

const ChartLegend = Legend;

function ChartLegendContent({
  className,
  hideIcon = false,
  payload,
  verticalAlign = "bottom",
  nameKey,
}: React.ComponentProps<"div"> & {
  hideIcon?: boolean;
  payload?: Array<{
    type?: string;
    dataKey?: string | number;
    value?: string;
    color?: string;
  }>;
  verticalAlign?: "top" | "bottom" | "middle";
  nameKey?: string;
}) {
  const { config } = useChart();

  if (!payload?.length) return null;

  return (
    <div
      className={cn(
        "flex items-center justify-center gap-1 text-xs text-gray-12",
        verticalAlign === "top" ? "pb-1" : "pt-1",
        className
      )}
    >
      {payload
        .filter((item) => item.type !== "none")
        .map((item) => {
          const key = `${nameKey || item.dataKey || "value"}`;
          const itemConfig = getPayloadConfigFromPayload(config, item, key);
          const Icon = itemConfig?.icon;
          const swatch =
            item.color && !String(item.color).startsWith("url")
              ? item.color
              : `var(--color-${key})`;

          return (
            <div key={String(item.value)} className="flex items-center gap-0.5">
              {Icon && !hideIcon ? (
                <Icon className="icon" />
              ) : (
                <div
                  className="size-0.5 shrink-0 rounded-sm"
                  style={{ backgroundColor: swatch }}
                />
              )}
              {itemConfig?.label}
            </div>
          );
        })}
    </div>
  );
}

type SeriesSource = {
  displayName?: string;
  defaultProps?: object;
  getComposedData?: (...args: never[]) => unknown;
};

function useChartMotion(isAnimationActive?: boolean, animationDuration = 250) {
  const { reducedMotion } = useChart();

  return {
    animationDuration,
    animationEasing: "ease-out" as const,
    isAnimationActive: isAnimationActive ?? !reducedMotion,
  };
}

function bindRechartsSeries(
  component: object,
  source: SeriesSource,
  displayName: string,
) {
  const bound: Record<string, unknown> = { displayName };
  if (source.defaultProps) bound.defaultProps = source.defaultProps;
  if (typeof source.getComposedData === "function") {
    bound.getComposedData = source.getComposedData;
  }
  Object.assign(component, bound);
}

function ChartArea({
  isAnimationActive,
  animationDuration = 250,
  ...props
}: Omit<React.ComponentProps<typeof Area>, "ref">) {
  return <Area {...props} {...useChartMotion(isAnimationActive, animationDuration)} />;
}

function ChartBar({
  isAnimationActive,
  animationDuration = 250,
  ...props
}: Omit<React.ComponentProps<typeof Bar>, "ref">) {
  return <Bar {...props} {...useChartMotion(isAnimationActive, animationDuration)} />;
}

function ChartLine({
  isAnimationActive,
  animationDuration = 250,
  ...props
}: Omit<React.ComponentProps<typeof Line>, "ref">) {
  return <Line {...props} {...useChartMotion(isAnimationActive, animationDuration)} />;
}

function ChartPie({
  isAnimationActive,
  animationDuration = 250,
  ...props
}: Omit<React.ComponentProps<typeof Pie>, "ref">) {
  return <Pie {...props} {...useChartMotion(isAnimationActive, animationDuration)} />;
}

function ChartRadar({
  isAnimationActive,
  animationDuration = 250,
  ...props
}: Omit<React.ComponentProps<typeof Radar>, "ref">) {
  return <Radar {...props} {...useChartMotion(isAnimationActive, animationDuration)} />;
}

function ChartRadialBar({
  isAnimationActive,
  animationDuration = 250,
  ...props
}: Omit<React.ComponentProps<typeof RadialBar>, "ref">) {
  return (
    <RadialBar {...props} {...useChartMotion(isAnimationActive, animationDuration)} />
  );
}

// Recharts identifies a series by displayName, reads axis ids from defaultProps,
// and skips a composed child that does not implement getComposedData.
bindRechartsSeries(ChartArea, Area, "Area");
bindRechartsSeries(ChartBar, Bar, "Bar");
bindRechartsSeries(ChartLine, Line, "Line");
bindRechartsSeries(ChartPie, Pie, "Pie");
bindRechartsSeries(ChartRadar, Radar, "Radar");
bindRechartsSeries(ChartRadialBar, RadialBar, "RadialBar");

function getPayloadConfigFromPayload(
  config: ChartConfig,
  payload: unknown,
  key: string
) {
  if (typeof payload !== "object" || payload === null) return undefined;

  const payloadPayload =
    "payload" in payload &&
    typeof payload.payload === "object" &&
    payload.payload !== null
      ? payload.payload
      : undefined;

  let configLabelKey = key;

  if (key in payload && typeof payload[key as keyof typeof payload] === "string") {
    configLabelKey = payload[key as keyof typeof payload] as string;
  } else if (
    payloadPayload &&
    key in payloadPayload &&
    typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
  ) {
    configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
  }

  return configLabelKey in config ? config[configLabelKey] : config[key];
}

export {
  ChartArea,
  ChartBar,
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartLine,
  ChartPie,
  ChartRadar,
  ChartRadialBar,
  ChartStyle,
  ChartTooltip,
  ChartTooltipContent,
  useChart,
};

Copy and paste the class names utility into your utils/class-names.ts file.

import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

Copy and paste the PieChart component into your components/ui/PieChart.tsx file.

"use client";
/**
 * @description Pie chart family: pie, joined slices, labels, legend, donut, active slice, center total, and stacked rings.
 */
import { ArrowUpIcon } from "@radix-ui/react-icons";
import { PieChart as RechartsPieChart, Sector } from "recharts";

import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/Card";
import {
  ChartContainer,
  ChartLegend,
  ChartLegendContent,
  ChartPie,
  ChartTooltip,
  ChartTooltipContent,
  type ChartConfig,
} from "@/components/ui/Chart";
import { cn } from "@/utils/class-names";

export const pieChartVariants = [
  "default",
  "joined",
  "label",
  "legend",
  "donut",
  "active",
  "text",
  "stacked",
] as const;

export type PieChartVariant = (typeof pieChartVariants)[number];

const browserShare = [
  { browser: "chrome", visitors: 275, fill: "var(--color-chrome)" },
  { browser: "safari", visitors: 200, fill: "var(--color-safari)" },
  { browser: "firefox", visitors: 187, fill: "var(--color-firefox)" },
  { browser: "edge", visitors: 173, fill: "var(--color-edge)" },
  { browser: "other", visitors: 90, fill: "var(--color-other)" },
];

const mobileShare = [
  { browser: "chrome", visitors: 180, fill: "var(--color-chrome)" },
  { browser: "safari", visitors: 160, fill: "var(--color-safari)" },
  { browser: "firefox", visitors: 70, fill: "var(--color-firefox)" },
  { browser: "edge", visitors: 40, fill: "var(--color-edge)" },
  { browser: "other", visitors: 30, fill: "var(--color-other)" },
];

const titles: Record<PieChartVariant, string> = {
  default: "Pie chart",
  joined: "Pie chart — joined",
  label: "Pie chart — label",
  legend: "Pie chart — legend",
  donut: "Pie chart — donut",
  active: "Pie chart — active",
  text: "Pie chart — text",
  stacked: "Pie chart — stacked",
};

const chartConfig = {
  visitors: { label: "Visitors" },
  chrome: { label: "Chrome", color: "var(--chart-1)" },
  safari: { label: "Safari", color: "var(--chart-2)" },
  firefox: { label: "Firefox", color: "var(--chart-3)" },
  edge: { label: "Edge", color: "var(--chart-4)" },
  other: { label: "Other", color: "var(--chart-5)" },
} satisfies ChartConfig;

const totalVisitors = browserShare.reduce((sum, item) => sum + item.visitors, 0);

function ActiveSlice(props: { outerRadius?: number }) {
  return <Sector {...props} outerRadius={(props.outerRadius ?? 0) + 8} />;
}

function PieChart({
  variant = "default",
  className,
}: {
  variant?: PieChartVariant;
  className?: string;
}) {
  const title = titles[variant];
  const donut = variant === "donut" || variant === "active" || variant === "text";
  const showCenter = variant === "text";

  return (
    <Card className={cn("w-full", className)}>
      <CardHeader>
        <CardTitle>{title}</CardTitle>
        <CardDescription>
          {variant === "stacked"
            ? "Desktop ring outside, mobile ring inside"
            : variant === "active"
              ? "Chrome is pulled out from the donut"
              : "Browser share for the last 6 months"}
        </CardDescription>
      </CardHeader>
      <CardContent>
        <div className="relative">
          <ChartContainer
            config={chartConfig}
            label={title}
            className="mx-auto aspect-square h-20 max-w-20"
          >
            <RechartsPieChart>
              <ChartTooltip
                content={<ChartTooltipContent nameKey="browser" hideLabel />}
              />
              {variant === "stacked" ? (
                <>
                  <ChartPie
                    data={browserShare}
                    dataKey="visitors"
                    nameKey="browser"
                    innerRadius="62%"
                    outerRadius="80%"
                    stroke="var(--gray-1)"
                    strokeWidth={2}
                  />
                  <ChartPie
                    data={mobileShare}
                    dataKey="visitors"
                    nameKey="browser"
                    innerRadius="38%"
                    outerRadius="56%"
                    stroke="var(--gray-1)"
                    strokeWidth={2}
                  />
                </>
              ) : (
                <ChartPie
                  data={browserShare}
                  dataKey="visitors"
                  nameKey="browser"
                  innerRadius={donut ? "55%" : 0}
                  outerRadius="80%"
                  stroke={variant === "joined" ? "transparent" : "var(--gray-1)"}
                  strokeWidth={variant === "joined" ? 0 : 2}
                  label={variant === "label"}
                  labelLine={variant === "label"}
                  activeIndex={variant === "active" ? 0 : undefined}
                  activeShape={variant === "active" ? ActiveSlice : undefined}
                />
              )}
              {variant === "legend" ? (
                <ChartLegend content={<ChartLegendContent nameKey="browser" />} />
              ) : null}
            </RechartsPieChart>
          </ChartContainer>
          {showCenter ? (
            <div className="pointer-events-none absolute inset-0 flex flex-col items-center justify-center">
              <span className="h5 text-gray-12">{totalVisitors.toLocaleString()}</span>
              <span className="text-sm text-gray-11">Visitors</span>
            </div>
          ) : null}
        </div>
      </CardContent>
      <CardFooter>
        <div className="grid gap-0.5">
          <div className="flex items-center gap-0.5 text-sm font-medium text-gray-12">
            Trending up by 5.2% this month
            <ArrowUpIcon className="icon" />
          </div>
          <p className="text-sm text-gray-11">January – June 2024</p>
        </div>
      </CardFooter>
    </Card>
  );
}

export { PieChart };

Usage

Pie

export const Pie = () => <PieChart variant="default" />;

Joined

export const Joined = () => <PieChart variant="joined" />;

Label

export const Label = () => <PieChart variant="label" />;

Legend

export const Legend = () => <PieChart variant="legend" />;

Donut

export const Donut = () => <PieChart variant="donut" />;

Active

export const Active = () => <PieChart variant="active" />;

Text

export const Text = () => <PieChart variant="text" />;

Stacked

export const Stacked = () => <PieChart variant="stacked" />;