rapidUI

Chart

Recharts wrapper with theme-aware colors, tooltips and legends.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/chart.json

Installs to @/components/ui/chart.

Manual installation

Install the dependencies:

pnpm add recharts@3.8.0

Copy the source into your project:

components/ui/chart.tsx
'use client'

import * as React from 'react'
import type { TooltipValueType } from 'recharts'
import * as RechartsPrimitive from 'recharts'
import { cn } from '@/lib/utils'

// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const

const INITIAL_DIMENSION = { width: 320, height: 200 } as const
type TooltipNameType = number | string

export type ChartConfig = Record<
	string,
	{
		label?: React.ReactNode
		icon?: React.ComponentType
	} & ({ color?: string; theme?: never } | { color?: never; theme: Record<keyof typeof THEMES, string> })
>

type ChartContextProps = {
	config: ChartConfig
}

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

function useChart() {
	const context = React.useContext(ChartContext)

	if (!context) {
		throw new Error('useChart must be used within a <ChartContainer />')
	}

	return context
}

function ChartContainer({
	id,
	className,
	children,
	config,
	initialDimension = INITIAL_DIMENSION,
	...props
}: React.ComponentProps<'div'> & {
	config: ChartConfig
	children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>['children']
	initialDimension?: {
		width: number
		height: number
	}
}) {
	const uniqueId = React.useId()
	const chartId = `chart-${id ?? uniqueId.replaceAll(':', '')}`

	return (
		<ChartContext.Provider value={{ config }}>
			<div
				data-slot="chart"
				data-chart={chartId}
				className={cn(
					"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
					className,
				)}
				{...props}
			>
				<ChartStyle id={chartId} config={config} />
				<RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension}>
					{children}
				</RechartsPrimitive.ResponsiveContainer>
			</div>
		</ChartContext.Provider>
	)
}

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
			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}: ${color};` : null
	})
	.join('\n')}
}
`,
					)
					.join('\n'),
			}}
		/>
	)
}

const ChartTooltip = RechartsPrimitive.Tooltip

function ChartTooltipContent({
	active,
	payload,
	className,
	indicator = 'dot',
	hideLabel = false,
	hideIndicator = false,
	label,
	labelFormatter,
	labelClassName,
	formatter,
	color,
	nameKey,
	labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
	React.ComponentProps<'div'> & {
		hideLabel?: boolean
		hideIndicator?: boolean
		indicator?: 'line' | 'dot' | 'dashed'
		nameKey?: string
		labelKey?: string
	} & Omit<RechartsPrimitive.DefaultTooltipContentProps<TooltipValueType, TooltipNameType>, 'accessibilityLayer'>) {
	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', labelClassName)}>{labelFormatter(value, payload)}</div>
		}

		if (!value) {
			return null
		}

		return <div className={cn('font-medium', 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-32 items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl',
				className,
			)}
		>
			{!nestLabel ? tooltipLabel : null}
			<div className="grid gap-1.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

						return (
							<div
								// One row per series; a series with several values (a scatter point) differs by data key and name
								key={`${item.graphicalItemId}:${item.dataKey}:${item.name}`}
								className={cn(
									'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
									indicator === 'dot' && 'items-center',
								)}
							>
								{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-[2px] border-(--color-border) bg-(--color-bg)', {
														'h-2.5 w-2.5': indicator === 'dot',
														'w-1': indicator === 'line',
														'w-0 border-[1.5px] border-dashed bg-transparent': indicator === 'dashed',
														'my-0.5': nestLabel && indicator === 'dashed',
													})}
													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-muted-foreground">{itemConfig?.label ?? item.name}</span>
											</div>
											{item.value != null && (
												<span className="font-mono font-medium text-foreground tabular-nums">
													{typeof item.value === 'number' ? item.value.toLocaleString() : String(item.value)}
												</span>
											)}
										</div>
									</>
								)}
							</div>
						)
					})}
			</div>
		</div>
	)
}

const ChartLegend = RechartsPrimitive.Legend

function ChartLegendContent({
	className,
	hideIcon = false,
	payload,
	verticalAlign = 'bottom',
	nameKey,
}: React.ComponentProps<'div'> & {
	hideIcon?: boolean
	nameKey?: string
} & RechartsPrimitive.DefaultLegendContentProps) {
	const { config } = useChart()

	if (!payload?.length) {
		return null
	}

	return (
		<div className={cn('flex items-center justify-center gap-4', verticalAlign === 'top' ? 'pb-3' : 'pt-3', className)}>
			{payload
				.filter((item) => item.type !== 'none')
				.map((item) => {
					const key = `${nameKey ?? item.dataKey ?? 'value'}`
					const itemConfig = getPayloadConfigFromPayload(config, item, key)

					return (
						<div
							// Series differ by data key; the sectors of one pie share it and differ by name and colour
							key={`${item.dataKey}:${item.value}:${item.color}`}
							className={cn('flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground')}
						>
							{itemConfig?.icon && !hideIcon ? (
								<itemConfig.icon />
							) : (
								<div
									className="h-2 w-2 shrink-0 rounded-[2px]"
									style={{
										backgroundColor: item.color,
									}}
								/>
							)}
							{itemConfig?.label}
						</div>
					)
				})}
		</div>
	)
}

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]
}

export { ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend, ChartLegendContent, ChartStyle }

A comprehensive chart component built on top of Recharts 3, providing accessible and customizable data visualization with consistent theming and styling. Features automatic color theming, responsive design, and comprehensive tooltip/legend support.

Features

  • Built on Recharts for robust chart functionality
  • Automatic color theming with light/dark mode support
  • Responsive container with aspect ratio control
  • Customizable tooltips (dot, line and dashed indicators, hideLabel, hideIndicator, formatter) and legends
  • Icon support for data series
  • initialDimension to control the size used before the container is measured (useful for SSR)
  • TypeScript support with comprehensive types
  • Accessible by default
  • Multiple chart types (Bar, Line, Area, Pie)
  • Custom styling with Tailwind CSS
  • Theme-aware color configuration

Usage

import {
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  ChartLegend,
  ChartLegendContent,
  type ChartConfig,
} from '@/components/ui/chart'
import { BarChart, Bar, XAxis, CartesianGrid } from 'recharts'

// Define your chart configuration
const chartConfig = {
  received: {
    label: 'Received',
    color: 'var(--chart-1)',
  },
  decided: {
    label: 'Decided',
    color: 'var(--chart-2)',
  },
} satisfies ChartConfig

// Building permit applications per month
const data = [
  { month: 'January', received: 142, decided: 118 },
  { month: 'February', received: 168, decided: 131 },
  { month: 'March', received: 231, decided: 176 },
]

// Basic bar chart
export function PermitChart() {
  return (
    <ChartContainer config={chartConfig} className="h-[240px] w-full">
      <BarChart accessibilityLayer data={data}>
        <CartesianGrid vertical={false} />
        <XAxis dataKey="month" />
        <ChartTooltip content={<ChartTooltipContent />} />
        <Bar dataKey="received" fill="var(--color-received)" radius={4} />
        <Bar dataKey="decided" fill="var(--color-decided)" radius={4} />
      </BarChart>
    </ChartContainer>
  )
}

// Different colours in light and dark mode
export function ThemedChart() {
  const themedConfig = {
    received: {
      label: 'Received',
      theme: { light: 'var(--primary)', dark: 'var(--chart-1)' },
    },
    decided: {
      label: 'Decided',
      theme: { light: 'var(--muted-foreground)', dark: 'var(--chart-2)' },
    },
  } satisfies ChartConfig

  return (
    <ChartContainer config={themedConfig} className="h-[240px] w-full">
      <BarChart data={data}>
        <XAxis dataKey="month" />
        <ChartTooltip content={<ChartTooltipContent />} />
        <Bar dataKey="received" fill="var(--color-received)" radius={4} />
        <Bar dataKey="decided" fill="var(--color-decided)" radius={4} />
      </BarChart>
    </ChartContainer>
  )
}

// With icons and legend
import { GlobeIcon, PhoneIcon } from 'lucide-react'

export function ChannelChart() {
  const channelConfig = {
    eservice: { label: 'E-services', color: 'var(--chart-1)', icon: GlobeIcon },
    phone: { label: 'Phone', color: 'var(--chart-2)', icon: PhoneIcon },
  } satisfies ChartConfig

  return (
    <ChartContainer config={channelConfig} className="h-[240px] w-full">
      <BarChart data={channelData}>
        <XAxis dataKey="month" />
        <ChartTooltip content={<ChartTooltipContent />} />
        <ChartLegend content={<ChartLegendContent />} />
        <Bar dataKey="eservice" fill="var(--color-eservice)" radius={4} />
        <Bar dataKey="phone" fill="var(--color-phone)" radius={4} />
      </BarChart>
    </ChartContainer>
  )
}

Examples

Default Bar Chart

Building permit applications received and decided per month, in a Card with a short summary in the footer.

Line Chart

Processing time for two permit types, with a ReferenceLine for the legal limit and a legend. The second series is dashed so the lines can be told apart without colour.

Area Chart

Stacked areas for physical and digital library loans.

Pie Chart

How residents contacted the municipality, with labels outside the slices. Colours are set per slice with fill: 'var(--color-<key>)' in the data.

With Custom Tooltip

labelFormatter and formatter on ChartTooltipContent add the year and a unit to the tooltip.

With Icons

An icon in the config replaces the colour swatch in the legend and tooltip.

Responsive

With w-full and a fixed height, the chart fills the width of its container.

Custom Theme

theme sets separate colours for light and dark mode. Use theme tokens so the colours follow the active theme.

Tooltip Indicators

ChartTooltipContent with indicator="dot", "line" and "dashed".

Tooltip Without Label

hideLabel hides the tooltip heading, which suits a small sparkline in a key figure card.

Stacked Bar

Bars with the same stackId stack on top of each other; round only the outer corners with radius.

