- 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
Output per team
744
+8.2%North · avg
268
Central · avg
787
South · avg
1,393
Output per team
744
+8.2%North268
Central787
South1,393
Usage
Pass groups of points with x and y values. Add z to size bubbles, or set bubble={false} to use equal dots. Set tiles={false} for a compact legend. The optional ranges prop supplies alternate datasets for the menu; popupClassName scopes the theme to its popup.
Source
"use client";
import { RiCalendarLine, RiArrowDownSLine } from "@remixicon/react";
import { cn } from "cn";
import * as React from "react";
import { useCountUp } from "@/components/hooks/use-count-up";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Reveal } from "@/components/ui/reveal";
export interface ScatterPoint {
x: number;
y: number;
z?: number;
label?: string;
}
export interface ScatterSeries {
label: string;
points: ScatterPoint[];
color?: string;
}
export interface ScatterRange {
label: string;
series: ScatterSeries[];
}
const colors = [
"var(--revenue-chart-active, var(--chart-1))",
"var(--primary)",
"var(--chart-5)",
];
const compact = (n: number) =>
n >= 1000 ? `${Number((n / 1000).toFixed(1))}k` : String(Math.round(n));
const average = (points: ScatterPoint[]) =>
points.length
? points.reduce((sum, point) => sum + point.y, 0) / points.length
: 0;
const axisMax = (value: number) => {
const scale = 10 ** Math.floor(Math.log10(value));
return Math.ceil(value / scale) * scale;
};
// The y axis has three intervals, so round its maximum up to a multiple of
// three nice steps (15 for a value of 14, with ticks 0, 5, 10, 15).
const axisMaxThirds = (value: number) => {
const raw = value / 3;
const scale = 10 ** Math.floor(Math.log10(raw));
const unit = [1, 2, 5, 10].find((n) => n * scale >= raw) ?? 10;
return unit * scale * 3;
};
/** A number that rolls to its value without re-rendering anything around it. */
function AnimatedValue({
value,
format,
}: {
value: number;
format: (value: number) => string;
}) {
const ref = useCountUp<HTMLSpanElement>(value, { format, precision: 2 });
return <span ref={ref}>{format(value)}</span>;
}
export function ScatterChartCard({
title,
series,
ranges,
axisLabels = ["X", "Y"],
bubble,
tiles = true,
change,
format = (n) => Math.round(n).toLocaleString(),
formatX = compact,
popupClassName,
className,
}: {
title: string;
series: ScatterSeries[];
ranges?: ScatterRange[];
axisLabels?: [string, string];
bubble?: boolean;
tiles?: boolean;
change?: string;
format?: (value: number) => string;
formatX?: (value: number) => string;
popupClassName?: string;
className?: string;
}) {
const [range, setRange] = React.useState(0);
// The plot is drawn at the card's real width, so its text keeps its size
// instead of shrinking with a scaled viewBox.
const plotRef = React.useRef<HTMLDivElement>(null);
const [width, setWidth] = React.useState(448);
React.useEffect(() => {
const node = plotRef.current;
if (!node) {
return;
}
const measure = () => setWidth(Math.max(240, Math.round(node.clientWidth)));
measure();
const observer = new ResizeObserver(measure);
observer.observe(node);
return () => observer.disconnect();
}, []);
const plotL = 40;
const plotR = width - 20;
const plotW = plotR - plotL;
const step = plotW / 4;
const [active, setActive] = React.useState<{
series: number;
point: number;
} | null>(null);
const data = ranges?.[range]?.series ?? series;
const all = data.flatMap((group) => group.points);
const selected = active && data[active.series]?.points[active.point];
const maxX = Math.max(1, ...all.map((point) => point.x));
const maxY = Math.max(1, ...all.map((point) => point.y));
const xMax = axisMax(maxX);
const yMax = axisMaxThirds(maxY);
const maxZ = Math.max(1, ...all.map((point) => point.z ?? 0));
const showBubbles = bubble ?? all.some((point) => point.z != null);
return (
<Reveal
variant="card"
render={
<Card variant="muted" className={cn("w-full min-w-0", className)} />
}
>
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="truncate text-sm leading-5 font-medium text-muted-foreground">
{selected?.label ?? title}
</p>
<div className="flex items-center gap-2">
<p className="text-2xl leading-8.5 font-medium tabular-nums">
<AnimatedValue
value={selected?.y ?? average(all)}
format={format}
/>
</p>
{change && !selected && (
<Badge variant="counter" tone="lime">
{change}
</Badge>
)}
</div>
</div>
{ranges && ranges.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
render={<Button variant="outline" size="sm" />}
>
<RiCalendarLine data-icon="inline-start" />
{ranges[range]?.label}
<RiArrowDownSLine
data-icon="inline-end"
className="transition-transform duration-150 ease-out group-aria-expanded/button:rotate-180 motion-reduce:transition-none"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className={popupClassName}>
<DropdownMenuGroup>
{ranges.map((option, index) => (
<DropdownMenuItem
key={option.label}
onClick={() => {
setRange(index);
setActive(null);
}}
>
{option.label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</CardHeader>
<CardContent ref={plotRef} onMouseLeave={() => setActive(null)}>
<svg
viewBox={`-8 0 ${String(width + 8)} 180`}
className="h-auto w-full overflow-visible"
role="group"
aria-label={`${title}: ${axisLabels[0]} by ${axisLabels[1]}`}
>
<text x="0" y="173" fill="var(--muted-foreground)" fontSize="11">
{axisLabels[1]}
</text>
<text
x={plotR}
y="173"
textAnchor="end"
fill="var(--muted-foreground)"
fontSize="11"
>
{axisLabels[0]}
</text>
{[0, 1, 2, 3].map((i) => (
<g key={`y${i}`}>
<line
x1="40"
x2={plotR}
y1={130 - i * 38}
y2={130 - i * 38}
stroke="var(--border)"
strokeDasharray="2 4"
/>
<text
x="33"
y={133 - i * 38}
textAnchor="end"
fill="var(--muted-foreground)"
fontSize="11"
>
{compact((yMax * i) / 3)}
</text>
</g>
))}
{[0, 1, 2, 3, 4].map((i) => (
<g key={`x${i}`}>
<line
x1={plotL + i * step}
x2={plotL + i * step}
y1="16"
y2="130"
stroke="var(--border)"
strokeDasharray="2 4"
/>
<text
x={plotL + i * step}
y="152"
textAnchor="middle"
fill="var(--muted-foreground)"
fontSize="11"
>
{formatX((xMax * i) / 4)}
</text>
</g>
))}
{data.map((group, si) =>
group.points.map((point, pi) => (
<circle
key={`${si}-${pi}`}
cx={plotL + (Math.max(0, point.x) / xMax) * plotW}
cy={130 - (Math.max(0, point.y) / yMax) * 114}
r={
showBubbles
? 6 + Math.sqrt(Math.max(0, point.z ?? 0) / maxZ) * 8
: 4
}
fill={group.color ?? colors[si % colors.length]}
stroke={group.color ?? colors[si % colors.length]}
strokeWidth="1.5"
fillOpacity="0.85"
opacity={active && active.series !== si ? 0.25 : 1}
tabIndex={0}
onMouseDown={(event) => event.preventDefault()}
role="img"
aria-label={`${point.label ?? group.label}: ${axisLabels[0]} ${formatX(point.x)}, ${axisLabels[1]} ${format(point.y)}`}
onMouseEnter={() => setActive({ series: si, point: pi })}
onFocus={() => setActive({ series: si, point: pi })}
onBlur={() => setActive(null)}
className="scatter-chart-card-01-point-in origin-center [transform-box:fill-box] outline-none transition-opacity duration-150 focus-visible:stroke-ring focus-visible:stroke-2 motion-reduce:transition-none"
/>
))
)}
{selected && active && (
<circle
key={`${active.series}-${active.point}`}
cx={plotL + (Math.max(0, selected.x) / xMax) * plotW}
cy={130 - (Math.max(0, selected.y) / yMax) * 114}
r={
showBubbles
? 6 + Math.sqrt(Math.max(0, selected.z ?? 0) / maxZ) * 8
: 4
}
fill={
data[active.series]?.color ??
colors[active.series % colors.length]
}
pointerEvents="none"
aria-hidden
className="scatter-chart-card-01-pulse origin-center [transform-box:fill-box]"
/>
)}
</svg>
</CardContent>
<CardContent>
<div
className={cn(
"flex flex-wrap gap-2",
!tiles && "justify-center gap-4"
)}
>
{data.map((group, i) => (
<div
key={group.label}
className={cn(
"min-w-0 text-xs",
tiles
? "min-w-0 flex-[1_1_120px] rounded-[8px] bg-card px-3 py-2"
: ""
)}
>
<div
className={cn(
"flex items-center gap-1.5",
!tiles && "flex-wrap"
)}
>
<svg
viewBox="0 0 10 10"
className="size-2.5 shrink-0"
aria-hidden
>
<rect
width="10"
height="10"
rx="3"
fill={group.color ?? colors[i % colors.length]}
/>
</svg>
<span className="min-w-0 break-words text-muted-foreground">
{group.label}
{tiles && " · avg"}
</span>
{!tiles && (
<span className="font-medium tabular-nums">
<AnimatedValue
value={average(group.points)}
format={format}
/>
</span>
)}
</div>
{tiles && (
<div className="mt-1 font-medium tabular-nums">
<AnimatedValue
value={average(group.points)}
format={format}
/>
</div>
)}
</div>
))}
</div>
</CardContent>
</Reveal>
);
}