component — charts
Chart
Themeable recharts wrapper with styled axes, grid, tooltip, and an interactive legend, using an ink series palette (gray ramp, no hue). Requires the recharts package.
- chart
- recharts
- data
- graph
- visualization
06 examples
Line
A line chart with two ink series, an interactive legend, and a tooltip.
Bar
Grouped bars sharing the same config and tooltip.
Area
A filled area chart with the ink as the primary series.
Custom legend content
`ChartLegend` renders whatever you give its `content`. The default is the interactive `ChartLegendContent`; this one is a plain read-only key.
Synchronized charts
Two charts sharing a `syncId` share a hover: pointing at March in one moves the tooltip and cursor in the other, which is what makes a stack of small charts read as one picture.
Responsive sizing
`Chart` wraps a `ResponsiveContainer`, so width always comes from the parent — never from a prop. Only the height is yours: `containerHeight`, defaulting to 370 and to 200 under the mobile breakpoint. These two charts are the same component in two grid tracks.
source
chart.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import {
createContext,
type ReactElement,
use,
useCallback,
useEffect,
useId,
useMemo,
useState,
} from "react"
import {
composeRenderProps,
ToggleButton,
ToggleButtonGroup,
type ToggleButtonGroupProps,
} from "react-aria-components"
import type {
CartesianGridProps as CartesianGridPrimitiveProps,
CartesianGridProps,
LegendPayload,
LegendProps,
XAxisProps as XAxisPropsPrimitive,
YAxisProps as YAxisPrimitiveProps,
} from "recharts"
import {
CartesianGrid as CartesianGridPrimitive,
Legend as LegendPrimitive,
ResponsiveContainer,
Tooltip as TooltipPrimitive,
XAxis as XAxisPrimitive,
YAxis as YAxisPrimitive,
} from "recharts"
import type { ContentType as LegendContentType } from "recharts/types/component/DefaultLegendContent"
import type {
NameType,
Props as TooltipContentProps,
ValueType,
} from "recharts/types/component/DefaultTooltipContent"
import type {
TooltipProps as RechartsTooltipProps,
ContentType as TooltipContentType,
} from "recharts/types/component/Tooltip"
import { cn } from "@/lib/utils"
/**
* Chart — quebi design system
*
* A themeable recharts wrapper: provides a `Chart` container plus styled
* axes, grid, tooltip, and an interactive legend. Series colors come from an
* ink ramp (`chart-1` is the ink itself) and can be overridden per-key via the
* `config` prop. The chrome is hairline grid, subtle caption-size ticks and a
* floating tooltip — the data carries the contrast, the frame does not.
*
* Requires the `recharts` npm package as a peer dependency.
*/
// Small inline mobile detector so the component stays self-contained.
function useIsMobile(breakpoint = 768) {
const [isMobile, setIsMobile] = useState(false)
useEffect(() => {
if (typeof window === "undefined") {
return
}
const mql = window.matchMedia(`(max-width: ${breakpoint - 1}px)`)
const onChange = () => setIsMobile(mql.matches)
onChange()
mql.addEventListener("change", onChange)
return () => mql.removeEventListener("change", onChange)
}, [breakpoint])
return isMobile
}
// #region Chart Types
type ChartType = "default" | "stacked" | "percent"
type ChartLayout = "horizontal" | "vertical" | "radial"
type IntervalType = "preserveStartEnd" | "equidistantPreserveStart"
type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: ChartColorKeys | (string & {}); theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
// Ink & Paper series palette: no hue, only ink. Each step is a rung of the
// gray ramp, ordered so that neighbouring series alternate dark and light and
// never sit on adjacent rungs; the values flip with the theme through the
// `--q-chart-*` variables in quebi-theme.css. chart-6 to chart-10 are the
// overflow ring for hierarchical charts (Treemap, Sunburst), which routinely
// have more branches than a five-series line chart. Past five series, label
// the marks — grey alone cannot carry ten categories.
const CHART_COLORS = {
"chart-1": "var(--q-chart-1)",
"chart-2": "var(--q-chart-2)",
"chart-3": "var(--q-chart-3)",
"chart-4": "var(--q-chart-4)",
"chart-5": "var(--q-chart-5)",
"chart-6": "var(--q-chart-6)",
"chart-7": "var(--q-chart-7)",
"chart-8": "var(--q-chart-8)",
"chart-9": "var(--q-chart-9)",
"chart-10": "var(--q-chart-10)",
} as const
type ChartColorKeys = keyof typeof CHART_COLORS | (string & {})
const DEFAULT_COLORS = ["chart-1", "chart-2", "chart-3", "chart-4", "chart-5"] as const
/**
* The full ten-step ring. `DEFAULT_COLORS` stays at five so the series charts
* keep the palette they were designed against; pass this where a chart has more
* categories than series, such as a Treemap or a Sunburst.
*/
const EXTENDED_COLORS = [
"chart-1",
"chart-2",
"chart-3",
"chart-4",
"chart-5",
"chart-6",
"chart-7",
"chart-8",
"chart-9",
"chart-10",
] as const
type ChartContextProps = {
config: ChartConfig
data?: Record<string, unknown>[]
layout: ChartLayout
dataKey?: string
selectedLegend: string | null
onLegendSelect: (legendItem: string | null) => void
}
const ChartContext = createContext<ChartContextProps | null>(null)
export function useChart() {
const context = use(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <Chart />")
}
return context
}
export function valueToPercent(value: number) {
return `${(value * 100).toFixed(0)}%`
}
const constructCategoryColors = (
categories: string[],
colors: readonly ChartColorKeys[],
): Map<string, ChartColorKeys> => {
const categoryColors = new Map<string, ChartColorKeys>()
categories.forEach((category, index) => {
const color = colors[index % colors.length]
if (color !== undefined) {
categoryColors.set(category, color)
}
})
return categoryColors
}
const getColorValue = (color?: string): string => {
if (!color) {
return CHART_COLORS["chart-1"]
}
return CHART_COLORS[color as "chart-1"] ?? color
}
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: string = 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 as keyof typeof config]
}
interface BaseChartProps<TValue extends ValueType, TName extends NameType>
extends React.HTMLAttributes<HTMLDivElement> {
containerHeight?: number
config: ChartConfig
data: Record<string, unknown>[]
dataKey: string
colors?: readonly (ChartColorKeys | (string & {}))[]
type?: ChartType
intervalType?: IntervalType
layout?: ChartLayout
valueFormatter?: (value: number) => string
tooltip?: TooltipContentType<TValue, TName> | boolean
tooltipProps?: Omit<ChartTooltipProps<TValue, TName>, "content"> & {
hideLabel?: boolean
labelSeparator?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}
cartesianGridProps?: CartesianGridProps
/**
* Extra recharts children — a `Brush`, a `ReferenceLine`, a `ReferenceArea` —
* rendered *alongside* the series the config generates. `children` replaces
* those series; this is the slot for the case where you want to keep them and
* add something next to them.
*/
overlays?: React.ReactNode
legend?: LegendContentType | boolean
legendProps?: Omit<React.ComponentProps<typeof LegendPrimitive>, "content" | "ref">
xAxisProps?: XAxisPropsPrimitive
yAxisProps?: YAxisPrimitiveProps
// XAxis
displayEdgeLabelsOnly?: boolean
hideGridLines?: boolean
hideXAxis?: boolean
hideYAxis?: boolean
}
const Chart = ({
id,
className,
children,
config,
data,
dataKey,
ref,
layout = "horizontal",
containerHeight,
...props
}: Omit<React.ComponentProps<"div">, "children"> &
Pick<ChartContextProps, "data" | "dataKey"> & {
config: ChartConfig
containerHeight?: number
layout?: ChartLayout
children: ReactElement | ((props: ChartContextProps) => ReactElement)
}) => {
const isMobile = useIsMobile()
const uniqueId = useId()
const chartId = useMemo(() => `chart-${id || uniqueId.replace(/:/g, "")}`, [id, uniqueId])
const [selectedLegend, setSelectedLegend] = useState<string | null>(null)
const onLegendSelect = useCallback((legendItem: string | null) => {
setSelectedLegend(legendItem)
}, [])
const _data = data ?? []
const _dataKey = dataKey ?? "value"
const value = useMemo(
() => ({
config,
selectedLegend,
onLegendSelect,
data: _data,
dataKey: _dataKey,
layout,
}),
[config, selectedLegend, onLegendSelect, _data, _dataKey, layout],
)
return (
<ChartContext value={value}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"z-20 flex w-full justify-center text-quebi-caption text-quebi-fg-subtle",
"[&_.recharts-cartesian-axis-tick_text]:fill-quebi-fg-subtle [&_.recharts-polar-angle-axis-tick_text]:fill-quebi-fg-subtle [&_.recharts-polar-radius-axis-tick_text]:fill-quebi-fg-subtle [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-quebi-hairline [&_.recharts-curve.recharts-tooltip-cursor]:stroke-quebi-hairline [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-quebi-hairline [&_.recharts-radial-bar-background-sector]:fill-quebi-raised [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-quebi-raised [&_.recharts-reference-line_[stroke='#ccc']]:stroke-quebi-hairline [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-hidden [&_.recharts-surface]:outline-hidden",
"[&_.recharts-dot[fill='#fff']]:fill-(--line-color)",
"[&_.recharts-active-dot>.recharts-dot]:stroke-quebi-hairline",
"[&_.recharts-surface_g]:focus:outline-hidden",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<ResponsiveContainer height={containerHeight ?? (isMobile ? 200 : 370)}>
{typeof children === "function" ? children(value) : children}
</ResponsiveContainer>
</div>
</ChartContext>
)
}
const THEMES = { light: "", dark: ".dark" } as const
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(([_, config]) => config.theme || config.color)
if (!colorConfig.length) {
return null
}
return (
<style
// biome-ignore lint/security/noDangerouslySetInnerHtml: dynamic chart theme CSS injection
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || itemConfig.color
return color ? ` --color-${key}: ${getColorValue(color)};` : null
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
)
}
type ChartTooltipProps<TValue extends ValueType, TName extends NameType> = RechartsTooltipProps<
TValue,
TName
>
const tooltipWrapperStyle = { outline: "none" } as const
// Theme-aware cursor tint: quebi-surface flips (white on dark, ink on light),
// so the hover cursor stays a faint neutral wash in both themes. A wash, not
// `raised`: the cursor sits over the marks, and a solid ground would hide them.
const cursorTint = "color-mix(in oklab, var(--color-quebi-surface) 6%, transparent)"
const cursorStyleRadial = {
stroke: cursorTint,
strokeWidth: 0.1,
fill: cursorTint,
fillOpacity: 0.5,
} as const
const cursorStyleDefault = {
stroke: cursorTint,
strokeWidth: 1,
fill: cursorTint,
fillOpacity: 0.5,
} as const
const ChartTooltip = <TValue extends ValueType, TName extends NameType>(
props: ChartTooltipProps<TValue, TName>,
) => {
const { layout } = useChart()
const cursorStyle = layout === "radial" ? cursorStyleRadial : cursorStyleDefault
return (
<TooltipPrimitive
wrapperStyle={tooltipWrapperStyle}
cursor={cursorStyle}
{...(props as RechartsTooltipProps<ValueType, NameType>)}
/>
)
}
type ChartLegendProps = Omit<React.ComponentProps<typeof LegendPrimitive>, "ref">
const ChartLegend = (props: ChartLegendProps) => {
return <LegendPrimitive align="center" verticalAlign="bottom" {...props} />
}
interface XAxisProps extends Omit<XAxisPropsPrimitive, "ref"> {
displayEdgeLabelsOnly?: boolean
intervalType?: IntervalType
}
const tickHorizontal = {
transform: "translate(32, 6)",
} as const
const XAxis = ({
displayEdgeLabelsOnly,
className,
intervalType = "preserveStartEnd",
minTickGap = 5,
domain,
...props
}: XAxisProps) => {
const { dataKey, data, layout } = useChart()
const ticks =
displayEdgeLabelsOnly && data?.length && dataKey
? ([data[0]?.[dataKey], data[data.length - 1]?.[dataKey]] as (string | number)[])
: undefined
const tick = layout === "horizontal" ? tickHorizontal : undefined
return (
<XAxisPrimitive
className={cn(
"font-mono text-quebi-fg-subtle text-quebi-caption tabular-nums **:[text]:fill-quebi-fg-subtle",
className,
)}
interval={displayEdgeLabelsOnly ? "preserveStartEnd" : intervalType}
tick={tick}
ticks={ticks}
tickLine={false}
axisLine={false}
// Destructured but never forwarded before this line, so a numeric X axis
// (a scatter plot's) could not be given a domain at all.
domain={domain}
minTickGap={minTickGap}
dataKey={layout === "horizontal" ? dataKey : undefined}
{...props}
/>
)
}
const yAxisTickHorizontal = {
transform: "translate(-3, 0)",
} as const
const yAxisTickVertical = {
transform: "translate(0, 0)",
} as const
const YAxis = ({
className,
width,
domain = ["auto", "auto"],
type,
...props
}: Omit<YAxisPrimitiveProps, "ref">) => {
const { layout, dataKey } = useChart()
const tick = layout === "horizontal" ? yAxisTickHorizontal : yAxisTickVertical
return (
<YAxisPrimitive
className={cn(
"font-mono text-quebi-fg-subtle text-quebi-caption tabular-nums **:[text]:fill-quebi-fg-subtle",
className,
)}
// Both of these used to bind tighter than intended — `width ?? layout ===
// "horizontal"` is a boolean, so an explicit `width` was always discarded,
// and `type || layout === "horizontal"` made every axis numeric, so
// `type="category"` could not be asked for. The parentheses are the fix.
width={width ?? (layout === "horizontal" ? 40 : 80)}
domain={domain}
tick={tick}
dataKey={layout === "horizontal" ? undefined : dataKey}
type={type ?? (layout === "horizontal" ? "number" : "category")}
interval={layout === "horizontal" ? undefined : "equidistantPreserveStart"}
axisLine={false}
tickLine={false}
{...props}
/>
)
}
const CartesianGrid = ({ className, ...props }: CartesianGridPrimitiveProps) => {
const { layout } = useChart()
return (
<CartesianGridPrimitive
className={cn("stroke-1 stroke-quebi-hairline", className)}
horizontal={layout !== "vertical"}
vertical={layout === "vertical"}
{...props}
/>
)
}
const ChartTooltipContent = <TValue extends ValueType, TName extends NameType>({
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelSeparator = true,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
ref,
}: TooltipContentProps<TValue, TName> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
labelSeparator?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) => {
const { config } = useChart()
const tooltipLabel = useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
if (!item) {
return null
}
const key = `${labelKey || item.dataKey || item.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return <div className={labelClassName}>{labelFormatter(value, payload)}</div>
}
if (!value) {
return null
}
return <div className={labelClassName}>{value}</div>
}, [label, labelFormatter, payload, hideLabel, labelClassName, config, labelKey])
if (!payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
ref={ref}
className={cn(
"grid min-w-48 items-start rounded-quebi-s border border-quebi-hairline bg-quebi-elevated px-3 py-2 text-quebi-caption text-quebi-fg shadow-quebi-float",
className,
)}
>
{!hideLabel && (
<>
{!nestLabel ? <span className="font-medium">{tooltipLabel}</span> : null}
{labelSeparator && (
<span aria-hidden className="mt-2 mb-3 block h-px w-full bg-quebi-hairline" />
)}
</>
)}
<div className="grid gap-3">
{payload.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
return (
<div
key={key}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 *:data-[slot=icon]:text-quebi-fg-muted",
indicator === "dot" && "items-center *:data-[slot=icon]:size-2.5",
indicator === "line" && "*:data-[slot=icon]:h-full *:data-[slot=icon]:w-2.5",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-full border-(--color-border) bg-(--color-bg)",
indicator === "dot" && "size-2.5",
indicator === "line" && "w-1",
indicator === "dashed" &&
"w-0 border-[1.5px] border-dashed bg-transparent",
nestLabel && indicator === "dashed" && "my-0.5",
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-quebi-fg-muted">{itemConfig?.label || item.name}</span>
</div>
{item.value && (
<span className="font-mono text-quebi-fg tabular-nums">
{item.value.toString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
type ChartLegendContentProps = ToggleButtonGroupProps &
Pick<LegendProps, "align" | "verticalAlign"> & {
payload?: ReadonlyArray<LegendPayload>
hideIcon?: boolean
nameKey?: string
ref?: React.Ref<HTMLDivElement>
}
const ChartLegendContent = ({
className,
hideIcon = false,
payload,
align = "right",
verticalAlign = "bottom",
nameKey,
ref,
}: ChartLegendContentProps) => {
const { config, selectedLegend, onLegendSelect } = useChart()
if (!payload?.length) {
return null
}
// `item.value` is the fallback for a series identified by name rather than by
// data key — a Scatter carries its points in its own `data`, so every scatter
// series would otherwise land on the literal "value" and collide with the
// others on both the React key and the toggle id.
const keyFor = (item: LegendPayload) => `${nameKey || item.dataKey || item.value || "value"}`
// Recharts can report one entry several times: a radial bar chart builds its
// legend from the rows, and registers all of them once per series. Two items
// with one id are a duplicate React key and a toggle that selects both.
const items = payload.filter(
(item, index) => payload.findIndex((other) => keyFor(other) === keyFor(item)) === index,
)
return (
<ToggleButtonGroup
ref={ref}
className={composeRenderProps(className, (resolved) =>
cn(
"flex flex-wrap items-center gap-x-1",
verticalAlign === "top" ? "pb-3" : "pt-3",
align === "right" ? "justify-end" : align === "left" ? "justify-start" : "justify-center",
resolved,
),
)}
// `[]`, not `undefined`: an undefined `selectedKeys` makes the group
// uncontrolled, and the first click flips it to controlled — React logs
// the switch and the group briefly owns state the chart is meant to own.
selectedKeys={selectedLegend ? [selectedLegend] : []}
onSelectionChange={(v) => {
const key = [...v][0]?.toString() ?? null
onLegendSelect(key)
}}
selectionMode="single"
>
{items.map((item: LegendPayload) => {
const key = keyFor(item)
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<ToggleButton
key={key}
id={key}
className={cn(
"flex items-center gap-2 rounded-(--q-radius-control) px-2 py-1 text-quebi-caption text-quebi-fg-muted *:data-[slot=icon]:-mx-0.5 *:data-[slot=icon]:size-2.5 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:text-quebi-fg-muted",
// Selected is ink and an underline: no fill, because the swatch
// beside the label is itself ink-on-paper and a ground would eat it.
"selected:text-quebi-fg selected:underline selected:decoration-1 selected:underline-offset-5",
"hover:text-quebi-fg",
"outline-hidden focus-visible:ring-2 focus-visible:ring-quebi-focus focus-visible:ring-inset",
"cursor-pointer transition-colors duration-150",
)}
aria-label={"Legend Item"}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon data-slot="icon" />
) : (
<div
data-slot="icon"
className="rounded-full"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</ToggleButton>
)
})}
</ToggleButtonGroup>
)
}
export type {
BaseChartProps,
ChartColorKeys,
ChartConfig,
ChartLayout,
ChartLegendContentProps,
ChartLegendProps,
ChartTooltipProps,
ChartType,
IntervalType,
XAxisProps,
}
export {
CartesianGrid,
CHART_COLORS,
Chart,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
constructCategoryColors,
DEFAULT_COLORS,
EXTENDED_COLORS,
getColorValue,
XAxis,
YAxis,
}