Horizontal Bar

layout="vertical" with category labels on the Y axis and values printed at the end of each bar with LabelList.

Donut Chart

Pie chart with innerRadius and a total in the middle via a Recharts Label, using nameKey so tooltip and legend labels come from the config.

Components

  • ChartContainer - Provides the config context, injects --color-{key} variables and wraps children in a Recharts ResponsiveContainer. Props: config (required), initialDimension ({ width, height }, defaults to 320x200), plus div props.
  • ChartTooltip - Re-export of Recharts Tooltip.
  • ChartTooltipContent - Styled tooltip. Props: indicator ('dot' | 'line' | 'dashed', default 'dot'), hideLabel, hideIndicator, nameKey, labelKey, labelFormatter, formatter, color, labelClassName.
  • ChartLegend - Re-export of Recharts Legend.
  • ChartLegendContent - Styled legend. Props: hideIcon, nameKey.
  • ChartStyle - The internal style injector (exported for advanced use).

Chart Configuration

The ChartConfig type defines how your data keys are styled and labeled:

type ChartConfig = {
  [key: string]: {
    label?: React.ReactNode
    icon?: React.ComponentType
  } & (
    | { color?: string; theme?: never }
    | { color?: never; theme: Record<'light' | 'dark', string> }
  )
}

Configuration Options

  • label: Display name for the data series
  • icon: Optional icon component for legends and tooltips
  • color: Single color value (works in both themes)
  • theme: Object with separate colors for light and dark themes

Built-in CSS Variables

The component provides CSS variables for consistent theming:

  • --chart-1 through --chart-5: Default chart colors (full color values, use them as var(--chart-1))
  • --color-{key}: Dynamic variables based on your config keys

Chart Types

Supported Recharts Components

  • BarChart: Vertical and horizontal bar charts
  • LineChart: Line charts with multiple series
  • AreaChart: Area and stacked area charts
  • PieChart: Pie and donut charts
  • RadialBarChart: Radial progress charts
  • ScatterChart: Scatter plot charts
  • ComposedChart: Mixed chart types

Common Patterns

// Bar Chart
<BarChart data={data}>
  <CartesianGrid vertical={false} />
  <XAxis dataKey="month" />
  <ChartTooltip content={<ChartTooltipContent />} />
  <Bar dataKey="value" fill="var(--color-value)" radius={4} />
</BarChart>

// Line Chart
<LineChart data={data}>
  <CartesianGrid strokeDasharray="3 3" />
  <XAxis dataKey="month" />
  <YAxis />
  <ChartTooltip content={<ChartTooltipContent />} />
  <Line type="monotone" dataKey="value" stroke="var(--color-value)" />
</LineChart>

// Area Chart
<AreaChart data={data}>
  <CartesianGrid strokeDasharray="3 3" />
  <XAxis dataKey="month" />
  <YAxis />
  <ChartTooltip content={<ChartTooltipContent />} />
  <Area type="monotone" dataKey="value" stroke="var(--color-value)" fill="var(--color-value)" />
</AreaChart>

Accessibility

The Chart component follows accessibility best practices:

  • Semantic markup with appropriate ARIA labels
  • Keyboard navigation support where applicable
  • High contrast colors for better visibility
  • Screen reader friendly tooltips and labels
  • Responsive design for various screen sizes
  • Focus management for interactive elements

Best Practices

  1. Data Preparation:

    • Use consistent data structures
    • Handle missing or null values appropriately
    • Consider data aggregation for large datasets
    • Format numbers and dates consistently
  2. Visual Design:

    • Choose appropriate chart types for your data
    • Use consistent color schemes across charts
    • Provide clear labels and legends
    • Maintain good contrast ratios
  3. Performance:

    • Limit data points for better performance
    • Use appropriate chart dimensions
    • Consider virtualization for large datasets
    • Optimize re-renders with proper memoization
  4. Responsiveness:

    • Set appropriate aspect ratios
    • Test on various screen sizes
    • Consider mobile-specific interactions
    • Use responsive font sizes
  5. Accessibility:

    • Provide alternative text descriptions
    • Use semantic HTML where possible
    • Test with screen readers
    • Ensure keyboard accessibility

Theming

CSS Variables

The component uses CSS variables for theming:

:root {
  --chart-1: #d2691e;
  --chart-2: #4b6f9c;
  --chart-3: #3b4c7a;
  --chart-4: #d4a017;
  --chart-5: #c99a0f;
}

The variables hold complete color values, so reference them directly (var(--chart-1)) rather than wrapping them in hsl().

Custom Themes

You can override the default theme by providing custom colors:

const customConfig = {
  applications: {
    label: 'Applications',
    theme: {
      light: 'var(--primary)', // Primary colour in light mode
      dark: 'var(--chart-1)',  // Chart colour in dark mode
    },
  },
  decisions: {
    label: 'Decisions',
    color: 'var(--chart-2)', // Same token in both modes
  },
}
  • Card
  • Tooltip
  • Progress