charts
Registry
Comparison Chart
Two overlapping gradient areas for this year vs last, with a KPI and delta.
Preview
Preview
Revenue
$369
+29.0%
This yearLast year
Loading
Loading
Loading
Installation
Install with the shadcn CLI. This copies source into your project.
bash
npx shadcn@latest add https://livedocs.xyz/r/comparison-chart.jsonNamespace (after one-time setup)
npx shadcn@latest add @livedocs/comparison-chartUsage
tsx
import { yearCompare, yearCompareConfig } from "@/components/ui/chart"
import { ComparisonChart } from "@/components/ui/comparison-chart"
export function Example() {
return (
<ComparisonChart
title="Revenue"
value="$83,151"
delta="+12.8%"
data={yearCompare}
config={yearCompareConfig}
/>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| primaryKey | string | "thisYear" | Foreground series |
| compareKey | string | "lastYear" | Background series |
Source
"use client";
import * as React from "react";
import {
Area,
AreaRevealShape,
type AreaRevealShapeProps,
AreaChart as RechartsAreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
} from "recharts";
import {
ChartContainer,
ChartTooltipContent,
GradientFill,
PixelSwatch,
colorVar,
type ChartConfig,
} from "@/components/ui/chart";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { ChartSkeleton } from "@/components/ui/chart-reactions";
import { cn } from "@/lib/utils";
function InteractiveAreaShape({ onMouseEnter, onMouseLeave, onFocus, onBlur, onClick, ...props }: AreaRevealShapeProps) {
return (
<g
onMouseEnter={onMouseEnter as unknown as React.MouseEventHandler<SVGGElement>}
onMouseLeave={onMouseLeave as unknown as React.MouseEventHandler<SVGGElement>}
onFocus={onFocus as unknown as React.FocusEventHandler<SVGGElement>}
onBlur={onBlur as unknown as React.FocusEventHandler<SVGGElement>}
onClick={onClick as unknown as React.MouseEventHandler<SVGGElement>}
>
<AreaRevealShape {...props} />
</g>
);
}
function ComparisonChartRoot({
title,
value,
delta,
tone = "up",
data,
config,
primaryKey = "thisYear",
compareKey = "lastYear",
xDataKey = "month",
className,
isLoading,
reaction,
}: {
title: string;
value: string;
delta?: string;
tone?: "up" | "down";
data: Record<string, unknown>[];
config: ChartConfig;
primaryKey?: string;
compareKey?: string;
xDataKey?: string;
className?: string;
isLoading?: boolean;
reaction?: import("@/components/ui/chart-reactions").ChartReactionOptions;
}) {
return (
<Card className={cn("p-5 sm:p-6", className)}>
<ChartSkeleton isLoading={isLoading}>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-[15px] font-medium tracking-tight">{title}</p>
<div className="mt-1 flex items-center gap-2">
<p className="tabular-nums text-4xl font-medium tracking-tight">{value}</p>
{delta ? (
<Badge
variant="outline"
className={
tone === "down"
? "border-destructive/30 bg-destructive/15 text-destructive"
: "border-transparent bg-secondary text-[color:var(--chart-2)]"
}
>
{delta}
</Badge>
) : null}
</div>
</div>
<div className="flex flex-col items-end gap-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<PixelSwatch color={colorVar(primaryKey)} />
{config[primaryKey]?.label ?? primaryKey}
</span>
<span className="flex items-center gap-1.5">
<PixelSwatch color={colorVar(compareKey)} />
{config[compareKey]?.label ?? compareKey}
</span>
</div>
</div>
<ChartContainer isLoading={isLoading} loadingVariant="line" reaction={reaction} config={config} data={data} className="mt-4 h-44 w-full" variant="plain">
<ComparisonBody
data={data}
xDataKey={xDataKey}
primaryKey={primaryKey}
compareKey={compareKey}
/>
</ChartContainer>
</ChartSkeleton>
</Card>
);
}
function ComparisonBody({
data,
xDataKey,
primaryKey,
compareKey,
}: {
data: Record<string, unknown>[];
xDataKey: string;
primaryKey: string;
compareKey: string;
}) {
const id = React.useId().replace(/:/g, "");
const [hovered, setHovered] = React.useState<string>();
const interaction = (key: string) => ({
onMouseEnter: () => setHovered(key),
onMouseLeave: () => setHovered(undefined),
onFocus: () => setHovered(key),
onBlur: () => setHovered(undefined),
tabIndex: 0,
"aria-label": key,
className: "transition-opacity duration-150 motion-reduce:transition-none [&_.recharts-curve]:transition-opacity [&_.recharts-curve]:duration-150 motion-reduce:[&_.recharts-curve]:transition-none",
opacity: hovered && hovered !== key ? 0.6 : 1,
});
return (
<ResponsiveContainer width="100%" height="100%">
<RechartsAreaChart data={data} margin={{ top: 8, right: 4, left: 0, bottom: 0 }}>
<defs>
<GradientFill id={`${id}-${primaryKey}`} color={colorVar(primaryKey)} />
<GradientFill id={`${id}-${compareKey}`} color={colorVar(compareKey)} />
</defs>
<CartesianGrid vertical={false} stroke="var(--border)" strokeDasharray="3 3" />
<XAxis
dataKey={xDataKey}
tickLine={false}
axisLine={false}
tick={{ fill: "var(--muted-foreground)", fontSize: 11 }}
/>
<Tooltip
cursor={false}
content={<ChartTooltipContent />}
/>
<Area
shape={InteractiveAreaShape}
type="monotone"
dataKey={compareKey}
{...interaction(compareKey)}
activeDot={{ ...interaction(compareKey), r: 4 }}
stroke={colorVar(compareKey)}
fill={`url(#${id}-${compareKey})`}
fillOpacity={1}
strokeWidth={1.5}
/>
<Area
shape={InteractiveAreaShape}
type="monotone"
dataKey={primaryKey}
{...interaction(primaryKey)}
activeDot={{ ...interaction(primaryKey), r: 4 }}
stroke={colorVar(primaryKey)}
fill={`url(#${id}-${primaryKey})`}
fillOpacity={1}
strokeWidth={2}
/>
</RechartsAreaChart>
</ResponsiveContainer>
);
}
export const ComparisonChart = Object.assign(ComparisonChartRoot, {});