Chart
Recharts charts with themed colors, a tooltip and legend that read labels from one config, and keyboard navigation with a visible focus ring.
Installation
Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:
npx shadcn@latest add https://kryptonite-ui.vercel.app/r/chart.jsonInstall manually
Install the dependencies:
npm install cn rechartsCopy this into
components/ui/chart.tsx:chart.tsx"use client" import * as React from "react" import { cn } from "cn" import * as RechartsPrimitive from "recharts" import type { DefaultLegendContentProps, TooltipContentProps, TooltipProps, TooltipValueType, } from "recharts" const THEMES = { light: "", dark: ".dark" } as const const INITIAL_DIMENSION = { width: 320, height: 200 } as const type TooltipNameType = number | string 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 <ChartContainer>.") } return context } function toCssIdent(value: string) { return value.replace(/[^\w-]/g, "-") } function toCssValue(value: string) { return value.replace(/[;{}<>]/g, "").trim() } 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-${toCssIdent(id ?? uniqueId)}` const context = React.useMemo(() => ({ config }), [config]) return ( <ChartContext.Provider value={context}> <div role="figure" data-slot="chart" data-chart={chartId} className={cn( "flex aspect-video min-w-0 justify-center text-xs tabular-nums forced-color-adjust-none [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-polar-angle-axis-tick_text]:fill-muted-foreground [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-foreground/5 [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:rounded-md [&_.recharts-surface]:outline-hidden [&_.recharts-surface]:transition-shadow [&_.recharts-surface]:duration-150 [&_.recharts-surface]:[direction:ltr] [&_.recharts-surface_*]:outline-hidden [&_.recharts-surface:focus-visible]:ring-3 [&_.recharts-surface:focus-visible]:ring-focus-ring [&_.recharts-tooltip-wrapper[data-chart-snap]]:transition-none! [&_.recharts-wrapper]:touch-pan-y", className )} {...props} > <ChartStyle id={chartId} config={config} /> <RechartsPrimitive.ResponsiveContainer initialDimension={initialDimension} > {children} </RechartsPrimitive.ResponsiveContainer> </div> </ChartContext.Provider> ) } function ChartStyle({ id, config }: { id: string; config: ChartConfig }) { const colorConfig = Object.entries(config).filter( ([, itemConfig]) => itemConfig.theme ?? itemConfig.color ) if (!colorConfig.length) { return null } const css = Object.entries(THEMES) .map(([theme, prefix]) => { const declarations = colorConfig .map(([key, itemConfig]) => { const color = itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? itemConfig.color const value = color ? toCssValue(color) : "" return value ? ` --color-${toCssIdent(key)}: ${value};` : null }) .filter(Boolean) .join("\n") return `${prefix} [data-chart="${toCssIdent(id)}"] {\n${declarations}\n}` }) .join("\n") return <style dangerouslySetInnerHTML={{ __html: css }} /> } function ChartTooltip({ animationDuration = 200, animationEasing = "ease-out", ...props }: TooltipProps<TooltipValueType, TooltipNameType>) { return ( <RechartsPrimitive.Tooltip animationDuration={animationDuration} animationEasing={animationEasing} {...props} /> ) } function useNumberFormat(locale: Intl.LocalesArgument) { return React.useMemo(() => { try { return new Intl.NumberFormat(locale) } catch { return new Intl.NumberFormat("en-US") } }, [locale]) } function formatValue(value: unknown, format: Intl.NumberFormat): string { if (typeof value === "number") { return Number.isFinite(value) ? format.format(value) : String(value) } if (Array.isArray(value)) { return value.map((item) => formatValue(item, format)).join(" – ") } return value == null ? "" : String(value) } function snapTooltipWrapper(node: HTMLDivElement | null) { const wrapper = node?.parentElement if ( !wrapper?.classList.contains("recharts-tooltip-wrapper") || typeof MutationObserver === "undefined" ) { return undefined } let hidden = wrapper.style.visibility === "hidden" let from: string | null = null let frame = 0 let timeout: ReturnType<typeof setTimeout> | undefined const release = () => { from = null clearTimeout(timeout) cancelAnimationFrame(frame) frame = requestAnimationFrame(() => { frame = requestAnimationFrame(() => { wrapper.removeAttribute("data-chart-snap") }) }) } const snap = () => { cancelAnimationFrame(frame) clearTimeout(timeout) wrapper.setAttribute("data-chart-snap", "") from = wrapper.style.transform timeout = setTimeout(release, 100) } if (!hidden) { snap() } const observer = new MutationObserver(() => { const nowHidden = wrapper.style.visibility === "hidden" if (hidden && !nowHidden) { snap() } else if (from !== null && wrapper.style.transform !== from) { release() } hidden = nowHidden }) observer.observe(wrapper, { attributes: true, attributeFilter: ["style"] }) return () => { observer.disconnect() cancelAnimationFrame(frame) clearTimeout(timeout) wrapper.removeAttribute("data-chart-snap") } } type ChartTooltipPayload = TooltipContentProps< TooltipValueType, TooltipNameType >["payload"] type ChartTooltipContentProps = { active?: boolean payload?: ChartTooltipPayload label?: React.ReactNode labelFormatter?: TooltipProps< TooltipValueType, TooltipNameType >["labelFormatter"] formatter?: TooltipProps<TooltipValueType, TooltipNameType>["formatter"] valueFormatter?: ( value: TooltipValueType | undefined, name: TooltipNameType | undefined, item: ChartTooltipPayload[number] ) => React.ReactNode className?: string labelClassName?: string color?: string indicator?: "line" | "dot" | "dashed" hideLabel?: boolean hideIndicator?: boolean nameKey?: string labelKey?: string locale?: Intl.LocalesArgument } function ChartTooltipContent({ active, payload, className, indicator = "dot", hideLabel = false, hideIndicator = false, label, labelFormatter, labelClassName, formatter, valueFormatter, color, nameKey, labelKey, locale = "en-US", }: ChartTooltipContentProps) { const { config } = useChart() const format = useNumberFormat(locale) if (!active || !payload?.length) { return null } const items = payload.filter((item) => item.type !== "none") const tooltipLabel = (() => { if (hideLabel) { 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" || typeof label === "number") ? ((Object.hasOwn(config, label) ? config[label].label : undefined) ?? label) : itemConfig?.label if (labelFormatter) { const formatted = labelFormatter(value, payload) return formatted == null || formatted === "" ? null : ( <div data-slot="chart-tooltip-label" className={cn("font-medium text-pretty", labelClassName)} > {formatted} </div> ) } if (value == null || value === "") { return null } return ( <div data-slot="chart-tooltip-label" className={cn("font-medium text-pretty", labelClassName)} > {value} </div> ) })() const nestLabel = items.length === 1 && indicator !== "dot" return ( <div ref={snapTooltipWrapper} data-slot="chart-tooltip" className={cn( "grid max-w-[min(18rem,calc(100vw-2rem))] min-w-32 animate-in items-start gap-1.5 rounded-lg bg-popover px-2.5 py-1.5 text-xs wrap-anywhere text-popover-foreground shadow-md ring-(length:--hairline) ring-foreground/10 ease-out-quint animation-duration-150 fade-in-0 motion-safe:zoom-in-95 forced-colors:border", className )} > {!nestLabel ? tooltipLabel : null} <div className="grid gap-1.5"> {items.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 const value = valueFormatter ? valueFormatter(item.value, item.name, item) : formatValue(item.value, format) return ( <div key={`${item.graphicalItemId ?? ""}-${String(item.dataKey ?? index)}-${index}`} data-slot="chart-tooltip-item" className={cn( "flex w-full flex-wrap items-stretch gap-2 [&>svg]:size-2.5 [&>svg]:shrink-0 [&>svg]:text-muted-foreground", indicator === "dot" && "items-center" )} > {formatter && item.value !== undefined && item.name ? ( formatter(item.value, item.name, item, index, payload) ) : ( <> {itemConfig?.icon ? ( <itemConfig.icon /> ) : ( !hideIndicator && ( <div aria-hidden data-slot="chart-tooltip-indicator" data-indicator={indicator} style={ { "--chart-indicator": indicatorColor, } as React.CSSProperties } className={cn( "shrink-0 rounded-[calc(var(--radius-sm)*0.4)] border-(--chart-indicator) bg-(--chart-indicator)", 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" )} /> ) )} <div className={cn( "flex min-w-0 flex-1 justify-between gap-4 leading-none", nestLabel ? "items-end" : "items-center" )} > <div className="grid min-w-0 gap-1.5"> {nestLabel ? tooltipLabel : null} <span className="text-muted-foreground"> {itemConfig?.label ?? item.name} </span> </div> {value != null && value !== "" && value !== false && ( <span dir="ltr" className="shrink-0 font-medium text-foreground tabular-nums" > {value} </span> )} </div> </> )} </div> ) })} </div> </div> ) } function ChartLegend({ itemSorter = null, ...props }: React.ComponentProps<typeof RechartsPrimitive.Legend>) { return <RechartsPrimitive.Legend itemSorter={itemSorter} {...props} /> } function ChartLegendContent({ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey, }: Pick<DefaultLegendContentProps, "payload" | "verticalAlign"> & { className?: string hideIcon?: boolean nameKey?: string }) { const { config } = useChart() const items = payload?.filter((item) => item.type !== "none") ?? [] if (!items.length) { return null } return ( <div data-slot="chart-legend" className={cn( "flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5", verticalAlign === "top" ? "pb-3" : "pt-3", className )} > {items.map((item, index) => { const key = `${nameKey ?? item.dataKey ?? "value"}` const itemConfig = getPayloadConfigFromPayload(config, item, key) return ( <div key={`${String(item.dataKey ?? item.value ?? "")}-${index}`} data-slot="chart-legend-item" className="flex min-w-0 items-center gap-1.5 [&>svg]:size-3 [&>svg]:shrink-0 [&>svg]:text-muted-foreground" > {itemConfig?.icon && !hideIcon ? ( <itemConfig.icon /> ) : ( <div aria-hidden data-slot="chart-legend-indicator" style={ { "--chart-indicator": item.color } as React.CSSProperties } className="size-2 shrink-0 rounded-[calc(var(--radius-sm)*0.4)] bg-(--chart-indicator)" /> )} <span className="min-w-0 wrap-anywhere"> {itemConfig?.label ?? item.value} </span> </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 Object.hasOwn(config, configLabelKey) ? config[configLabelKey] : Object.hasOwn(config, key) ? config[key] : undefined } export { ChartContainer, ChartLegend, ChartLegendContent, ChartStyle, ChartTooltip, ChartTooltipContent, useChart, } export type { ChartConfig, ChartTooltipContentProps }
Examples
Bar chart
Horizontal bars
Icons
Line chart
Long labels
Negative values
Donut
Radar
Right to left
Stacked bars
Light and dark colors
Tooltip
Formatting values
Feedback
Questions, bugs or ideas for Chart? Post them here, or reply to someone else. Sign in with GitHub to comment.