component — charts

Composed Chart

Bars, lines and areas on one pair of axes, driven by a series list over the quebi Chart wrapper, with an optional second Y axis that appears only when a series asks for it. Requires the recharts package.

  • chart
  • composed
  • combo
  • recharts
  • dual-axis
  • data
  • visualization

04 examples

Bar and line

Two series on one axis, drawn with different marks. The list in `series` says which key is which — order does not matter, lines are always drawn over bars.

Dual Y axis

A series measured against its own right-hand axis. The second axis is rendered only because a series asked for it, and each axis formats its own ticks.

Area, bar and line

All three marks together, with the rate series on the right-hand axis.

Stacked bars with a target line

Bars sharing a `stackId` stack together — orders and refunds are one column — while the line reads against the right-hand axis. `overlays` adds the reference line beside the generated series instead of replacing them.

source

composed-chart.tsx

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

"use client"

import { type ComponentProps, useMemo } from "react"
import { Area, Bar, ComposedChart as ComposedChartPrimitive, Line } 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"

/**
 * ComposedChart — quebi design system
 *
 * Bars, lines and areas on one pair of axes, built on the quebi `Chart` wrapper.
 * Each entry in `series` names a key in `config` and the mark it is drawn with;
 * colors come from the ink series palette and can be overridden per key via
 * `config`. A series can be measured against a second, right-hand Y axis by
 * setting `yAxisId: "right"` — the axis appears only when something uses it.
 *
 * Requires the `recharts` npm package as a peer dependency.
 */

/** The mark a composed series is drawn with. */
export type ComposedSeriesType = "area" | "bar" | "line"

export interface ComposedSeries {
  /** Key in both `data` and `config` — the config entry supplies label and color. */
  key: string
  /** The mark this series is drawn with. */
  type: ComposedSeriesType
  /** Which Y axis the series is measured against. Defaults to `"left"`. */
  yAxisId?: "left" | "right"
  /** Series sharing a `stackId` on the same axis are stacked together. */
  stackId?: string
}

// Areas sit behind bars, bars behind lines, whatever order `series` arrives in.
// A line hidden under a bar is never what the author meant, and the alternative
// is asking every caller to remember the z-order of their own marks.
const MARK_ORDER: Record<ComposedSeriesType, number> = { area: 0, bar: 1, line: 2 }

export interface ComposedChartProps<TValue extends ValueType, TName extends NameType>
  extends BaseChartProps<TValue, TName> {
  /** One entry per rendered series, in any order. */
  series: ComposedSeries[]
  barRadius?: number
  barSize?: number
  barGap?: number
  barCategoryGap?: number
  /** Corner rounding and fill opacity of the `area` marks. */
  areaFillOpacity?: number
  /** Props for the second Y axis. It is rendered only if a series asks for it. */
  rightYAxisProps?: ComponentProps<typeof YAxis>
  /** Tick formatter for the right-hand axis. Falls back to `valueFormatter`. */
  rightValueFormatter?: (value: number) => string
  chartProps?: Omit<ComponentProps<typeof ComposedChartPrimitive>, "data" | "stackOffset">
  areaProps?: Partial<ComponentProps<typeof Area>>
  barProps?: Partial<ComponentProps<typeof Bar>>
  lineProps?: Partial<ComponentProps<typeof Line>>
}

