- Accordion
- Agent Thinking
- Alert
- Alert Dialog
- Announcement
- Aspect Ratio
- Attachment
- Avatar
- Badge
- Breadcrumb
- Bubble
- Button
- Button Group
- Calendar
- Card
- Carousel
- Checkbox
- Checkbox Card
- Code Block
- Collapsible
- Color Picker
- Combobox
- Command
- Composer
- Composer Loader
- Context Menu
- Data Grid
- Data Table
- Date Picker
- Dialog
- Direction
- Drawer
- Dropdown Menu
- Empty
- Field
- File Upload
- Hover Card
- Input
- Input Group
- Input Otp
- Item
- Kbd
- Label
- Marker
- Menubar
- Message
- Message Scroller
- Meter
- Native Select
- Navigation Menu
- Number Field
- Pagination
- Period Switcher
- Popover
- Progress
- Questionnaire
- Radio Card
- Radio Group
- Resizable
- Reveal
- Scroll Area
- Search Field
- Select
- Separator
- Sheet
- Sidebar
- Skeleton
- Slider
- Social Button
- Sonner
- Spinner
- Status Dot
- Switch
- Switch Card
- Table
- Tabs
- Tag Group
- Textarea
- Theme Toggle
- Time Field
- Toggle
- Toggle Group
- Tooltip
- Trace
- Tree View
- Agent Creator 01
- Agent Limits Card 01
- Agent Progress 01
- AI Chat 01
- AI Image Generation 01
- AI Profile 01
- Alerts Card 01
- Auth Card 01
- Calendar 01
- Composer 01
- Composer Attachments 01
- Composer Panel 01
- Finance Dashboard 01
- Home Dashboard 01
- Marketing Dashboard 01
- Medical Profile 01
- Meeting Scheduler 01
- Multi Agent Chat 01
- Notification Center 01
- Patient Info Card 01
- Project Board 01
- Questionnaire 01
- Settings Modal 01
- Sidebar 01
- Stat Cards 01
- Task List 01
- Web Search 01
- Activity Rings Card 01
- Area Chart Card 01
- Bar List Card 01
- Combo Chart Card 01
- Contributions Card 01
- Earnings Chart Card 01
- Funnel Chart Card 01
- Heatmap Chart Card 01
- Line Chart Card 01
- Most Active Days Card 01
- Orders Chart Card 01
- Radar Chart Card 01
- Radial Chart Card 01
- Revenue Chart Card 01
- Sankey Chart Card 01
- Scatter Chart Card 01
- Sleep Score Card 01
- Stage Bars Card 01
- Steps Card 01
Sessions
69,600
+19.8%Direct
35,700Search
19,500Partners
14,400Sessions
69,600
+19.8%Direct
35,700Search
19,500Partners
14,400Sessions
69,600
+19.8%Direct
35,700Search
19,500Partners
14,400Usage
Supply rows with a label and a numeric value for each series. The card supports stacked, overlap, and percent variants, plus smooth or sharp lines. Set tiles to show the totals in separate tiles. Optional ranges provide a period selector; pass popupClassName when the popup needs a scoped theme.
Source
"use client";
import { RiCalendarLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import {
Area,
AreaChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { useCountUp } from "@/components/hooks/use-count-up";
import { useReducedMotion } from "@/components/hooks/use-reduced-motion";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardAction,
CardHeader,
} from "@/components/ui/card";
import { ChartActiveDot, ChartContainer } from "@/components/ui/chart";
import { Reveal } from "@/components/ui/reveal";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export interface AreaChartPoint {
label: string;
[key: string]: string | number;
}
export interface AreaChartSeries {
key: string;
label: string;
color?: string;
}
export interface AreaChartRange {
id: string;
label: string;
data: AreaChartPoint[];
delta?: number;
}
const format = (value: number) =>
value.toLocaleString("en-US", { maximumFractionDigits: 0 });
const CHART_MARGIN = { top: 4, right: 4, left: 0, bottom: 0 };
const TICK = { fontSize: 11 };
const CURSOR = {
stroke: "var(--border-strong, var(--border))",
strokeWidth: 1,
strokeDasharray: "4 4",
};
const PERCENT_DOMAIN: [number, number] = [0, 100];
const AUTO_DOMAIN: [number, string] = [0, "auto"];
const renderNothing = () => null;
/** A number that rolls to its value without re-rendering anything around it. */
function CountText({ value }: { value: number }) {
const ref = useCountUp<HTMLSpanElement>(value, { format });
return <span ref={ref}>{format(value)}</span>;
}
/**
* The plot is memoised on purpose: hovering re-renders the card (the headline
* follows the hovered point), and re-rendering the chart with fresh props would
* restart the draw.
*/
const AreaPlot = React.memo(function AreaPlot({
chartData,
series,
colors,
variant,
shape,
gradientId,
reduced,
onActiveIndexChange,
}: {
chartData: AreaChartPoint[];
series: AreaChartSeries[];
colors: string[];
variant: "stacked" | "overlap" | "percent";
shape: "smooth" | "sharp";
gradientId: string;
reduced: boolean;
onActiveIndexChange: (index: number | null) => void;
}) {
const activeDots = React.useMemo(
() => colors.map((color) => <ChartActiveDot key={color} color={color} />),
[colors]
);
const formatTick = React.useCallback(
(value: number) =>
variant === "percent"
? `${value}%`
: value >= 1000
? `${Number((value / 1000).toFixed(1))}K`
: String(value),
[variant]
);
return (
<ChartContainer config={{}} className="aspect-auto h-[165px] w-full">
<AreaChart
data={chartData}
margin={CHART_MARGIN}
onMouseMove={(state) => {
const index = Number(state?.activeTooltipIndex);
if (state?.isTooltipActive && Number.isFinite(index)) {
onActiveIndexChange(index);
}
}}
onMouseLeave={() => onActiveIndexChange(null)}
>
<defs>
{series.map((item, index) => (
<linearGradient
key={item.key}
id={`${gradientId}-${index}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop offset="0%" stopColor={colors[index]} stopOpacity={0.35} />
<stop
offset="100%"
stopColor={colors[index]}
stopOpacity={0.02}
/>
</linearGradient>
))}
</defs>
<CartesianGrid
vertical={false}
stroke="var(--border)"
strokeDasharray="2 4"
/>
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
interval="preserveStartEnd"
tick={TICK}
/>
<YAxis
width={40}
tickLine={false}
axisLine={false}
tick={TICK}
domain={variant === "percent" ? PERCENT_DOMAIN : AUTO_DOMAIN}
tickFormatter={formatTick}
/>
<Tooltip content={renderNothing} cursor={CURSOR} />
{series.map((item, index) => (
<Area
key={item.key}
dataKey={item.key}
type={shape === "sharp" ? "linear" : "monotone"}
stackId={variant === "overlap" ? undefined : "area"}
stroke={colors[index]}
strokeWidth={2}
fill={`url(#${gradientId}-${index})`}
activeDot={activeDots[index]}
isAnimationActive={!reduced}
animationDuration={450}
/>
))}
</AreaChart>
</ChartContainer>
);
});
export function AreaChartCard({
title = "Activity",
data,
series,
ranges,
delta,
variant = "stacked",
shape = "smooth",
tiles = false,
popupClassName,
className,
}: {
title?: string;
data: AreaChartPoint[];
series: AreaChartSeries[];
ranges?: AreaChartRange[];
delta?: number;
variant?: "stacked" | "overlap" | "percent";
shape?: "smooth" | "sharp";
tiles?: boolean;
popupClassName?: string;
className?: string;
}) {
const [rangeId, setRangeId] = React.useState(ranges?.[0]?.id);
const [activeIndex, setActiveIndex] = React.useState<number | null>(null);
const reduced = useReducedMotion();
const id = React.useId().replaceAll(":", "");
const range = ranges?.find((item) => item.id === rangeId);
const rows = range?.data ?? data;
const change = range?.delta ?? delta;
const totals = series.map((item) =>
rows.reduce((sum, row) => sum + (Number(row[item.key]) || 0), 0)
);
const point = activeIndex === null ? undefined : rows[activeIndex];
const values = point
? series.map((item) => Number(point[item.key]) || 0)
: totals;
const total = values.reduce((sum, value) => sum + value, 0);
const totalRef = useCountUp<HTMLParagraphElement>(total, { format });
const chartData = React.useMemo(
() =>
variant === "percent"
? rows.map((row) => {
const sum = series.reduce(
(value, item) => value + (Number(row[item.key]) || 0),
0
);
return {
...row,
...Object.fromEntries(
series.map((item) => [
item.key,
sum ? ((Number(row[item.key]) || 0) / sum) * 100 : 0,
])
),
};
})
: rows,
[rows, series, variant]
);
const colors = React.useMemo(
() =>
series.map(
(item, index) => item.color ?? `var(--chart-${(index % 5) + 1})`
),
[series]
);
return (
<Reveal
variant="card"
render={<Card variant="muted" className={cn("min-w-0", className)} />}
>
<CardHeader className="min-w-0">
<div className="min-w-0">
<p className="text-sm leading-5 font-medium text-muted-foreground">
{point?.label ?? title}
</p>
<div className="flex items-center gap-2">
<p
ref={totalRef}
className="text-2xl leading-8 font-medium tabular-nums"
>
{format(total)}
</p>
{!point && change !== undefined && (
<Badge variant="label" tone={change >= 0 ? "lime" : "rose"}>
{change > 0 ? "+" : ""}
{(change * 100).toFixed(1)}%
</Badge>
)}
</div>
</div>
<CardAction>
{ranges && ranges.length > 0 && (
<Select
items={ranges.map((item) => ({
value: item.id,
label: item.label,
}))}
value={rangeId}
onValueChange={(value) => {
if (value !== null) {
setRangeId(value);
setActiveIndex(null);
}
}}
>
<SelectTrigger
size="sm"
// oxlint-disable-next-line shadcn/no-restyle -- reference: period picker keeps 14px text at 32px height (measured 121px wide)
className="data-[size=sm]:text-sm data-[size=sm]:leading-5"
aria-label="Time period"
>
<RiCalendarLine className="size-4 text-muted-foreground" />
<SelectValue />
</SelectTrigger>
<SelectContent
className={popupClassName}
align="end"
alignItemWithTrigger={false}
>
<SelectGroup>
{ranges.map((item) => (
<SelectItem key={item.id} value={item.id}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)}
</CardAction>
</CardHeader>
<CardContent>
<AreaPlot
chartData={chartData}
series={series}
colors={colors}
variant={variant}
shape={shape}
gradientId={id}
reduced={reduced}
onActiveIndexChange={setActiveIndex}
/>
<div
className={cn(
"flex flex-wrap justify-center gap-3",
tiles &&
"grid grid-cols-[repeat(auto-fit,minmax(min(100%,7rem),1fr))] gap-2"
)}
>
{series.map((item, index) => (
<div
key={item.key}
className={cn(
"flex min-w-0 items-center gap-1.5 text-xs",
tiles &&
"min-w-0 flex-col items-start gap-0 rounded-[8px] bg-background p-2"
)}
>
<div className="flex min-w-0 items-center gap-1.5 text-muted-foreground">
<span
aria-hidden
className="size-2.5 shrink-0 rounded-[3px] bg-(--series-color)"
style={
{ "--series-color": colors[index] } as React.CSSProperties
}
/>
<span className="break-words">{item.label}</span>
</div>
<span className="font-medium tabular-nums">
<CountText value={values[index] ?? 0} />
</span>
</div>
))}
</div>
</CardContent>
</Reveal>
);
}