Charts

Area Chart

Filled area chart built on the quebi chart wrapper, with an interactive legend, themed tooltip, stacked/percent stacking, and gradient or solid fills. Requires the recharts package.

chartarearechartsdatagraphvisualization

Default

Two teal-led series with gradient fills, an interactive legend, and a themed tooltip.

Stacked

Series stacked on top of each other to show cumulative totals.

Percent

Stacked areas normalized to 100% to compare relative share over time.

Solid fill, no grid

Solid fills with grid lines hidden for a cleaner look.

Source

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { type ComponentProps, Fragment, useId, useMemo } from "react"
import { Area, AreaChart as AreaChartPrimitive } from "recharts"
import type { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"
import {
  type BaseChartProps,
  CartesianGrid,
  Chart,
  ChartLegend,
  ChartLegendContent,
  ChartTooltip,
  ChartTooltipContent,
  constructCategoryColors,
  DEFAULT_COLORS,
  getColorValue,
  valueToPercent,
  XAxis,
  YAxis,
} from "@/components/chart"

/**
 * AreaChart — quebi design system
 *
 * A filled area chart built on the quebi `Chart` recharts wrapper. Renders one
 * area per key in `config`, with an interactive legend, themed tooltip, and a
 * teal-led series palette. Supports stacked / percent stacking and gradient,
 * solid, or no fill. Pass `children` to take full control of the area marks.
 *
 * Requires the `recharts` npm package as a peer dependency.
 */

const slugRegExp = /[^a-zA-Z0-9]/g

const fillNone = <stop stopColor="currentColor" stopOpacity={0} />

const fillGradientEnd = <stop offset="95%" stopColor="currentColor" stopOpacity={0} />

function getFillContent({
  fillType,
  stopOpacity,
}: {
  fillType: AreaChartProps<ValueType, NameType>["fillType"]
  stopOpacity: number
}): React.ReactNode {
  switch (fillType) {
    case "none":
      return fillNone
    case "gradient":
      return (
        <>
          <stop offset="5%" stopColor="currentColor" stopOpacity={stopOpacity} />
          {fillGradientEnd}
        </>
      )
    default:
      return <stop stopColor="currentColor" stopOpacity={stopOpacity} />
  }
}

export interface AreaChartProps<TValue extends ValueType, TName extends NameType>
  extends BaseChartProps<TValue, TName> {
  chartProps?: Omit<ComponentProps<typeof AreaChartPrimitive>, "data" | "stackOffset">
  areaProps?: Partial<ComponentProps<typeof Area>>
  connectNulls?: boolean
  fillType?: "gradient" | "solid" | "none"
}

export function AreaChart<TValue extends ValueType, TName extends NameType>({
  data = [],
  dataKey,
  colors = DEFAULT_COLORS,
  connectNulls = false,
  type = "default",

  fillType = "gradient",
  config,
  children,

  areaProps,

  // Components
  tooltip = true,
  tooltipProps,

  cartesianGridProps,

  legend = true,
  legendProps,

  intervalType = "equidistantPreserveStart",

  valueFormatter = (value: number) => value.toString(),

  // XAxis
  displayEdgeLabelsOnly = false,
  hideXAxis = false,
  xAxisProps,

  // YAxis
  hideYAxis = false,
  yAxisProps,

  hideGridLines = false,
  chartProps,
  ...props
}: AreaChartProps<TValue, TName>) {
  const configKeys = useMemo(() => Object.keys(config), [config])
  const categoryColors = useMemo(
    () => constructCategoryColors(configKeys, colors),
    [configKeys, colors],
  )
  const stacked = type === "stacked" || type === "percent"
  const areaId = useId()

  const configEntries = useMemo(() => Object.entries(config), [config])

  return (
    <Chart config={config} data={data} dataKey={dataKey} {...props}>
      {({ onLegendSelect, selectedLegend }) => (
        <AreaChartPrimitive
          onClick={() => {
            onLegendSelect(null)
          }}
          data={data}
          margin={{
            bottom: 0,
            left: 0,
            right: 0,
            top: 5,
          }}
          stackOffset={type === "percent" ? "expand" : undefined}
          {...chartProps}
        >
          {!hideGridLines && <CartesianGrid {...cartesianGridProps} strokeDasharray="3 3" />}
          <XAxis
            className="text-quebi-fg-muted **:[text]:fill-quebi-fg-muted"
            hide={hideXAxis}
            displayEdgeLabelsOnly={displayEdgeLabelsOnly}
            intervalType={intervalType}
            {...xAxisProps}
          />
          <YAxis
            className="text-quebi-fg-muted **:[text]:fill-quebi-fg-muted"
            hide={hideYAxis}
            tickFormatter={type === "percent" ? valueToPercent : valueFormatter}
            {...yAxisProps}
          />

          {legend && (
            <ChartLegend
              content={typeof legend === "boolean" ? <ChartLegendContent /> : legend}
              {...legendProps}
            />
          )}

          {tooltip && (
            <ChartTooltip
              content={
                typeof tooltip === "boolean" ? (
                  <ChartTooltipContent
                    {...{
                      hideIndicator: tooltipProps?.hideIndicator,
                      hideLabel: tooltipProps?.hideLabel,
                      cursor: tooltipProps?.cursor,
                      indicator: tooltipProps?.indicator,
                      labelSeparator: tooltipProps?.labelSeparator,
                      formatter: tooltipProps?.formatter,
                      labelFormatter: tooltipProps?.labelFormatter,
                    }}
                    accessibilityLayer
                  />
                ) : (
                  tooltip
                )
              }
              {...tooltipProps}
            />
          )}

          {!children
            ? configEntries.map(([category, values]) => {
                const categoryId = `${areaId}-${category.replace(slugRegExp, "")}`
                const strokeOpacity = selectedLegend && selectedLegend !== category ? 0.1 : 1
                const stopOpacity = selectedLegend && selectedLegend !== category ? 0.1 : 0.5
                const color = getColorValue(values.color || categoryColors.get(category))

                return (
                  <Fragment key={categoryId}>
                    <defs>
                      <linearGradient style={{ color }} id={categoryId} x1="0" y1="0" x2="0" y2="1">
                        {getFillContent({ fillType, stopOpacity })}
                      </linearGradient>
                    </defs>
                    <Area
                      dot={false}
                      name={category}
                      dataKey={category}
                      stroke={color}
                      style={{
                        strokeWidth: 2,
                        strokeOpacity,
                      }}
                      strokeLinejoin="round"
                      strokeLinecap="round"
                      isAnimationActive={true}
                      connectNulls={connectNulls}
                      stackId={stacked ? "stack" : undefined}
                      fill={`url(#${categoryId})`}
                      {...areaProps}
                    />
                  </Fragment>
                )
              })
            : children}
        </AreaChartPrimitive>
      )}
    </Chart>
  )
}