Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/chart.jsonInstalls to @/components/ui/chart.
Manual installation
Install the dependencies:
pnpm add recharts@3.8.0Copy the source into your project:
'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,lineanddashedindicators,hideLabel,hideIndicator,formatter) and legends - Icon support for data series
initialDimensionto 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 RechartsResponsiveContainer. Props:config(required),initialDimension({ width, height }, defaults to 320x200), plusdivprops.ChartTooltip- Re-export of RechartsTooltip.ChartTooltipContent- Styled tooltip. Props:indicator('dot'|'line'|'dashed', default'dot'),hideLabel,hideIndicator,nameKey,labelKey,labelFormatter,formatter,color,labelClassName.ChartLegend- Re-export of RechartsLegend.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 seriesicon: Optional icon component for legends and tooltipscolor: 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-1through--chart-5: Default chart colors (full color values, use them asvar(--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
-
Data Preparation:
- Use consistent data structures
- Handle missing or null values appropriately
- Consider data aggregation for large datasets
- Format numbers and dates consistently
-
Visual Design:
- Choose appropriate chart types for your data
- Use consistent color schemes across charts
- Provide clear labels and legends
- Maintain good contrast ratios
-
Performance:
- Limit data points for better performance
- Use appropriate chart dimensions
- Consider virtualization for large datasets
- Optimize re-renders with proper memoization
-
Responsiveness:
- Set appropriate aspect ratios
- Test on various screen sizes
- Consider mobile-specific interactions
- Use responsive font sizes
-
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
},
}Related Components
- Card
- Tooltip
- Progress