Aura Design System

Bar Chart

Bar chart family — vertical, horizontal, multiple, stacked, labels, mixed, active, and negative.

Preview

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

export function BarChartDemo() {
  return (
  <div className="flex w-full flex-col gap-2">
    {variants.map((variant) => (
      <BarChart 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/bar-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 BarChart component into your components/ui/BarChart.tsx file.

"use client";
/**
 * @description Bar chart family: vertical, horizontal, multiple, stacked, labels, mixed, active, and negative.
 */
import { ArrowUpIcon } from "@radix-ui/react-icons";
import {
  BarChart as RechartsBarChart,
  CartesianGrid,
  Cell,
  ComposedChart,
  LabelList,
  XAxis,
  YAxis,
} from "recharts";

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

export const barChartVariants = [
  "default",
  "horizontal",
  "multiple",
  "stacked",
  "label",
  "custom",
  "mixed",
  "active",
  "negative",
] as const;

export type BarChartVariant = (typeof barChartVariants)[number];

const monthVisitors = [
  { month: "January", desktop: 186, mobile: 80 },
  { month: "February", desktop: 305, mobile: 200 },
  { month: "March", desktop: 237, mobile: 120 },
  { month: "April", desktop: 73, mobile: 190 },
  { month: "May", desktop: 209, mobile: 130 },
  { month: "June", desktop: 214, mobile: 140 },
];

const negativeVisitors = [
  { month: "January", visitors: 186 },
  { month: "February", visitors: 305 },
  { month: "March", visitors: -237 },
  { month: "April", visitors: 73 },
  { month: "May", visitors: -209 },
  { month: "June", visitors: 214 },
];

const axisTick = { fontSize: 13, fill: "var(--gray-11)" };

const titles: Record<BarChartVariant, string> = {
  default: "Bar chart",
  horizontal: "Bar chart — horizontal",
  multiple: "Bar chart — multiple",
  stacked: "Bar chart — stacked",
  label: "Bar chart — label",
  custom: "Bar chart — custom label",
  mixed: "Bar chart — mixed",
  active: "Bar chart — active",
  negative: "Bar chart — negative",
};

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

function MonthLabel({
  x = 0,
  y = 0,
  width = 0,
  value,
}: {
  x?: string | number;
  y?: string | number;
  width?: string | number;
  value?: string | number;
}) {
  const label = String(value ?? "").slice(0, 3);
  return (
    <text
      x={Number(x) + Number(width) / 2}
      y={Number(y) - 6.5}
      fill="var(--gray-11)"
      fontSize={13}
      textAnchor="middle"
    >
      {label}
    </text>
  );
}

function BarChart({
  variant = "default",
  className,
}: {
  variant?: BarChartVariant;
  className?: string;
}) {
  const title = titles[variant];
  const horizontal = variant === "horizontal";
  const negative = variant === "negative";
  const multi = variant === "multiple" || variant === "stacked" || variant === "mixed";
  const stacked = variant === "stacked";
  const data = negative ? negativeVisitors : monthVisitors;
  const highlight = monthVisitors.length - 1;
  const showLegend = variant === "multiple" || variant === "stacked" || variant === "mixed";

  const bars = negative ? (
    <ChartBar dataKey="visitors" radius={4}>
      {negativeVisitors.map((item) => (
        <Cell
          key={item.month}
          fill={item.visitors < 0 ? "var(--chart-5)" : "var(--color-visitors)"}
        />
      ))}
    </ChartBar>
  ) : variant === "active" ? (
    <ChartBar dataKey="desktop" radius={4}>
      {monthVisitors.map((item, index) => (
        <Cell
          key={item.month}
          fill="var(--color-desktop)"
          fillOpacity={index === highlight ? 1 : 0.35}
        />
      ))}
    </ChartBar>
  ) : (
    <ChartBar
      dataKey="desktop"
      fill="var(--color-desktop)"
      radius={stacked ? [0, 0, 0, 0] : 4}
      layout={horizontal ? "vertical" : "horizontal"}
      stackId={stacked ? "a" : undefined}
    >
      {variant === "label" ? (
        <LabelList
          dataKey="desktop"
          position="top"
          offset={6.5}
          fontSize={13}
          fill="var(--gray-11)"
        />
      ) : null}
      {variant === "custom" ? (
        <LabelList dataKey="month" content={MonthLabel} />
      ) : null}
    </ChartBar>
  );

  const plot = variant === "mixed" ? (
    <ComposedChart
      accessibilityLayer
      data={monthVisitors}
      margin={{ top: 13, right: 13, left: 13, bottom: 0 }}
    >
      <CartesianGrid vertical={false} stroke="var(--gray-6)" />
      <XAxis
        dataKey="month"
        tickLine={false}
        axisLine={false}
        tickMargin={13}
        tick={axisTick}
        tickFormatter={(value: string) => value.slice(0, 3)}
      />
      <ChartTooltip content={<ChartTooltipContent indicator="dot" />} />
      <ChartBar dataKey="desktop" fill="var(--color-desktop)" radius={4} />
      <ChartLine
        dataKey="mobile"
        type="natural"
        stroke="var(--color-mobile)"
        strokeWidth={2}
        dot={false}
      />
      <ChartLegend content={<ChartLegendContent />} />
    </ComposedChart>
  ) : (
    <RechartsBarChart
      accessibilityLayer
      data={data}
      layout={horizontal ? "vertical" : "horizontal"}
      margin={{ top: variant === "label" || variant === "custom" ? 26 : 13, right: 13, left: 13, bottom: 0 }}
    >
      <CartesianGrid vertical={horizontal} stroke="var(--gray-6)" />
      {horizontal ? (
        <XAxis type="number" dataKey="desktop" tickLine={false} axisLine={false} tickMargin={13} tick={axisTick} />
      ) : (
        <XAxis
          dataKey="month"
          tickLine={false}
          axisLine={false}
          tickMargin={13}
          tick={axisTick}
          tickFormatter={(value: string) => value.slice(0, 3)}
        />
      )}
      {horizontal ? (
        <YAxis
          type="category"
          dataKey="month"
          tickLine={false}
          axisLine={false}
          tickMargin={13}
          width={78}
          tick={axisTick}
          tickFormatter={(value: string) => value.slice(0, 3)}
        />
      ) : null}
      <ChartTooltip
        content={
          <ChartTooltipContent
            indicator={horizontal ? "line" : "dot"}
            hideLabel={negative}
          />
        }
      />
      {bars}
      {multi ? (
        <ChartBar
          dataKey="mobile"
          fill="var(--color-mobile)"
          radius={4}
          layout={horizontal ? "vertical" : "horizontal"}
          stackId={stacked ? "a" : undefined}
        />
      ) : null}
      {showLegend ? <ChartLegend content={<ChartLegendContent />} /> : null}
    </RechartsBarChart>
  );

  return (
    <Card className={cn("w-full", className)}>
      <CardHeader>
        <CardTitle>{title}</CardTitle>
        <CardDescription>
          {negative
            ? "Showing visitor change for the last 6 months"
            : variant === "active"
              ? "June is highlighted. Earlier months stay quiet."
              : "Showing total visitors for the last 6 months"}
        </CardDescription>
      </CardHeader>
      <CardContent>
        <ChartContainer
          config={chartConfig}
          label={title}
          className={horizontal ? "aspect-auto h-20" : undefined}
        >
          {plot}
        </ChartContainer>
      </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 { BarChart };

Usage

Vertical

export const Vertical = () => <BarChart variant="default" />;

Horizontal

export const Horizontal = () => <BarChart variant="horizontal" />;

Multiple

export const Multiple = () => <BarChart variant="multiple" />;

Stacked

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

Label

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

CustomLabel

export const CustomLabel = () => <BarChart variant="custom" />;

Mixed

export const Mixed = () => <BarChart variant="mixed" />;

Active

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

Negative

export const Negative = () => <BarChart variant="negative" />;