export function ComposedChart<TValue extends ValueType, TName extends NameType>({
  data = [],
  dataKey,
  series,
  colors = DEFAULT_COLORS,
  type = "default",
  config,
  children,

  // Components
  tooltip = true,
  tooltipProps,

  legend = true,
  legendProps,

  cartesianGridProps,

  intervalType = "equidistantPreserveStart",

  barCategoryGap = 5,
  barGap,
  barSize,
  barRadius = 4,
  areaFillOpacity = 0.2,

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

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

  // YAxis
  yAxisProps,
  rightYAxisProps,
  hideYAxis = false,

  hideGridLines = false,
  chartProps,
  areaProps,
  barProps,
  lineProps,
  overlays,
  ...props
}: ComposedChartProps<TValue, TName>) {
  const configKeys = useMemo(() => Object.keys(config), [config])
  const categoryColors = useMemo(
    () => constructCategoryColors(configKeys, colors),
    [configKeys, colors],
  )

  const orderedSeries = useMemo(
    () => [...series].sort((a, b) => MARK_ORDER[a.type] - MARK_ORDER[b.type]),
    [series],
  )

  const hasRightAxis = useMemo(() => series.some((s) => s.yAxisId === "right"), [series])
  const stacked = type === "stacked" || type === "percent"

  return (
    <Chart config={config} data={data} dataKey={dataKey} {...props}>
      {({ onLegendSelect, selectedLegend }) => (
        <ComposedChartPrimitive
          onClick={() => {
            onLegendSelect(null)
          }}
          data={data}
          margin={{
            bottom: 0,
            left: 5,
            right: 0,
            top: 5,
          }}
          barGap={barGap}
          barSize={barSize}
          barCategoryGap={barCategoryGap}
          stackOffset={type === "percent" ? "expand" : undefined}
          {...chartProps}
        >
          {!hideGridLines && <CartesianGrid strokeDasharray="4 4" {...cartesianGridProps} />}
          <XAxis
            hide={hideXAxis}
            displayEdgeLabelsOnly={displayEdgeLabelsOnly}
            intervalType={intervalType}
            {...xAxisProps}
          />
          <YAxis
            yAxisId="left"
            hide={hideYAxis}
            tickFormatter={type === "percent" ? valueToPercent : valueFormatter}
            {...yAxisProps}
          />
          {hasRightAxis && (
            <YAxis
              yAxisId="right"
              orientation="right"
              hide={hideYAxis}
              tickFormatter={rightValueFormatter ?? valueFormatter}
              {...rightYAxisProps}
            />
          )}

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

          {tooltip && (
            <ChartTooltip
              content={
                typeof tooltip === "boolean" ? <ChartTooltipContent accessibilityLayer /> : tooltip
              }
              {...tooltipProps}
            />
          )}

          {!children
            ? orderedSeries.map(({ key, type: markType, yAxisId = "left", stackId }) => {
                const color = getColorValue(config[key]?.color || categoryColors.get(key))
                const dimmed = selectedLegend !== null && selectedLegend !== key
                const strokeOpacity = dimmed ? 0.1 : 1
                const resolvedStackId = stackId ?? (stacked ? "stack" : undefined)

                if (markType === "bar") {
                  return (
                    <Bar
                      key={key}
                      name={key}
                      dataKey={key}
                      yAxisId={yAxisId}
                      stackId={resolvedStackId}
                      radius={resolvedStackId ? undefined : barRadius}
                      fill={color}
                      fillOpacity={dimmed ? 0.1 : 1}
                      stroke={color}
                      strokeWidth={1}
                      strokeOpacity={dimmed ? 0.2 : 0}
                      {...barProps}
                    />
                  )
                }

                if (markType === "area") {
                  return (
                    <Area
                      key={key}
                      name={key}
                      dataKey={key}
                      yAxisId={yAxisId}
                      stackId={resolvedStackId}
                      dot={false}
                      type="monotone"
                      stroke={color}
                      fill={color}
                      fillOpacity={dimmed ? 0.05 : areaFillOpacity}
                      strokeLinecap="round"
                      strokeLinejoin="round"
                      style={{ strokeWidth: 2, strokeOpacity }}
                      {...areaProps}
                    />
                  )
                }

                return (
                  <Line
                    key={key}
                    name={key}
                    dataKey={key}
                    yAxisId={yAxisId}
                    dot={false}
                    type="monotone"
                    stroke={color}
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    style={
                      {
                        strokeWidth: 2,
                        strokeOpacity,
                        "--line-color": color,
                      } as React.CSSProperties
                    }
                    {...lineProps}
                  />
                )
              })
            : children}

          {overlays}
        </ComposedChartPrimitive>
      )}
    </Chart>
  )
}