diff --git a/apps/docs/docgen.config.js b/apps/docs/docgen.config.js
index 6139a942f..e0ae84a67 100644
--- a/apps/docs/docgen.config.js
+++ b/apps/docs/docgen.config.js
@@ -74,7 +74,9 @@ module.exports = {
'chart/area/AreaChart',
'chart/bar/BarChart',
'chart/CartesianChart',
+ 'chart/ChartTooltip',
'chart/DonutChart',
+ 'chart/legend/Legend',
'chart/line/LineChart',
'chart/pie/PieChart',
'chart/PolarChart',
diff --git a/apps/docs/docs/components/graphs/ChartTooltip/_webExamples.mdx b/apps/docs/docs/components/graphs/ChartTooltip/_webExamples.mdx
new file mode 100644
index 000000000..d07431ca1
--- /dev/null
+++ b/apps/docs/docs/components/graphs/ChartTooltip/_webExamples.mdx
@@ -0,0 +1,404 @@
+ChartTooltip displays series data at the current scrubber position in a floating tooltip. It automatically shows all series values, supports custom formatting, and positions itself near the cursor.
+
+## Basic Usage
+
+Add ChartTooltip as a child of any chart with `enableScrubbing` to display values when hovering. The tooltip label defaults to the x-axis value.
+
+```jsx live
+function BasicTooltip() {
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+ const revenue = [120, 150, 180, 165, 190, 210];
+ const expenses = [80, 95, 110, 105, 120, 130];
+
+ return (
+
+
+
+
+ );
+}
+```
+
+## Value Formatter
+
+Use the `valueFormatter` prop to customize how values are displayed. The formatter receives the numeric value and should return a string or ReactNode.
+
+```jsx live
+function ValueFormatterTooltip() {
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+ const sales = [45000, 52000, 48000, 61000, 55000, 67000];
+ const profit = [12000, 15000, 11000, 18000, 14000, 21000];
+
+ const currencyFormatter = useCallback(
+ (value) =>
+ new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ maximumFractionDigits: 0,
+ }).format(value),
+ [],
+ );
+
+ return (
+
+
+
+
+ );
+}
+```
+
+## Custom Label
+
+Use the `label` prop to customize the tooltip header. It can be a static string, ReactNode, or a function that receives the data index.
+
+```jsx live
+function CustomLabelTooltip() {
+ const dates = ['2024-01', '2024-02', '2024-03', '2024-04', '2024-05', '2024-06'];
+ const displayDates = [
+ 'January 2024',
+ 'February 2024',
+ 'March 2024',
+ 'April 2024',
+ 'May 2024',
+ 'June 2024',
+ ];
+ const temperature = [32, 35, 45, 58, 68, 78];
+ const humidity = [65, 60, 55, 50, 55, 60];
+
+ return (
+
+
+ (
+
+ {displayDates[dataIndex]}
+
+ )}
+ />
+
+ );
+}
+```
+
+## Filtered Series
+
+Use the `seriesIds` prop to show only specific series in the tooltip. This is useful when you want to hide certain series from the tooltip while still displaying them on the chart.
+
+```jsx live
+function FilteredSeriesTooltip() {
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+ const primary = [100, 120, 140, 130, 150, 170];
+ const secondary = [60, 70, 80, 75, 85, 95];
+ const tertiary = [30, 35, 40, 38, 42, 48];
+
+ return (
+
+
+
+
+ );
+}
+```
+
+## With Bar Chart
+
+ChartTooltip works with bar charts and other chart types.
+
+```jsx live
+function BarChartTooltip() {
+ const categories = ['Q1', 'Q2', 'Q3', 'Q4'];
+ const revenue = [250, 320, 280, 410];
+ const costs = [180, 220, 200, 280];
+
+ const numberFormatter = useCallback((value) => `$${value}k`, []);
+
+ return (
+
+
+
+
+
+
+ );
+}
+```
+
+## Stacked Area Tooltip
+
+When used with stacked charts, ChartTooltip shows individual series values rather than cumulative values.
+
+```jsx live
+function StackedAreaTooltip() {
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'];
+ const desktop = [4000, 4200, 3800, 4500, 4800, 5200, 5000, 5500];
+ const mobile = [2400, 2800, 3000, 3200, 3500, 3800, 4000, 4200];
+ const tablet = [1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900];
+
+ const numberFormatter = useCallback((value) => value.toLocaleString(), []);
+
+ return (
+
+
+
+
+ );
+}
+```
+
+## Custom Value Display
+
+The `valueFormatter` can return a ReactNode for rich value display, including icons, colors, and additional information.
+
+```jsx live
+function CustomValueDisplayTooltip() {
+ const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
+ const steps = [8500, 12000, 7200, 9800, 11500, 15000, 6500];
+ const goal = 10000;
+
+ const stepsFormatter = useCallback((value) => {
+ const percentage = Math.round((value / goal) * 100);
+ const isGoalMet = value >= goal;
+ return (
+
+
+ {value.toLocaleString()} steps
+
+
+ ({percentage}%)
+
+
+ );
+ }, []);
+
+ return (
+
+
+
+
+ );
+}
+```
+
+## Multi-Axis Tooltip
+
+ChartTooltip works with charts that have multiple y-axes, showing values from all series.
+
+```jsx live
+function MultiAxisTooltip() {
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+ const revenue = [455, 520, 380, 455, 285, 235];
+ const profitMargin = [23, 20, 16, 38, 12, 9];
+
+ return (
+
+
+ `$${value}k`}
+ />
+ `${value}%`}
+ />
+
+ {
+ return value < 100 ? `${value}%` : `$${value}k`;
+ }}
+ />
+
+ );
+}
+```
+
+## With Legend
+
+ChartTooltip can be combined with Legend to provide both persistent series identification and on-hover details.
+
+```jsx live
+function TooltipWithLegend() {
+ const precipitationData = [
+ {
+ id: 'northeast',
+ label: 'Northeast',
+ data: [5.14, 1.53, 5.73, 4.29, 3.78, 3.92, 4.19, 5.54, 2.03, 1.42, 2.95, 3.89],
+ color: 'rgb(var(--blue40))',
+ },
+ {
+ id: 'upperMidwest',
+ label: 'Upper Midwest',
+ data: [1.44, 0.49, 2.16, 3.67, 5.44, 6.21, 4.02, 3.67, 0.92, 1.47, 3.05, 1.48],
+ color: 'rgb(var(--green40))',
+ },
+ {
+ id: 'southwest',
+ label: 'Southwest',
+ data: [1.12, 1.5, 1.52, 0.75, 0.76, 1.27, 1.44, 2.01, 0.62, 1.08, 1.23, 0.25],
+ color: 'rgb(var(--purple40))',
+ },
+ ];
+
+ const xAxisData = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ];
+
+ return (
+
+
+ 2024 Precipitation by Climate Region
+
+
+
+ `${value} in`} />
+
+
+ );
+}
+```
diff --git a/apps/docs/docs/components/graphs/ChartTooltip/_webPropsTable.mdx b/apps/docs/docs/components/graphs/ChartTooltip/_webPropsTable.mdx
new file mode 100644
index 000000000..e17542521
--- /dev/null
+++ b/apps/docs/docs/components/graphs/ChartTooltip/_webPropsTable.mdx
@@ -0,0 +1,11 @@
+import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable';
+
+import webPropsData from ':docgen/web-visualization/chart/ChartTooltip/data';
+import { sharedParentTypes } from ':docgen/_types/sharedParentTypes';
+import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases';
+
+
diff --git a/apps/docs/docs/components/graphs/ChartTooltip/index.mdx b/apps/docs/docs/components/graphs/ChartTooltip/index.mdx
new file mode 100644
index 000000000..8e8549f09
--- /dev/null
+++ b/apps/docs/docs/components/graphs/ChartTooltip/index.mdx
@@ -0,0 +1,27 @@
+---
+id: chartTooltip
+title: ChartTooltip
+platform_switcher_options: { web: true, mobile: false }
+hide_title: true
+---
+
+import { VStack } from '@coinbase/cds-web/layout';
+
+import { ComponentHeader } from '@site/src/components/page/ComponentHeader';
+import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer';
+
+import webPropsToc from ':docgen/web-visualization/chart/ChartTooltip/toc-props';
+
+import WebPropsTable from './_webPropsTable.mdx';
+import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx';
+import webMetadata from './webMetadata.json';
+
+
+
+ }
+ webExamples={}
+ webExamplesToc={webExamplesToc}
+ webPropsToc={webPropsToc}
+ />
+
diff --git a/apps/docs/docs/components/graphs/ChartTooltip/webMetadata.json b/apps/docs/docs/components/graphs/ChartTooltip/webMetadata.json
new file mode 100644
index 000000000..6efbcabee
--- /dev/null
+++ b/apps/docs/docs/components/graphs/ChartTooltip/webMetadata.json
@@ -0,0 +1,29 @@
+{
+ "import": "import { ChartTooltip } from '@coinbase/cds-web-visualization'",
+ "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/ChartTooltip.tsx",
+ "description": "A tooltip component for displaying series data at the current scrubber position. Supports custom labels, value formatting, and series filtering.",
+ "relatedComponents": [
+ {
+ "label": "CartesianChart",
+ "url": "/components/graphs/CartesianChart/"
+ },
+ {
+ "label": "Legend",
+ "url": "/components/graphs/Legend/"
+ },
+ {
+ "label": "LineChart",
+ "url": "/components/graphs/LineChart/"
+ },
+ {
+ "label": "Scrubber",
+ "url": "/components/graphs/Scrubber/"
+ }
+ ],
+ "dependencies": [
+ {
+ "name": "framer-motion",
+ "version": "^10.18.0"
+ }
+ ]
+}
diff --git a/apps/docs/docs/components/graphs/Legend/_mobileExamples.mdx b/apps/docs/docs/components/graphs/Legend/_mobileExamples.mdx
new file mode 100644
index 000000000..128fc0019
--- /dev/null
+++ b/apps/docs/docs/components/graphs/Legend/_mobileExamples.mdx
@@ -0,0 +1,296 @@
+Legend displays series information for charts, showing labels and color indicators for each data series. It can be positioned around the chart and supports custom shapes and item components.
+
+## Basic Usage
+
+Use the `legend` prop on chart components to enable a default legend, or pass a `Legend` component for customization.
+
+```tsx
+function BasicLegend() {
+ const theme = useTheme();
+ const pages = useMemo(
+ () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'],
+ [],
+ );
+ const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []);
+ const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []);
+
+ return (
+
+
+
+ );
+}
+```
+
+## Shape Variants
+
+Legend supports different shape variants: `pill`, `circle`, `square`, and `squircle`. Set the shape on each series using `legendShape`.
+
+```tsx
+function ShapeVariants() {
+ const theme = useTheme();
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+
+ return (
+
+ }
+ legendPosition="bottom"
+ series={[
+ {
+ id: 'pill',
+ label: 'Pill',
+ data: [120, 150, 130, 170, 160, 190],
+ color: `rgb(${theme.spectrum.blue40})`,
+ legendShape: 'pill',
+ },
+ {
+ id: 'circle',
+ label: 'Circle',
+ data: [80, 110, 95, 125, 115, 140],
+ color: `rgb(${theme.spectrum.green40})`,
+ legendShape: 'circle',
+ },
+ {
+ id: 'square',
+ label: 'Square',
+ data: [60, 85, 70, 100, 90, 115],
+ color: `rgb(${theme.spectrum.orange40})`,
+ legendShape: 'square',
+ },
+ {
+ id: 'squircle',
+ label: 'Squircle',
+ data: [40, 60, 50, 75, 65, 85],
+ color: `rgb(${theme.spectrum.purple40})`,
+ legendShape: 'squircle',
+ },
+ ]}
+ width="100%"
+ xAxis={{ data: months }}
+ />
+
+ );
+}
+```
+
+## Layout Direction
+
+Use the `direction` prop to arrange legend items in a row or column.
+
+```tsx
+function LayoutDirection() {
+ const theme = useTheme();
+
+ const series = [
+ {
+ id: 'stocks',
+ data: 45,
+ label: 'Stocks',
+ color: `rgb(${theme.spectrum.blue40})`,
+ legendShape: 'circle' as const,
+ },
+ {
+ id: 'bonds',
+ data: 25,
+ label: 'Bonds',
+ color: `rgb(${theme.spectrum.green40})`,
+ legendShape: 'circle' as const,
+ },
+ {
+ id: 'realEstate',
+ data: 15,
+ label: 'Real Estate',
+ color: `rgb(${theme.spectrum.orange40})`,
+ legendShape: 'circle' as const,
+ },
+ {
+ id: 'commodities',
+ data: 10,
+ label: 'Commodities',
+ color: `rgb(${theme.spectrum.purple40})`,
+ legendShape: 'circle' as const,
+ },
+ {
+ id: 'cash',
+ data: 5,
+ label: 'Cash',
+ color: `rgb(${theme.spectrum.gray40})`,
+ legendShape: 'circle' as const,
+ },
+ ];
+
+ return (
+
+ Portfolio Allocation
+ }
+ legendPosition="right"
+ series={series}
+ width={300}
+ />
+
+ );
+}
+```
+
+## Donut Chart Legend
+
+Legend works with donut and pie charts to show segment information.
+
+```tsx
+function DonutChartLegend() {
+ const theme = useTheme();
+
+ const series = [
+ {
+ id: 'completed',
+ data: 68,
+ label: 'Completed',
+ color: theme.color.fgPositive,
+ legendShape: 'squircle' as const,
+ },
+ {
+ id: 'inProgress',
+ data: 22,
+ label: 'In Progress',
+ color: `rgb(${theme.spectrum.blue40})`,
+ legendShape: 'squircle' as const,
+ },
+ {
+ id: 'pending',
+ data: 10,
+ label: 'Pending',
+ color: `rgb(${theme.spectrum.gray40})`,
+ legendShape: 'squircle' as const,
+ },
+ ];
+
+ return (
+
+ Task Status
+
+
+ );
+}
+```
+
+## Custom Legend Item
+
+Use `ItemComponent` to render custom legend items with additional information or interactions.
+
+```tsx
+function CustomLegendItem() {
+ const theme = useTheme();
+
+ const CustomItem = memo(function CustomItem({ label, color, shape }) {
+ return (
+
+
+ {label}
+ 100%
+
+ );
+ });
+
+ return (
+
+ Custom Legend Item
+ }
+ legendPosition="bottom"
+ series={[
+ {
+ id: 'btc',
+ data: 60,
+ label: 'Bitcoin',
+ color: `rgb(${theme.spectrum.orange40})`,
+ },
+ {
+ id: 'eth',
+ data: 30,
+ label: 'Ethereum',
+ color: `rgb(${theme.spectrum.purple40})`,
+ },
+ {
+ id: 'other',
+ data: 10,
+ label: 'Other',
+ color: `rgb(${theme.spectrum.gray40})`,
+ },
+ ]}
+ width={150}
+ />
+
+ );
+}
+```
+
+## DefaultLegendShape
+
+You can use `DefaultLegendShape` directly to create custom legend layouts or displays.
+
+```tsx
+function DefaultShapes() {
+ const theme = useTheme();
+
+ const spectrumColors = [
+ 'blue40',
+ 'green40',
+ 'orange40',
+ 'yellow40',
+ 'gray40',
+ 'indigo40',
+ 'pink40',
+ 'purple40',
+ 'red40',
+ 'teal40',
+ ];
+
+ const shapes: LegendShapeVariant[] = ['pill', 'circle', 'squircle', 'square'];
+
+ return (
+
+ {shapes.map((shape) => (
+
+ {spectrumColors.map((color) => (
+
+
+
+ ))}
+
+ ))}
+
+ );
+}
+```
diff --git a/apps/docs/docs/components/graphs/Legend/_mobilePropsTable.mdx b/apps/docs/docs/components/graphs/Legend/_mobilePropsTable.mdx
new file mode 100644
index 000000000..60808a42b
--- /dev/null
+++ b/apps/docs/docs/components/graphs/Legend/_mobilePropsTable.mdx
@@ -0,0 +1,11 @@
+import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable';
+
+import mobilePropsData from ':docgen/mobile-visualization/chart/legend/Legend/data';
+import { sharedParentTypes } from ':docgen/_types/sharedParentTypes';
+import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases';
+
+
diff --git a/apps/docs/docs/components/graphs/Legend/_webExamples.mdx b/apps/docs/docs/components/graphs/Legend/_webExamples.mdx
new file mode 100644
index 000000000..2c97726e6
--- /dev/null
+++ b/apps/docs/docs/components/graphs/Legend/_webExamples.mdx
@@ -0,0 +1,655 @@
+Legend displays series information for charts, showing labels and color indicators for each data series. It can be positioned around the chart and supports custom shapes and item components.
+
+## Basic Usage
+
+Use the `legend` prop on chart components to enable a default legend, or pass a `Legend` component for customization.
+
+```jsx live
+function BasicLegend() {
+ const pages = useMemo(
+ () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'],
+ [],
+ );
+ const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []);
+ const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []);
+
+ const numberFormatter = useCallback(
+ (value) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value),
+ [],
+ );
+
+ return (
+
+
+
+ );
+}
+```
+
+## Legend Position
+
+Use `legendPosition` to place the legend at different positions around the chart. You can also customize alignment using the `justifyContent` prop on Legend.
+
+```jsx live
+function LegendPosition() {
+ return (
+ }
+ legendPosition="bottom"
+ series={[
+ {
+ id: 'revenue',
+ label: 'Revenue',
+ data: [455, 520, 380, 455, 285, 235],
+ yAxisId: 'revenue',
+ color: 'rgb(var(--yellow40))',
+ legendShape: 'squircle',
+ },
+ {
+ id: 'profitMargin',
+ label: 'Profit Margin',
+ data: [23, 20, 16, 38, 12, 9],
+ yAxisId: 'profitMargin',
+ color: 'var(--color-fgPositive)',
+ legendShape: 'squircle',
+ },
+ ]}
+ xAxis={{
+ data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
+ scaleType: 'band',
+ }}
+ yAxis={[
+ {
+ id: 'revenue',
+ domain: { min: 0 },
+ },
+ {
+ id: 'profitMargin',
+ domain: { max: 100, min: 0 },
+ },
+ ]}
+ >
+
+ `$${value}k`}
+ width={60}
+ />
+ `${value}%`}
+ />
+
+
+ );
+}
+```
+
+## Shape Variants
+
+Legend supports different shape variants: `pill`, `circle`, `square`, and `squircle`. Set the shape on each series using `legendShape`.
+
+```jsx live
+function ShapeVariants() {
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+
+ return (
+
+ );
+}
+```
+
+## Layout Direction
+
+Use the `direction` prop to arrange legend items in a row or column.
+
+```jsx live
+function LayoutDirection() {
+ const series = [
+ {
+ id: 'stocks',
+ data: 45,
+ label: 'Stocks',
+ color: 'rgb(var(--blue40))',
+ legendShape: 'circle',
+ },
+ {
+ id: 'bonds',
+ data: 25,
+ label: 'Bonds',
+ color: 'rgb(var(--green40))',
+ legendShape: 'circle',
+ },
+ {
+ id: 'realEstate',
+ data: 15,
+ label: 'Real Estate',
+ color: 'rgb(var(--orange40))',
+ legendShape: 'circle',
+ },
+ {
+ id: 'commodities',
+ data: 10,
+ label: 'Commodities',
+ color: 'rgb(var(--purple40))',
+ legendShape: 'circle',
+ },
+ {
+ id: 'cash',
+ data: 5,
+ label: 'Cash',
+ color: 'rgb(var(--gray40))',
+ legendShape: 'circle',
+ },
+ ];
+
+ return (
+
+
+ Portfolio Allocation
+
+
+
+ );
+}
+```
+
+## Custom Legend Item
+
+Use `ItemComponent` to render custom legend items with additional information or interactions.
+
+```jsx live
+function CustomLegendItem() {
+ const timeLabels = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ];
+
+ const series = [
+ {
+ id: 'candidate-a',
+ label: 'Candidate A',
+ data: [48, 47, 46, 45, 44, 43, 42, 41, 40, 39, 38, 38],
+ color: 'rgb(var(--blue40))',
+ legendShape: 'circle',
+ },
+ {
+ id: 'candidate-b',
+ label: 'Candidate B',
+ data: [null, null, null, 6, 10, 14, 18, 22, 26, 29, 32, 35],
+ color: 'rgb(var(--orange40))',
+ legendShape: 'circle',
+ },
+ {
+ id: 'candidate-c',
+ label: 'Candidate C',
+ data: [52, 53, 54, 49, 46, 43, 40, 37, 34, 32, 30, 27],
+ color: 'rgb(var(--gray40))',
+ legendShape: 'circle',
+ },
+ ];
+
+ const ValueLegendItem = memo(function ValueLegendItem({ seriesId, label, color, shape }) {
+ const { scrubberPosition } = useScrubberContext();
+ const { series, dataLength } = useCartesianChartContext();
+
+ const dataIndex = scrubberPosition ?? dataLength - 1;
+
+ const seriesData = series.find((s) => s.id === seriesId);
+ const rawValue = seriesData?.data?.[dataIndex];
+
+ const formattedValue =
+ rawValue === null || rawValue === undefined ? '--' : `${Math.round(rawValue)}%`;
+
+ return (
+
+
+ {label}
+
+ {formattedValue}
+
+
+ );
+ });
+
+ return (
+
+
+ Election Polls
+
+ }
+ legendPosition="top"
+ series={series}
+ xAxis={{
+ data: timeLabels,
+ }}
+ yAxis={{
+ domain: { max: 100, min: 0 },
+ showGrid: true,
+ tickLabelFormatter: (value) => `${value}%`,
+ }}
+ >
+
+
+
+ );
+}
+```
+
+## Interactive Legend
+
+Create interactive legends by using custom item components with state management.
+
+```jsx live
+function InteractiveLegend() {
+ const [emphasizedId, setEmphasizedId] = useState(null);
+
+ const months = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ];
+
+ const seriesConfig = useMemo(
+ () => [
+ {
+ id: 'revenue',
+ label: 'Revenue',
+ data: [120, 150, 180, 165, 190, 210, 240, 220, 260, 280, 310, 350],
+ baseColor: '--blue',
+ },
+ {
+ id: 'expenses',
+ label: 'Expenses',
+ data: [80, 95, 110, 105, 120, 130, 145, 140, 155, 165, 180, 195],
+ baseColor: '--orange',
+ },
+ {
+ id: 'profit',
+ label: 'Profit',
+ data: [40, 55, 70, 60, 70, 80, 95, 80, 105, 115, 130, 155],
+ baseColor: '--green',
+ },
+ ],
+ [],
+ );
+
+ const handleToggle = useCallback((seriesId) => {
+ setEmphasizedId((prev) => (prev === seriesId ? null : seriesId));
+ }, []);
+
+ const ChipLegendItem = memo(function ChipLegendItem({ seriesId, label }) {
+ const isEmphasized = emphasizedId === seriesId;
+ const config = seriesConfig.find((s) => s.id === seriesId);
+ const baseColor = config?.baseColor ?? '--gray';
+
+ return (
+ handleToggle(seriesId)}
+ style={{
+ backgroundColor: `rgb(var(${baseColor}10))`,
+ borderWidth: 0,
+ color: 'var(--color-fg)',
+ outlineColor: `rgb(var(${baseColor}50))`,
+ }}
+ >
+
+
+ {label}
+
+
+ );
+ });
+
+ const series = useMemo(() => {
+ return seriesConfig.map((config) => {
+ const isEmphasized = emphasizedId === config.id;
+ const isDimmed = emphasizedId !== null && !isEmphasized;
+
+ return {
+ id: config.id,
+ label: config.label,
+ data: config.data,
+ color: `rgb(var(${config.baseColor}40))`,
+ opacity: isDimmed ? 0.3 : 1,
+ };
+ });
+ }, [emphasizedId, seriesConfig]);
+
+ return (
+
+
+ Financial Overview
+
+ }
+ legendPosition="top"
+ series={series}
+ xAxis={{
+ data: months,
+ }}
+ yAxis={{
+ domain: { min: 0 },
+ showGrid: true,
+ tickLabelFormatter: (value) => `$${value}k`,
+ }}
+ />
+
+ );
+}
+```
+
+## Donut Chart Legend
+
+Legend works with donut and pie charts to show segment information.
+
+```jsx live
+function DonutChartLegend() {
+ const series = [
+ {
+ id: 'completed',
+ data: 68,
+ label: 'Completed',
+ color: 'var(--color-fgPositive)',
+ legendShape: 'squircle',
+ },
+ {
+ id: 'inProgress',
+ data: 22,
+ label: 'In Progress',
+ color: 'rgb(var(--blue40))',
+ legendShape: 'squircle',
+ },
+ {
+ id: 'pending',
+ data: 10,
+ label: 'Pending',
+ color: 'rgb(var(--gray40))',
+ legendShape: 'squircle',
+ },
+ ];
+
+ return (
+
+
+ Task Status
+
+
+
+ );
+}
+```
+
+## Custom Legend Shapes
+
+You can pass a custom ReactNode as `legendShape` for fully custom indicators.
+
+```jsx live
+function CustomLegendShapes () {
+ const months = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ];
+
+ // Actual revenue (first 9 months)
+ const actualRevenue = [320, 380, 420, 390, 450, 480, 520, 490, 540, null, null, null];
+
+ // Forecasted revenue (last 3 months)
+ const forecastRevenue = [null, null, null, null, null, null, null, null, null, 580, 620, 680];
+
+ const numberFormatter = useCallback(
+ (value: number) =>
+ `$${new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value)}k`,
+ [],
+ );
+
+ // Pattern settings for dotted fill
+ const patternSize = 4;
+ const dotSize = 1;
+ const patternId = useId();
+ const maskId = useId();
+ const legendPatternId = useId();
+
+ // Custom legend indicator that matches the dotted bar pattern
+ const DottedLegendIndicator = (
+
+ );
+
+ // Custom bar component that renders bars with dotted pattern fill
+ const DottedBarComponent = memo((props) => {
+ const { dataX, x, y } = props;
+ // Create unique IDs per bar so patterns are scoped to each bar
+ const uniqueMaskId = `${maskId}-${dataX}`;
+ const uniquePatternId = `${patternId}-${dataX}`;
+ return (
+ <>
+
+ {/* Pattern positioned relative to this bar's origin */}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ });
+
+ return (
+
+ );
+};
+```
diff --git a/apps/docs/docs/components/graphs/Legend/_webPropsTable.mdx b/apps/docs/docs/components/graphs/Legend/_webPropsTable.mdx
new file mode 100644
index 000000000..7b21df026
--- /dev/null
+++ b/apps/docs/docs/components/graphs/Legend/_webPropsTable.mdx
@@ -0,0 +1,11 @@
+import ComponentPropsTable from '@site/src/components/page/ComponentPropsTable';
+
+import webPropsData from ':docgen/web-visualization/chart/legend/Legend/data';
+import { sharedParentTypes } from ':docgen/_types/sharedParentTypes';
+import { sharedTypeAliases } from ':docgen/_types/sharedTypeAliases';
+
+
diff --git a/apps/docs/docs/components/graphs/Legend/index.mdx b/apps/docs/docs/components/graphs/Legend/index.mdx
new file mode 100644
index 000000000..4969ce0b7
--- /dev/null
+++ b/apps/docs/docs/components/graphs/Legend/index.mdx
@@ -0,0 +1,35 @@
+---
+id: legend
+title: Legend
+platform_switcher_options: { web: true, mobile: true }
+hide_title: true
+---
+
+import { VStack } from '@coinbase/cds-web/layout';
+
+import { ComponentHeader } from '@site/src/components/page/ComponentHeader';
+import { ComponentTabsContainer } from '@site/src/components/page/ComponentTabsContainer';
+
+import webPropsToc from ':docgen/web-visualization/chart/legend/Legend/toc-props';
+import mobilePropsToc from ':docgen/mobile-visualization/chart/legend/Legend/toc-props';
+
+import WebPropsTable from './_webPropsTable.mdx';
+import MobilePropsTable from './_mobilePropsTable.mdx';
+import WebExamples, { toc as webExamplesToc } from './_webExamples.mdx';
+import MobileExamples, { toc as mobileExamplesToc } from './_mobileExamples.mdx';
+import webMetadata from './webMetadata.json';
+import mobileMetadata from './mobileMetadata.json';
+
+
+
+ }
+ webExamples={}
+ mobilePropsTable={}
+ mobileExamples={}
+ webExamplesToc={webExamplesToc}
+ mobileExamplesToc={mobileExamplesToc}
+ webPropsToc={webPropsToc}
+ mobilePropsToc={mobilePropsToc}
+ />
+
diff --git a/apps/docs/docs/components/graphs/Legend/mobileMetadata.json b/apps/docs/docs/components/graphs/Legend/mobileMetadata.json
new file mode 100644
index 000000000..55826f201
--- /dev/null
+++ b/apps/docs/docs/components/graphs/Legend/mobileMetadata.json
@@ -0,0 +1,37 @@
+{
+ "import": "import { Legend } from '@coinbase/cds-mobile-visualization'",
+ "source": "https://github.com/coinbase/cds/blob/master/packages/mobile-visualization/src/chart/legend/Legend.tsx",
+ "description": "A legend component for displaying series information in charts. Supports customizable shapes, layouts, and custom item components.",
+ "relatedComponents": [
+ {
+ "label": "CartesianChart",
+ "url": "/components/graphs/CartesianChart/"
+ },
+ {
+ "label": "DonutChart",
+ "url": "/components/graphs/DonutChart/"
+ },
+ {
+ "label": "LineChart",
+ "url": "/components/graphs/LineChart/"
+ },
+ {
+ "label": "PieChart",
+ "url": "/components/graphs/PieChart/"
+ }
+ ],
+ "dependencies": [
+ {
+ "name": "@shopify/react-native-skia",
+ "version": "^1.12.4 || ^2.0.0"
+ },
+ {
+ "name": "react-native-gesture-handler",
+ "version": "^2.16.2"
+ },
+ {
+ "name": "react-native-reanimated",
+ "version": "^3.14.0"
+ }
+ ]
+}
diff --git a/apps/docs/docs/components/graphs/Legend/webMetadata.json b/apps/docs/docs/components/graphs/Legend/webMetadata.json
new file mode 100644
index 000000000..9c9246ff4
--- /dev/null
+++ b/apps/docs/docs/components/graphs/Legend/webMetadata.json
@@ -0,0 +1,33 @@
+{
+ "import": "import { Legend } from '@coinbase/cds-web-visualization'",
+ "source": "https://github.com/coinbase/cds/blob/master/packages/web-visualization/src/chart/legend/Legend.tsx",
+ "description": "A legend component for displaying series information in charts. Supports customizable shapes, layouts, and custom item components.",
+ "relatedComponents": [
+ {
+ "label": "CartesianChart",
+ "url": "/components/graphs/CartesianChart/"
+ },
+ {
+ "label": "ChartTooltip",
+ "url": "/components/graphs/ChartTooltip/"
+ },
+ {
+ "label": "DonutChart",
+ "url": "/components/graphs/DonutChart/"
+ },
+ {
+ "label": "LineChart",
+ "url": "/components/graphs/LineChart/"
+ },
+ {
+ "label": "PieChart",
+ "url": "/components/graphs/PieChart/"
+ }
+ ],
+ "dependencies": [
+ {
+ "name": "framer-motion",
+ "version": "^10.18.0"
+ }
+ ]
+}
diff --git a/apps/docs/sidebars.ts b/apps/docs/sidebars.ts
index ce48fdc1a..e0c5a8c2c 100644
--- a/apps/docs/sidebars.ts
+++ b/apps/docs/sidebars.ts
@@ -603,11 +603,21 @@ const sidebars: SidebarsConfig = {
id: 'components/graphs/CartesianChart/cartesianChart',
label: 'CartesianChart',
},
+ {
+ type: 'doc',
+ id: 'components/graphs/ChartTooltip/chartTooltip',
+ label: 'ChartTooltip',
+ },
{
type: 'doc',
id: 'components/graphs/DonutChart/donutChart',
label: 'DonutChart',
},
+ {
+ type: 'doc',
+ id: 'components/graphs/Legend/legend',
+ label: 'Legend',
+ },
{
type: 'doc',
id: 'components/graphs/LineChart/lineChart',
diff --git a/apps/mobile-app/scripts/utils/routes.mjs b/apps/mobile-app/scripts/utils/routes.mjs
index 293096742..0b3492e3a 100644
--- a/apps/mobile-app/scripts/utils/routes.mjs
+++ b/apps/mobile-app/scripts/utils/routes.mjs
@@ -303,6 +303,11 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default,
},
+ {
+ key: 'Legend',
+ getComponent: () =>
+ require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default,
+ },
{
key: 'LinearGradient',
getComponent: () =>
diff --git a/apps/mobile-app/src/routes.ts b/apps/mobile-app/src/routes.ts
index 293096742..0b3492e3a 100644
--- a/apps/mobile-app/src/routes.ts
+++ b/apps/mobile-app/src/routes.ts
@@ -303,6 +303,11 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default,
},
+ {
+ key: 'Legend',
+ getComponent: () =>
+ require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default,
+ },
{
key: 'LinearGradient',
getComponent: () =>
diff --git a/packages/mobile-visualization/src/chart/CartesianChart.tsx b/packages/mobile-visualization/src/chart/CartesianChart.tsx
index 1d3e957d2..c6c312594 100644
--- a/packages/mobile-visualization/src/chart/CartesianChart.tsx
+++ b/packages/mobile-visualization/src/chart/CartesianChart.tsx
@@ -1,11 +1,12 @@
-import React, { forwardRef, memo, useCallback, useMemo } from 'react';
-import { type StyleProp, type View, type ViewStyle } from 'react-native';
+import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react';
+import { type LayoutChangeEvent, type StyleProp, type View, type ViewStyle } from 'react-native';
import type { Rect } from '@coinbase/cds-common/types';
import { useLayout } from '@coinbase/cds-mobile/hooks/useLayout';
import type { BoxBaseProps, BoxProps } from '@coinbase/cds-mobile/layout';
import { Box } from '@coinbase/cds-mobile/layout';
import { Canvas, Skia, type SkTypefaceFontProvider } from '@shopify/react-native-skia';
+import { Legend } from './legend/Legend';
import { ScrubberProvider, type ScrubberProviderProps } from './scrubber/ScrubberProvider';
import { convertToSerializableScale, type SerializableScale } from './utils/scale';
import { useChartContextBridge } from './ChartContextBridge';
@@ -28,17 +29,23 @@ import {
useTotalAxisPadding,
} from './utils';
-const ChartCanvas = memo(
- ({ children, style }: { children: React.ReactNode; style?: StyleProp }) => {
- const ContextBridge = useChartContextBridge();
+type ChartCanvasProps = {
+ children: React.ReactNode;
+ style?: StyleProp;
+ onLayout?: (event: LayoutChangeEvent) => void;
+};
- return (
-
- );
- },
-);
+const ChartCanvas = memo(({ children, style, onLayout }: ChartCanvasProps) => {
+ const ContextBridge = useChartContextBridge();
+
+ return (
+
+ );
+});
+
+export type LegendPosition = 'top' | 'bottom' | 'left' | 'right';
export type CartesianChartBaseProps = Omit &
Pick & {
@@ -64,6 +71,18 @@ export type CartesianChartBaseProps = Omit &
* Inset around the entire chart (outside the axes).
*/
inset?: number | Partial;
+ /**
+ * Whether to show a legend, or a custom legend element.
+ * When `true`, renders the default Legend component.
+ * When a ReactNode, renders the provided element.
+ * @default false
+ */
+ legend?: boolean | React.ReactNode;
+ /**
+ * Position of the legend relative to the chart.
+ * @default 'bottom'
+ */
+ legendPosition?: LegendPosition;
};
export type CartesianChartProps = CartesianChartBaseProps &
@@ -97,6 +116,11 @@ export type CartesianChartProps = CartesianChartBaseProps &
* Custom styles for the chart canvas element.
*/
chart?: StyleProp;
+ /**
+ * Custom styles for the legend element.
+ * @note not used when legend is a ReactNode.
+ */
+ legend?: StyleProp;
};
};
@@ -112,6 +136,8 @@ export const CartesianChart = memo(
yAxis: yAxisConfigProp,
inset,
onScrubberPositionChange,
+ legend,
+ legendPosition = 'bottom',
width = '100%',
height = '100%',
style,
@@ -386,8 +412,11 @@ export const CartesianChart = memo(
return Skia.TypefaceFontProvider.Make();
}, [fontProviderProp]);
+ const chartRef = useRef(null);
+
const contextValue: CartesianChartContextValue = useMemo(
() => ({
+ type: 'cartesian',
series: series ?? [],
getSeries,
getSeriesData: getStackedSeriesData,
@@ -407,6 +436,7 @@ export const CartesianChart = memo(
registerAxis,
unregisterAxis,
getAxisBounds,
+ ref: chartRef,
}),
[
series,
@@ -428,12 +458,28 @@ export const CartesianChart = memo(
registerAxis,
unregisterAxis,
getAxisBounds,
+ chartRef,
],
);
- const rootStyles = useMemo(() => {
- return [style, styles?.root];
- }, [style, styles?.root]);
+ const isVerticalLegend = legendPosition === 'top' || legendPosition === 'bottom';
+ const isLegendBefore = legendPosition === 'top' || legendPosition === 'left';
+
+ const legendElement = useMemo(() => {
+ if (!legend) return;
+ if (typeof legend !== 'boolean') return legend;
+ return (
+
+ );
+ }, [legend, isVerticalLegend, styles?.legend]);
+
+ const rootStyles = useMemo(() => {
+ return [
+ { flexDirection: isVerticalLegend ? 'column' : 'row' } as ViewStyle,
+ style as ViewStyle,
+ styles?.root as ViewStyle,
+ ].filter(Boolean);
+ }, [isVerticalLegend, style, styles?.root]);
return (
@@ -443,17 +489,29 @@ export const CartesianChart = memo(
onScrubberPositionChange={onScrubberPositionChange}
>
{
+ chartRef.current = node;
+ if (ref) {
+ if (typeof ref === 'function') {
+ ref(node);
+ } else {
+ ref.current = node;
+ }
+ }
+ }}
accessibilityLiveRegion="polite"
accessibilityRole="image"
collapsable={collapsable}
height={height}
- onLayout={onContainerLayout}
style={rootStyles}
width={width}
{...props}
>
- {children}
+ {isLegendBefore && legendElement}
+
+ {children}
+
+ {!isLegendBefore && legendElement}
diff --git a/packages/mobile-visualization/src/chart/PolarChart.tsx b/packages/mobile-visualization/src/chart/PolarChart.tsx
index b87aea4a1..ddaf8508c 100644
--- a/packages/mobile-visualization/src/chart/PolarChart.tsx
+++ b/packages/mobile-visualization/src/chart/PolarChart.tsx
@@ -1,12 +1,14 @@
-import React, { forwardRef, memo, useCallback, useMemo } from 'react';
-import { type StyleProp, type View, type ViewStyle } from 'react-native';
+import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react';
+import { type LayoutChangeEvent, type StyleProp, type View, type ViewStyle } from 'react-native';
import type { Rect } from '@coinbase/cds-common/types';
import { useLayout } from '@coinbase/cds-mobile/hooks/useLayout';
import type { BoxBaseProps, BoxProps } from '@coinbase/cds-mobile/layout';
import { Box } from '@coinbase/cds-mobile/layout';
import { Canvas, Skia, type SkTypefaceFontProvider } from '@shopify/react-native-skia';
+import { Legend } from './legend/Legend';
import { convertToSerializableScale, type SerializableScale } from './utils/scale';
+import type { LegendPosition } from './CartesianChart';
import { useChartContextBridge } from './ChartContextBridge';
import { PolarChartProvider } from './ChartProvider';
import {
@@ -28,17 +30,21 @@ import {
type RadialAxisConfigProps,
} from './utils';
-const ChartCanvas = memo(
- ({ children, style }: { children: React.ReactNode; style?: StyleProp }) => {
- const ContextBridge = useChartContextBridge();
+type ChartCanvasProps = {
+ children: React.ReactNode;
+ style?: StyleProp;
+ onLayout?: (event: LayoutChangeEvent) => void;
+};
- return (
-
- );
- },
-);
+const ChartCanvas = memo(({ children, style, onLayout }: ChartCanvasProps) => {
+ const ContextBridge = useChartContextBridge();
+
+ return (
+
+ );
+});
export type PolarChartBaseProps = Omit & {
/**
@@ -113,6 +119,18 @@ export type PolarChartBaseProps = Omit & {
* Inset around the entire chart (outside the drawing area).
*/
inset?: number | Partial;
+ /**
+ * Whether to show a legend, or a custom legend element.
+ * When `true`, renders the default Legend component.
+ * When a ReactNode, renders the provided element.
+ * @default false
+ */
+ legend?: boolean | React.ReactNode;
+ /**
+ * Position of the legend relative to the chart.
+ * @default 'bottom'
+ */
+ legendPosition?: LegendPosition;
};
export type PolarChartProps = PolarChartBaseProps &
@@ -145,6 +163,11 @@ export type PolarChartProps = PolarChartBaseProps &
* Custom styles for the chart canvas element.
*/
chart?: StyleProp;
+ /**
+ * Custom styles for the legend element.
+ * @note not used when legend is a ReactNode.
+ */
+ legend?: StyleProp;
};
};
@@ -162,6 +185,8 @@ export const PolarChart = memo(
angularAxis,
radialAxis,
inset: insetInput,
+ legend,
+ legendPosition = 'bottom',
width = '100%',
height = '100%',
style,
@@ -370,8 +395,11 @@ export const PolarChart = memo(
return Skia.TypefaceFontProvider.Make();
}, [fontProviderProp]);
+ const chartRef = useRef(null);
+
const contextValue: PolarChartContextValue = useMemo(
() => ({
+ type: 'polar',
series: series ?? [],
getSeries,
getSeriesData,
@@ -389,6 +417,7 @@ export const PolarChart = memo(
getAngularSerializableScale,
getRadialSerializableScale,
dataLength,
+ ref: chartRef,
}),
[
series,
@@ -408,27 +437,55 @@ export const PolarChart = memo(
getAngularSerializableScale,
getRadialSerializableScale,
dataLength,
+ chartRef,
],
);
- const rootStyles = useMemo(() => {
- return [style, styles?.root];
- }, [style, styles?.root]);
+ const isVerticalLegend = legendPosition === 'top' || legendPosition === 'bottom';
+ const isLegendBefore = legendPosition === 'top' || legendPosition === 'left';
+
+ const legendElement = useMemo(() => {
+ if (!legend) return;
+ if (typeof legend !== 'boolean') return legend;
+ return (
+
+ );
+ }, [legend, isVerticalLegend, styles?.legend]);
+
+ const rootStyles = useMemo(() => {
+ return [
+ { flexDirection: isVerticalLegend ? 'column' : 'row' } as ViewStyle,
+ style as ViewStyle,
+ styles?.root as ViewStyle,
+ ].filter(Boolean);
+ }, [isVerticalLegend, style, styles?.root]);
return (
{
+ chartRef.current = node;
+ if (ref) {
+ if (typeof ref === 'function') {
+ ref(node);
+ } else {
+ ref.current = node;
+ }
+ }
+ }}
accessibilityLiveRegion="polite"
accessibilityRole="image"
collapsable={collapsable}
height={height}
- onLayout={onContainerLayout}
style={rootStyles}
width={width}
{...props}
>
- {children}
+ {isLegendBefore && legendElement}
+
+ {children}
+
+ {!isLegendBefore && legendElement}
);
diff --git a/packages/mobile-visualization/src/chart/bar/Bar.tsx b/packages/mobile-visualization/src/chart/bar/Bar.tsx
index c6e8ae052..becb330a4 100644
--- a/packages/mobile-visualization/src/chart/bar/Bar.tsx
+++ b/packages/mobile-visualization/src/chart/bar/Bar.tsx
@@ -6,6 +6,10 @@ import { getBarPath, type Transition } from '../utils';
import { DefaultBar } from './DefaultBar';
export type BarBaseProps = {
+ /**
+ * The series ID this bar belongs to.
+ */
+ seriesId?: string;
/**
* X coordinate of the bar (left edge).
*/
@@ -99,6 +103,7 @@ export type BarComponent = React.FC;
*/
export const Bar = memo(
({
+ seriesId,
x,
y,
width,
@@ -129,9 +134,7 @@ export const Bar = memo(
const effectiveOriginY = originY ?? y + height;
- if (!barPath) {
- return null;
- }
+ if (!barPath) return;
// Always use the BarComponent for rendering
return (
@@ -146,6 +149,7 @@ export const Bar = memo(
originY={effectiveOriginY}
roundBottom={roundBottom}
roundTop={roundTop}
+ seriesId={seriesId}
stroke={stroke}
strokeWidth={strokeWidth}
transition={transition}
diff --git a/packages/mobile-visualization/src/chart/bar/BarChart.tsx b/packages/mobile-visualization/src/chart/bar/BarChart.tsx
index a47211d11..20e8d2d0b 100644
--- a/packages/mobile-visualization/src/chart/bar/BarChart.tsx
+++ b/packages/mobile-visualization/src/chart/bar/BarChart.tsx
@@ -9,13 +9,13 @@ import {
} from '../CartesianChart';
import {
type CartesianAxisConfigProps,
- type CartesianSeries,
defaultChartInset,
defaultStackId,
getChartInset,
} from '../utils';
import { BarPlot, type BarPlotProps } from './BarPlot';
+import type { BarSeries } from './BarStack';
export type BarChartBaseProps = Omit &
Pick<
@@ -36,7 +36,7 @@ export type BarChartBaseProps = Omit;
+ series?: Array;
/**
* Whether to stack the areas on top of each other.
* When true, each series builds cumulative values on top of the previous series.
diff --git a/packages/mobile-visualization/src/chart/bar/BarStack.tsx b/packages/mobile-visualization/src/chart/bar/BarStack.tsx
index e5698e490..7d551589d 100644
--- a/packages/mobile-visualization/src/chart/bar/BarStack.tsx
+++ b/packages/mobile-visualization/src/chart/bar/BarStack.tsx
@@ -7,11 +7,21 @@ import type { CartesianSeries, ChartScaleFunction, Transition } from '../utils';
import { evaluateGradientAtValue, getGradientStops } from '../utils/gradient';
import { convertToSerializableScale } from '../utils/scale';
-import { Bar, type BarProps } from './Bar';
+import { Bar, type BarComponent, type BarProps } from './Bar';
import { DefaultBarStack } from './DefaultBarStack';
const EPSILON = 1e-4;
+/**
+ * Extended series type that includes bar-specific properties.
+ */
+export type BarSeries = CartesianSeries & {
+ /**
+ * Custom component to render bars for this series.
+ */
+ BarComponent?: BarComponent;
+};
+
export type BarStackBaseProps = Pick<
BarProps,
'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius'
@@ -19,7 +29,7 @@ export type BarStackBaseProps = Pick<
/**
* Array of series configurations that belong to this stack.
*/
- series: CartesianSeries[];
+ series: BarSeries[];
/**
* The category index for this stack.
*/
@@ -180,6 +190,7 @@ export const BarStack = memo(
roundTop?: boolean;
roundBottom?: boolean;
shouldApplyGap?: boolean;
+ BarComponent?: BarComponent;
}> = [];
// Track how many bars we've stacked in each direction for gap calculation
@@ -262,6 +273,7 @@ export const BarStack = memo(
width,
height,
dataY: value, // Store the actual data value
+ BarComponent: s.BarComponent,
fill: barFill,
// Check if the bar should be rounded based on the baseline, with an epsilon to handle floating-point rounding
roundTop: roundBaseline || Math.abs(barTop - baseline) >= EPSILON,
@@ -676,6 +688,7 @@ export const BarStack = memo(
originY={baseline}
roundBottom={bar.roundBottom}
roundTop={bar.roundTop}
+ seriesId={bar.seriesId}
stroke={defaultStroke}
strokeWidth={defaultStrokeWidth}
transition={transition}
diff --git a/packages/mobile-visualization/src/chart/index.ts b/packages/mobile-visualization/src/chart/index.ts
index f2e887573..a7e276c42 100644
--- a/packages/mobile-visualization/src/chart/index.ts
+++ b/packages/mobile-visualization/src/chart/index.ts
@@ -7,6 +7,7 @@ export * from './ChartContextBridge';
export * from './ChartProvider';
export * from './DonutChart';
export * from './gradient/index';
+export * from './legend/index';
export * from './line/index';
export * from './Path';
export * from './PeriodSelector';
diff --git a/packages/mobile-visualization/src/chart/legend/DefaultLegendItem.tsx b/packages/mobile-visualization/src/chart/legend/DefaultLegendItem.tsx
new file mode 100644
index 000000000..12c8a6b10
--- /dev/null
+++ b/packages/mobile-visualization/src/chart/legend/DefaultLegendItem.tsx
@@ -0,0 +1,106 @@
+import { memo } from 'react';
+import { type StyleProp, StyleSheet, type ViewStyle } from 'react-native';
+import type { SharedProps } from '@coinbase/cds-common/types';
+import { Box, HStack, type HStackProps } from '@coinbase/cds-mobile/layout';
+import { TextLabel2 } from '@coinbase/cds-mobile/typography';
+
+import type { Series } from '../utils';
+
+import { DefaultLegendShape, type LegendShapeComponent } from './DefaultLegendShape';
+
+const styles = StyleSheet.create({
+ shapeWrapper: {
+ width: 10,
+ height: 24,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ legendItem: {
+ alignItems: 'center',
+ },
+});
+
+export type LegendItemBaseProps = Omit &
+ SharedProps & {
+ /**
+ * Id of the series.
+ */
+ seriesId: string;
+ /**
+ * Display label for the legend item.
+ * Can be a string or a custom ReactNode.
+ * If a ReactNode is provided, it replaces the default Text component.
+ */
+ label: React.ReactNode;
+ /**
+ * Color associated with the series.
+ * This is a raw string color value (e.g. 'rgb(...)' or hex).
+ */
+ color?: string;
+ /**
+ * Shape to display in the legend.
+ */
+ shape?: Series['legendShape'];
+ /**
+ * Custom component to render the legend shape.
+ * @default DefaultLegendShape
+ */
+ ShapeComponent?: LegendShapeComponent;
+ };
+
+export type LegendItemProps = LegendItemBaseProps & {
+ /**
+ * Custom styles for the component parts.
+ */
+ styles?: {
+ /**
+ * Custom styles for the root element.
+ */
+ root?: StyleProp;
+ /**
+ * Custom styles for the shape wrapper element.
+ */
+ shapeWrapper?: StyleProp;
+ /**
+ * Custom styles for the shape element.
+ */
+ shape?: StyleProp;
+ /**
+ * Custom styles for the label element.
+ * @note not applied when label is a ReactNode.
+ */
+ label?: StyleProp;
+ };
+};
+
+export type LegendItemComponent = React.FC;
+
+export const DefaultLegendItem = memo(function DefaultLegendItem({
+ label,
+ color,
+ shape,
+ ShapeComponent = DefaultLegendShape,
+ gap = 1,
+ style,
+ styles: stylesProp,
+ testID,
+ ...props
+}) {
+ return (
+
+
+
+
+ {typeof label === 'string' ? (
+ {label}
+ ) : (
+ label
+ )}
+
+ );
+});
diff --git a/packages/mobile-visualization/src/chart/legend/DefaultLegendShape.tsx b/packages/mobile-visualization/src/chart/legend/DefaultLegendShape.tsx
new file mode 100644
index 000000000..c2a7f03c9
--- /dev/null
+++ b/packages/mobile-visualization/src/chart/legend/DefaultLegendShape.tsx
@@ -0,0 +1,82 @@
+import { memo } from 'react';
+import { StyleSheet, type ViewStyle } from 'react-native';
+import type { SharedProps } from '@coinbase/cds-common/types';
+import { useTheme } from '@coinbase/cds-mobile';
+import { Box, type BoxProps } from '@coinbase/cds-mobile/layout';
+
+import type { LegendShape, LegendShapeVariant } from '../utils/chart';
+
+const styles = StyleSheet.create({
+ pill: {
+ width: 6,
+ height: 24,
+ borderRadius: 3,
+ },
+ circle: {
+ width: 10,
+ height: 10,
+ borderRadius: 5,
+ },
+ square: {
+ width: 10,
+ height: 10,
+ },
+ squircle: {
+ width: 10,
+ height: 10,
+ borderRadius: 2,
+ },
+});
+
+const stylesByVariant: Record = {
+ pill: styles.pill,
+ circle: styles.circle,
+ square: styles.square,
+ squircle: styles.squircle,
+};
+
+const isVariantShape = (shape: LegendShape): shape is LegendShapeVariant =>
+ typeof shape === 'string' && shape in stylesByVariant;
+
+export type LegendShapeBaseProps = SharedProps & {
+ /**
+ * Color of the legend shape.
+ * @default theme.color.fgPrimary
+ */
+ color?: string;
+ /**
+ * Shape to display. Can be a preset shape or a custom ReactNode.
+ * @default 'circle'
+ */
+ shape?: LegendShape;
+};
+
+export type LegendShapeProps = Omit & LegendShapeBaseProps;
+
+export type LegendShapeComponent = React.FC;
+
+/**
+ * Default shape component for chart legends.
+ * Renders a colored shape (pill, circle, square, or squircle) or a custom ReactNode.
+ */
+export const DefaultLegendShape = memo(function DefaultLegendShape({
+ color,
+ shape = 'circle',
+ style,
+ testID,
+ ...props
+}) {
+ const theme = useTheme();
+
+ if (!isVariantShape(shape)) return shape;
+
+ const variantStyle = stylesByVariant[shape];
+
+ return (
+
+ );
+});
diff --git a/packages/mobile-visualization/src/chart/legend/Legend.tsx b/packages/mobile-visualization/src/chart/legend/Legend.tsx
new file mode 100644
index 000000000..c9fc11d4e
--- /dev/null
+++ b/packages/mobile-visualization/src/chart/legend/Legend.tsx
@@ -0,0 +1,116 @@
+import { forwardRef, memo, useMemo } from 'react';
+import type { StyleProp, View, ViewStyle } from 'react-native';
+import type { SharedProps } from '@coinbase/cds-common/types';
+import { Box, type BoxProps } from '@coinbase/cds-mobile/layout';
+
+import { useChartContext } from '../ChartProvider';
+
+import { DefaultLegendItem, type LegendItemComponent } from './DefaultLegendItem';
+import type { LegendShapeComponent } from './DefaultLegendShape';
+
+export type LegendBaseProps = SharedProps & {
+ /**
+ * Array of series IDs to display in the legend.
+ * By default, all series will be displayed.
+ */
+ seriesIds?: string[];
+ /**
+ * Custom component to render each legend item.
+ * @default DefaultLegendItem
+ */
+ ItemComponent?: LegendItemComponent;
+ /**
+ * Custom component to render the legend shape within each item.
+ * Only used when ItemComponent is not provided or is DefaultLegendItem.
+ * @default DefaultLegendShape
+ */
+ ShapeComponent?: LegendShapeComponent;
+};
+
+export type LegendProps = BoxProps &
+ LegendBaseProps & {
+ /**
+ * Custom styles for the component parts.
+ */
+ styles?: {
+ /**
+ * Custom styles for the root element.
+ */
+ root?: StyleProp;
+ /**
+ * Custom styles for each item element.
+ */
+ item?: StyleProp;
+ /**
+ * Custom styles for the shape wrapper element within each item.
+ */
+ itemShapeWrapper?: StyleProp;
+ /**
+ * Custom styles for the shape element within each item.
+ */
+ itemShape?: StyleProp;
+ /**
+ * Custom styles for the label element within each item.
+ * @note not applied when label is a ReactNode.
+ */
+ itemLabel?: StyleProp;
+ };
+ };
+
+export const Legend = memo(
+ forwardRef(function Legend(
+ {
+ flexDirection = 'row',
+ justifyContent = 'center',
+ alignItems = flexDirection === 'row' ? 'center' : 'flex-start',
+ flexWrap = 'wrap',
+ gap = 1,
+ seriesIds,
+ ItemComponent = DefaultLegendItem,
+ ShapeComponent,
+ style,
+ styles,
+ ...props
+ },
+ ref,
+ ) {
+ const { series } = useChartContext();
+
+ const filteredSeries = useMemo(() => {
+ if (seriesIds === undefined) return series;
+ return series.filter((s) => seriesIds.includes(s.id));
+ }, [series, seriesIds]);
+
+ if (filteredSeries.length === 0) return null;
+
+ return (
+
+ {filteredSeries.map((s) => (
+
+ ))}
+
+ );
+ }),
+);
diff --git a/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx b/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx
new file mode 100644
index 000000000..5648eb4bb
--- /dev/null
+++ b/packages/mobile-visualization/src/chart/legend/__stories__/Legend.stories.tsx
@@ -0,0 +1,343 @@
+import { memo, useCallback, useMemo, useState } from 'react';
+import { useTheme } from '@coinbase/cds-mobile';
+import { IconButton } from '@coinbase/cds-mobile/buttons';
+import { ExampleScreen } from '@coinbase/cds-mobile/examples/ExampleScreen';
+import { Box, HStack, VStack } from '@coinbase/cds-mobile/layout';
+import { TextLabel1, TextLabel2 } from '@coinbase/cds-mobile/typography';
+import { Text } from '@coinbase/cds-mobile/typography/Text';
+
+import { DonutChart } from '../../DonutChart';
+import { LineChart } from '../../line';
+import { PieChart } from '../../pie';
+import type { LegendShapeVariant } from '../../utils/chart';
+import type { LegendItemProps } from '../DefaultLegendItem';
+import { DefaultLegendShape } from '../DefaultLegendShape';
+import { Legend } from '../Legend';
+
+const spectrumColors = [
+ 'blue40',
+ 'green40',
+ 'orange40',
+ 'yellow40',
+ 'gray40',
+ 'indigo40',
+ 'pink40',
+ 'purple40',
+ 'red40',
+ 'teal40',
+];
+
+const shapes: LegendShapeVariant[] = ['pill', 'circle', 'squircle', 'square'];
+
+const Shapes = () => {
+ const theme = useTheme();
+
+ return (
+
+ {shapes.map((shape) => (
+
+ {spectrumColors.map((color) => (
+
+
+
+ ))}
+
+ ))}
+
+ );
+};
+
+const BasicLegend = () => {
+ const theme = useTheme();
+ const pages = useMemo(
+ () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'],
+ [],
+ );
+ const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []);
+ const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []);
+
+ return (
+
+
+
+ );
+};
+
+const ShapeVariants = () => {
+ const theme = useTheme();
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+
+ return (
+
+ }
+ legendPosition="bottom"
+ series={[
+ {
+ id: 'pill',
+ label: 'Pill',
+ data: [120, 150, 130, 170, 160, 190],
+ color: `rgb(${theme.spectrum.blue40})`,
+ legendShape: 'pill',
+ },
+ {
+ id: 'circle',
+ label: 'Circle',
+ data: [80, 110, 95, 125, 115, 140],
+ color: `rgb(${theme.spectrum.green40})`,
+ legendShape: 'circle',
+ },
+ {
+ id: 'square',
+ label: 'Square',
+ data: [60, 85, 70, 100, 90, 115],
+ color: `rgb(${theme.spectrum.orange40})`,
+ legendShape: 'square',
+ },
+ {
+ id: 'squircle',
+ label: 'Squircle',
+ data: [40, 60, 50, 75, 65, 85],
+ color: `rgb(${theme.spectrum.purple40})`,
+ legendShape: 'squircle',
+ },
+ ]}
+ width="100%"
+ xAxis={{ data: months }}
+ />
+
+ );
+};
+
+const PieChartLegend = () => {
+ const theme = useTheme();
+
+ const series = [
+ {
+ id: 'stocks',
+ data: 45,
+ label: 'Stocks',
+ color: `rgb(${theme.spectrum.blue40})`,
+ legendShape: 'circle' as const,
+ },
+ {
+ id: 'bonds',
+ data: 25,
+ label: 'Bonds',
+ color: `rgb(${theme.spectrum.green40})`,
+ legendShape: 'circle' as const,
+ },
+ {
+ id: 'realEstate',
+ data: 15,
+ label: 'Real Estate',
+ color: `rgb(${theme.spectrum.orange40})`,
+ legendShape: 'circle' as const,
+ },
+ {
+ id: 'commodities',
+ data: 10,
+ label: 'Commodities',
+ color: `rgb(${theme.spectrum.purple40})`,
+ legendShape: 'circle' as const,
+ },
+ {
+ id: 'cash',
+ data: 5,
+ label: 'Cash',
+ color: `rgb(${theme.spectrum.gray40})`,
+ legendShape: 'circle' as const,
+ },
+ ];
+
+ return (
+
+ Portfolio Allocation
+ }
+ legendPosition="right"
+ series={series}
+ width={300}
+ />
+
+ );
+};
+
+const DonutChartLegend = () => {
+ const theme = useTheme();
+
+ const series = [
+ {
+ id: 'completed',
+ data: 68,
+ label: 'Completed',
+ color: theme.color.fgPositive,
+ legendShape: 'squircle' as const,
+ },
+ {
+ id: 'inProgress',
+ data: 22,
+ label: 'In Progress',
+ color: `rgb(${theme.spectrum.blue40})`,
+ legendShape: 'squircle' as const,
+ },
+ {
+ id: 'pending',
+ data: 10,
+ label: 'Pending',
+ color: `rgb(${theme.spectrum.gray40})`,
+ legendShape: 'squircle' as const,
+ },
+ ];
+
+ return (
+
+ Task Status
+
+
+ );
+};
+
+const CustomLegendItem = () => {
+ const theme = useTheme();
+
+ const CustomItem = memo(function CustomItem({ label, color, shape }) {
+ return (
+
+
+ {label}
+ 100%
+
+ );
+ });
+
+ return (
+
+ Custom Legend Item
+ }
+ legendPosition="bottom"
+ series={[
+ {
+ id: 'btc',
+ data: 60,
+ label: 'Bitcoin',
+ color: `rgb(${theme.spectrum.orange40})`,
+ },
+ {
+ id: 'eth',
+ data: 30,
+ label: 'Ethereum',
+ color: `rgb(${theme.spectrum.purple40})`,
+ },
+ {
+ id: 'other',
+ data: 10,
+ label: 'Other',
+ color: `rgb(${theme.spectrum.gray40})`,
+ },
+ ]}
+ width={150}
+ />
+
+ );
+};
+
+type ExampleItem = {
+ title: string;
+ component: React.ReactNode;
+};
+
+function ExampleNavigator() {
+ const [currentIndex, setCurrentIndex] = useState(0);
+
+ const examples = useMemo(
+ () => [
+ { title: 'Shapes', component: },
+ { title: 'Basic Legend', component: },
+ { title: 'Shape Variants', component: },
+ { title: 'Pie Chart Legend', component: },
+ { title: 'Donut Chart Legend', component: },
+ { title: 'Custom Legend Item', component: },
+ ],
+ [],
+ );
+
+ const currentExample = examples[currentIndex];
+ const isFirstExample = currentIndex === 0;
+ const isLastExample = currentIndex === examples.length - 1;
+
+ const handlePrevious = useCallback(() => {
+ setCurrentIndex((prev) => Math.max(0, prev - 1));
+ }, []);
+
+ const handleNext = useCallback(() => {
+ setCurrentIndex((prev) => Math.min(examples.length - 1, prev + 1));
+ }, [examples.length]);
+
+ return (
+
+
+
+
+
+ {currentExample.title}
+
+ {currentIndex + 1} / {examples.length}
+
+
+
+
+
+ {currentExample.component}
+
+
+
+ );
+}
+
+export default ExampleNavigator;
diff --git a/packages/mobile-visualization/src/chart/legend/index.ts b/packages/mobile-visualization/src/chart/legend/index.ts
new file mode 100644
index 000000000..ba2be3549
--- /dev/null
+++ b/packages/mobile-visualization/src/chart/legend/index.ts
@@ -0,0 +1,3 @@
+export * from './DefaultLegendItem';
+export * from './DefaultLegendShape';
+export * from './Legend';
diff --git a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx b/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx
index 6f3234bea..2cccdabba 100644
--- a/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx
+++ b/packages/mobile-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx
@@ -19,12 +19,9 @@ export type DefaultScrubberBeaconLabelProps = ScrubberBeaconLabelProps &
*/
export const DefaultScrubberBeaconLabel = memo(
({
- background,
color,
elevated = true,
- borderRadius = 4,
font = 'label1',
- verticalAlignment = 'middle',
inset = {
left: labelHorizontalInset,
right: labelHorizontalInset,
@@ -38,13 +35,10 @@ export const DefaultScrubberBeaconLabel = memo(
return (
{label}
diff --git a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx
index 89d619b2d..b3e0b4bf8 100644
--- a/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx
+++ b/packages/mobile-visualization/src/chart/scrubber/Scrubber.tsx
@@ -146,6 +146,10 @@ export type ScrubberBaseProps = Pick
* By default, all series will be highlighted.
*/
seriesIds?: string[];
+ /**
+ * Hides the beacon labels while keeping the line label visible (if provided).
+ */
+ hideBeaconLabels?: boolean;
/**
* Hides the scrubber line.
* @note This hides Scrubber's ReferenceLine including the label.
@@ -211,6 +215,7 @@ export const Scrubber = memo(
(
{
seriesIds,
+ hideBeaconLabels,
hideLine,
label,
lineStroke,
@@ -380,7 +385,7 @@ export const Scrubber = memo(
seriesIds={filteredSeriesIds}
transitions={beaconTransitions}
/>
- {beaconLabels.length > 0 && (
+ {!hideBeaconLabels && beaconLabels.length > 0 && (
): bounds is AxisBounds =>
bounds.min !== undefined && bounds.max !== undefined;
+export type LegendShapeVariant = 'circle' | 'square' | 'squircle' | 'pill';
+
+export type LegendShape = LegendShapeVariant | React.ReactNode;
+
export type Series = {
/**
* Id of the series.
@@ -32,6 +36,12 @@ export type Series = {
* Color for the series.
*/
color?: string;
+ /**
+ * Legend shape for this series.
+ * Can be a LegendShapeVariant or a custom ReactNode.
+ * @default 'circle'
+ */
+ legendShape?: LegendShape;
};
export type CartesianSeries = Series & {
diff --git a/packages/mobile-visualization/src/chart/utils/context.ts b/packages/mobile-visualization/src/chart/utils/context.ts
index 0c49f3fdc..56ceb6c4c 100644
--- a/packages/mobile-visualization/src/chart/utils/context.ts
+++ b/packages/mobile-visualization/src/chart/utils/context.ts
@@ -1,4 +1,5 @@
-import { createContext, useContext } from 'react';
+import { createContext, type RefObject, useContext } from 'react';
+import type { View } from 'react-native';
import type { SharedValue } from 'react-native-reanimated';
import type { Rect } from '@coinbase/cds-common/types';
import type { SkTypefaceFontProvider } from '@shopify/react-native-skia';
@@ -8,9 +9,23 @@ import type { CartesianSeries, PolarSeries, Series } from './chart';
import type { ChartScaleFunction, SerializableScale } from './scale';
/**
- * Context value for charts.
+ * Chart context type discriminator.
+ */
+export type ChartType = 'cartesian' | 'polar';
+
+/**
+ * Base context value for all chart types.
*/
export type ChartContextValue = {
+ /**
+ * The type of chart.
+ */
+ type: ChartType;
+ /**
+ * The series data for the chart.
+ * Contains common series properties (id, label, color, legendShape).
+ */
+ series: Series[];
/**
* Whether to animate the chart.
*/
@@ -40,13 +55,21 @@ export type ChartContextValue = {
* Length of the data domain.
*/
dataLength: number;
+ /**
+ * Reference to the chart's root element (SVG on web, Canvas on mobile).
+ */
+ ref?: RefObject;
};
/**
* Context value for Cartesian (X/Y) coordinate charts.
* Contains axis-specific methods and properties for rectangular coordinate systems.
*/
-export type CartesianChartContextValue = ChartContextValue & {
+export type CartesianChartContextValue = Omit & {
+ /**
+ * The type of chart.
+ */
+ type: 'cartesian';
/**
* The series data for the chart.
*/
@@ -112,7 +135,11 @@ export type CartesianChartContextValue = ChartContextValue & {
* Context value for Polar (Angular/Radial) coordinate charts.
* Contains axis-specific methods and properties for polar coordinate systems.
*/
-export type PolarChartContextValue = ChartContextValue & {
+export type PolarChartContextValue = Omit & {
+ /**
+ * The type of chart.
+ */
+ type: 'polar';
/**
* The series data for the chart.
*/
diff --git a/packages/ui-mobile-playground/src/routes.ts b/packages/ui-mobile-playground/src/routes.ts
index 293096742..0b3492e3a 100644
--- a/packages/ui-mobile-playground/src/routes.ts
+++ b/packages/ui-mobile-playground/src/routes.ts
@@ -303,6 +303,11 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default,
},
+ {
+ key: 'Legend',
+ getComponent: () =>
+ require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default,
+ },
{
key: 'LinearGradient',
getComponent: () =>
diff --git a/packages/ui-mobile-visreg/src/routes.ts b/packages/ui-mobile-visreg/src/routes.ts
index 293096742..0b3492e3a 100644
--- a/packages/ui-mobile-visreg/src/routes.ts
+++ b/packages/ui-mobile-visreg/src/routes.ts
@@ -303,6 +303,11 @@ export const routes = [
getComponent: () =>
require('@coinbase/cds-mobile/controls/__stories__/InputStack.stories').default,
},
+ {
+ key: 'Legend',
+ getComponent: () =>
+ require('@coinbase/cds-mobile-visualization/chart/legend/__stories__/Legend.stories').default,
+ },
{
key: 'LinearGradient',
getComponent: () =>
diff --git a/packages/web-visualization/package.json b/packages/web-visualization/package.json
index 02aaba956..ed4cb11d4 100644
--- a/packages/web-visualization/package.json
+++ b/packages/web-visualization/package.json
@@ -42,6 +42,7 @@
"@coinbase/cds-lottie-files": "workspace:^",
"@coinbase/cds-utils": "workspace:^",
"@coinbase/cds-web": "workspace:^",
+ "@floating-ui/react-dom": "^2.1.1",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
diff --git a/packages/web-visualization/src/chart/CartesianChart.tsx b/packages/web-visualization/src/chart/CartesianChart.tsx
index cfd5a89dc..167fb6d0d 100644
--- a/packages/web-visualization/src/chart/CartesianChart.tsx
+++ b/packages/web-visualization/src/chart/CartesianChart.tsx
@@ -5,6 +5,7 @@ import { useDimensions } from '@coinbase/cds-web/hooks/useDimensions';
import { Box, type BoxBaseProps, type BoxProps } from '@coinbase/cds-web/layout';
import { css } from '@linaria/core';
+import { Legend } from './legend/Legend';
import { ScrubberProvider, type ScrubberProviderProps } from './scrubber/ScrubberProvider';
import { CartesianChartProvider } from './ChartProvider';
import {
@@ -25,7 +26,11 @@ import {
useTotalAxisPadding,
} from './utils';
-const focusStylesCss = css`
+const rootCss = css`
+ display: flex;
+ overflow: hidden;
+`;
+const focusCss = css`
&:focus {
outline: none;
}
@@ -34,6 +39,19 @@ const focusStylesCss = css`
outline-offset: 2px;
}
`;
+const verticalCss = css`
+ flex-direction: column;
+`;
+const horizontalCss = css`
+ flex-direction: row;
+`;
+const chartContainerCss = css`
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+`;
+
+export type LegendPosition = 'top' | 'bottom' | 'left' | 'right';
export type CartesianChartBaseProps = BoxBaseProps &
Pick & {
@@ -61,6 +79,17 @@ export type CartesianChartBaseProps = BoxBaseProps &
* Inset around the entire chart (outside the axes).
*/
inset?: number | Partial;
+ /**
+ * Whether to show a legend, or a custom legend element.
+ * When `true`, renders the default Legend component.
+ * When a ReactNode, renders the provided element.
+ */
+ legend?: boolean | React.ReactNode;
+ /**
+ * Position of the legend relative to the chart.
+ * @default 'bottom'
+ */
+ legendPosition?: LegendPosition;
};
export type CartesianChartProps = Omit, 'title'> &
@@ -81,6 +110,11 @@ export type CartesianChartProps = Omit, 'title'> &
* Custom class name for the chart SVG element.
*/
chart?: string;
+ /**
+ * Custom class name for the legend element.
+ * @note not used when legend is a ReactNode.
+ */
+ legend?: string;
};
/**
* Custom styles for the root element.
@@ -98,6 +132,11 @@ export type CartesianChartProps = Omit, 'title'> &
* Custom styles for the chart SVG element.
*/
chart?: React.CSSProperties;
+ /**
+ * Custom styles for the legend element.
+ * @note not used when legend is a ReactNode.
+ */
+ legend?: React.CSSProperties;
};
};
@@ -113,6 +152,8 @@ export const CartesianChart = memo(
inset,
enableScrubbing,
onScrubberPositionChange,
+ legend,
+ legendPosition = 'bottom',
width = '100%',
height = '100%',
className,
@@ -124,7 +165,7 @@ export const CartesianChart = memo(
ref,
) => {
const { observe, width: chartWidth, height: chartHeight } = useDimensions();
- const svgRef = useRef(null);
+ const chartRef = useRef(null);
const calculatedInset = useMemo(() => getChartInset(inset, defaultChartInset), [inset]);
@@ -353,6 +394,7 @@ export const CartesianChart = memo(
const contextValue: CartesianChartContextValue = useMemo(
() => ({
+ type: 'cartesian',
series: series ?? [],
getSeries,
getSeriesData: getStackedSeriesData,
@@ -368,6 +410,7 @@ export const CartesianChart = memo(
registerAxis,
unregisterAxis,
getAxisBounds,
+ ref: chartRef,
}),
[
series,
@@ -385,12 +428,35 @@ export const CartesianChart = memo(
registerAxis,
unregisterAxis,
getAxisBounds,
+ chartRef,
],
);
+ const isVerticalLegend = useMemo(
+ () => legendPosition === 'top' || legendPosition === 'bottom',
+ [legendPosition],
+ );
+ const isLegendBefore = useMemo(
+ () => legendPosition === 'top' || legendPosition === 'left',
+ [legendPosition],
+ );
+
+ const legendElement = useMemo(() => {
+ if (!legend) return;
+ if (typeof legend !== 'boolean') return legend;
+ return (
+
+ );
+ }, [legend, isVerticalLegend, classNames?.legend, styles?.legend]);
+
const rootClassNames = useMemo(
- () => cx(className, classNames?.root),
- [className, classNames],
+ () =>
+ cx(rootCss, isVerticalLegend ? verticalCss : horizontalCss, className, classNames?.root),
+ [className, classNames, isVerticalLegend],
);
const rootStyles = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]);
@@ -399,22 +465,21 @@ export const CartesianChart = memo(
{
- observe(node as unknown as HTMLElement);
- }}
className={rootClassNames}
height={height}
style={rootStyles}
width={width}
{...props}
>
+ {isLegendBefore && legendElement}
{
const svgElement = node as unknown as SVGSVGElement;
- svgRef.current = svgElement;
+ chartRef.current = svgElement;
+ observe(node as unknown as HTMLElement);
+
// Forward the ref to the user
if (ref) {
if (typeof ref === 'function') {
@@ -426,7 +491,7 @@ export const CartesianChart = memo(
}}
aria-live="polite"
as="svg"
- className={cx(enableScrubbing && focusStylesCss, classNames?.chart)}
+ className={cx(chartContainerCss, enableScrubbing && focusCss, classNames?.chart)}
height="100%"
style={styles?.chart}
tabIndex={enableScrubbing ? 0 : undefined}
@@ -434,6 +499,7 @@ export const CartesianChart = memo(
>
{children}
+ {!isLegendBefore && legendElement}
diff --git a/packages/web-visualization/src/chart/ChartTooltip.tsx b/packages/web-visualization/src/chart/ChartTooltip.tsx
new file mode 100644
index 000000000..eef34d80d
--- /dev/null
+++ b/packages/web-visualization/src/chart/ChartTooltip.tsx
@@ -0,0 +1,290 @@
+import React, { useEffect, useMemo } from 'react';
+import {
+ Divider,
+ VStack,
+ type VStackBaseProps,
+ type VStackDefaultElement,
+ type VStackProps,
+} from '@coinbase/cds-web/layout';
+import { Portal } from '@coinbase/cds-web/overlays/Portal';
+import { tooltipContainerId } from '@coinbase/cds-web/overlays/PortalProvider';
+import { Text } from '@coinbase/cds-web/typography';
+import { flip, offset, shift, useFloating, type VirtualElement } from '@floating-ui/react-dom';
+
+import type { LegendShapeComponent } from './legend/DefaultLegendShape';
+import { useCartesianChartContext } from './ChartProvider';
+import { type ChartTooltipItemComponent, DefaultChartTooltipItem } from './DefaultChartTooltipItem';
+import { useScrubberContext } from './utils';
+
+export type ChartTooltipBaseProps = VStackBaseProps & {
+ /**
+ * Label text displayed at the top of the tooltip.
+ * Can be a static string, a custom ReactNode, or a function that receives the current dataIndex.
+ * If not provided, defaults to the x-axis data value at the current index.
+ * If null is returned, the label is omitted.
+ */
+ label?: React.ReactNode | ((dataIndex: number) => React.ReactNode);
+ /**
+ * Array of series IDs to include in the tooltip.
+ * By default, all series will be included.
+ */
+ seriesIds?: string[];
+ /**
+ * Formatter function for series values.
+ * Receives the numeric series value and should return a ReactNode.
+ * String results will automatically be wrapped in Text with font="label2".
+ */
+ valueFormatter?: (value: number) => React.ReactNode;
+ /**
+ * Custom component to render each tooltip item.
+ * @default DefaultChartTooltipItem
+ */
+ ItemComponent?: ChartTooltipItemComponent;
+ /**
+ * Custom component to render the legend shape within each item.
+ * Only used when ItemComponent is DefaultChartTooltipItem.
+ * @default DefaultLegendShape
+ */
+ ShapeComponent?: LegendShapeComponent;
+};
+
+export type ChartTooltipProps = VStackProps &
+ ChartTooltipBaseProps & {
+ /**
+ * Custom class names for the component parts.
+ */
+ classNames?: {
+ /**
+ * Custom class name for the root element.
+ */
+ root?: string;
+ /**
+ * Custom class name for the label element.
+ * @note not applied when label is a ReactNode.
+ */
+ label?: string;
+ /**
+ * Custom class name for the divider element.
+ */
+ divider?: string;
+ /**
+ * Custom class name for each item element.
+ */
+ item?: string;
+ /**
+ * Custom class name for the legend item element within each item.
+ */
+ itemLegendItem?: string;
+ /**
+ * Custom class name for the value element within each item.
+ */
+ itemValue?: string;
+ /**
+ * Custom class name for the shape wrapper element within each item.
+ */
+ itemShapeWrapper?: string;
+ /**
+ * Custom class name for the shape element within each item.
+ */
+ itemShape?: string;
+ /**
+ * Custom class name for the label element within each item.
+ * @note not applied when label is a ReactNode.
+ */
+ itemLabel?: string;
+ };
+ /**
+ * Custom styles for the component parts.
+ */
+ styles?: {
+ /**
+ * Custom styles for the root element.
+ */
+ root?: React.CSSProperties;
+ /**
+ * Custom styles for the label element.
+ * @note not applied when label is a ReactNode.
+ */
+ label?: React.CSSProperties;
+ /**
+ * Custom styles for the divider element.
+ */
+ divider?: React.CSSProperties;
+ /**
+ * Custom styles for each item element.
+ */
+ item?: React.CSSProperties;
+ /**
+ * Custom styles for the legend item element within each item.
+ */
+ itemLegendItem?: React.CSSProperties;
+ /**
+ * Custom styles for the value element within each item.
+ */
+ itemValue?: React.CSSProperties;
+ /**
+ * Custom styles for the shape wrapper element within each item.
+ */
+ itemShapeWrapper?: React.CSSProperties;
+ /**
+ * Custom styles for the shape element within each item.
+ */
+ itemShape?: React.CSSProperties;
+ /**
+ * Custom styles for the label element within each item.
+ * @note not applied when label is a ReactNode.
+ */
+ itemLabel?: React.CSSProperties;
+ };
+ };
+
+export const ChartTooltip = ({
+ label,
+ seriesIds,
+ valueFormatter,
+ ItemComponent = DefaultChartTooltipItem,
+ ShapeComponent,
+ background = 'bgElevation2',
+ borderRadius = 400,
+ elevation = 2,
+ gap = 1,
+ minWidth = 320,
+ paddingX = 2,
+ paddingY = 1.5,
+ className,
+ classNames,
+ style,
+ styles,
+ ...props
+}: ChartTooltipProps) => {
+ const { ref, series, getXAxis } = useCartesianChartContext();
+ const { scrubberPosition, enableScrubbing } = useScrubberContext();
+
+ const isTooltipVisible = enableScrubbing && scrubberPosition !== undefined;
+
+ const { refs, floatingStyles } = useFloating({
+ open: isTooltipVisible,
+ placement: 'bottom-start',
+ middleware: [
+ offset(({ placement }) => {
+ const mainAxis = placement.includes('bottom') ? 16 : 8;
+ const crossAxis = placement.includes('start') ? 16 : -8;
+
+ return { mainAxis, crossAxis };
+ }),
+ flip({
+ fallbackPlacements: ['top-start', 'bottom-end', 'top-end'],
+ }),
+ shift({ padding: 8 }),
+ ],
+ });
+
+ useEffect(() => {
+ const element = ref?.current;
+ if (!element || !enableScrubbing) return;
+
+ const handleMouseMove = (event: Event) => {
+ const { clientX, clientY } = event as MouseEvent;
+ const virtualEl: VirtualElement = {
+ getBoundingClientRect() {
+ return {
+ width: 0,
+ height: 0,
+ x: clientX,
+ y: clientY,
+ left: clientX,
+ right: clientX,
+ top: clientY,
+ bottom: clientY,
+ } as DOMRect;
+ },
+ };
+ refs.setReference(virtualEl);
+ };
+
+ element.addEventListener('mousemove', handleMouseMove);
+
+ return () => element.removeEventListener('mousemove', handleMouseMove);
+ }, [enableScrubbing, refs, ref]);
+
+ const filteredSeries = useMemo(() => {
+ if (seriesIds === undefined) return series;
+ return series.filter((s) => seriesIds.includes(s.id));
+ }, [series, seriesIds]);
+
+ const resolvedLabel = useMemo(() => {
+ if (scrubberPosition === undefined) return;
+
+ let resolved: React.ReactNode;
+ if (label !== undefined) {
+ resolved = typeof label === 'function' ? label(scrubberPosition) : label;
+ } else {
+ // Default to x-axis data value
+ const xAxis = getXAxis();
+ if (xAxis?.data && xAxis.data[scrubberPosition] !== undefined) {
+ resolved = xAxis.data[scrubberPosition];
+ } else {
+ resolved = scrubberPosition;
+ }
+ }
+ return resolved;
+ }, [scrubberPosition, label, getXAxis]);
+
+ if (!isTooltipVisible) return;
+
+ return (
+
+
+ {resolvedLabel &&
+ (typeof resolvedLabel === 'string' || typeof resolvedLabel === 'number' ? (
+
+ {resolvedLabel}
+
+ ) : (
+ resolvedLabel
+ ))}
+ {resolvedLabel && filteredSeries.length > 0 && (
+
+ )}
+ {filteredSeries.length > 0 &&
+ filteredSeries.map((s) => (
+
+ ))}
+
+
+ );
+};
diff --git a/packages/web-visualization/src/chart/DefaultChartTooltipItem.tsx b/packages/web-visualization/src/chart/DefaultChartTooltipItem.tsx
new file mode 100644
index 000000000..6ee446095
--- /dev/null
+++ b/packages/web-visualization/src/chart/DefaultChartTooltipItem.tsx
@@ -0,0 +1,191 @@
+import { memo, useMemo } from 'react';
+import type { SharedProps } from '@coinbase/cds-common/types';
+import {
+ HStack,
+ type HStackBaseProps,
+ type HStackDefaultElement,
+ type HStackProps,
+} from '@coinbase/cds-web/layout';
+import { Text } from '@coinbase/cds-web/typography';
+
+import { DefaultLegendItem, type LegendItemComponent } from './legend/DefaultLegendItem';
+import type { LegendShapeComponent } from './legend/DefaultLegendShape';
+import { useCartesianChartContext } from './ChartProvider';
+import type { CartesianSeries } from './utils';
+
+export type ChartTooltipItemBaseProps = Omit &
+ SharedProps & {
+ /**
+ * The series to display.
+ */
+ series: CartesianSeries;
+ /**
+ * The current scrubber position (data index).
+ */
+ scrubberPosition: number;
+ /**
+ * Formatter function for series values.
+ * Receives the numeric series value and should return a ReactNode.
+ * String results will automatically be wrapped in Text.
+ */
+ valueFormatter?: (value: number) => React.ReactNode;
+ /**
+ * Custom component to render the legend item (shape + label).
+ * @default DefaultLegendItem
+ */
+ LegendItemComponent?: LegendItemComponent;
+ /**
+ * Custom component to render the legend shape.
+ * Only used when LegendItemComponent is DefaultLegendItem.
+ * @default DefaultLegendShape
+ */
+ ShapeComponent?: LegendShapeComponent;
+ };
+
+export type ChartTooltipItemProps = Omit, 'children'> &
+ ChartTooltipItemBaseProps & {
+ /**
+ * Custom class names for the component parts.
+ */
+ classNames?: {
+ /**
+ * Custom class name for the root element.
+ */
+ root?: string;
+ /**
+ * Custom class name for the legend item element.
+ */
+ legendItem?: string;
+ /**
+ * Custom class name for the value element.
+ * @note not applied when value is a ReactNode.
+ */
+ value?: string;
+ /**
+ * Custom class name for the shape wrapper element.
+ */
+ shapeWrapper?: string;
+ /**
+ * Custom class name for the shape element.
+ */
+ shape?: string;
+ /**
+ * Custom class name for the label element.
+ * @note not applied when label is a ReactNode.
+ */
+ label?: string;
+ };
+ /**
+ * Custom styles for the component parts.
+ */
+ styles?: {
+ /**
+ * Custom styles for the root element.
+ */
+ root?: React.CSSProperties;
+ /**
+ * Custom styles for the legend item element.
+ */
+ legendItem?: React.CSSProperties;
+ /**
+ * Custom styles for the value element.
+ * @note not applied when value is a ReactNode.
+ */
+ value?: React.CSSProperties;
+ /**
+ * Custom styles for the shape wrapper element.
+ */
+ shapeWrapper?: React.CSSProperties;
+ /**
+ * Custom styles for the shape element.
+ */
+ shape?: React.CSSProperties;
+ /**
+ * Custom styles for the label element.
+ * @note not applied when label is a ReactNode.
+ */
+ label?: React.CSSProperties;
+ };
+ };
+
+export type ChartTooltipItemComponent = React.FC;
+
+export const DefaultChartTooltipItem = memo(
+ ({
+ alignItems = 'center',
+ justifyContent = 'space-between',
+ series,
+ scrubberPosition,
+ valueFormatter,
+ LegendItemComponent = DefaultLegendItem,
+ ShapeComponent,
+ className,
+ classNames,
+ style,
+ styles,
+ testID,
+ ...props
+ }) => {
+ const { getSeriesData } = useCartesianChartContext();
+
+ const formattedValue: React.ReactNode = useMemo(() => {
+ const data = getSeriesData(series.id);
+ const dataPoint = data?.[scrubberPosition];
+ let value: number | undefined;
+
+ if (dataPoint && dataPoint !== null) {
+ const [start, end] = dataPoint;
+ value = end - start;
+ } else if (series.data) {
+ const rawPoint = series.data[scrubberPosition];
+ if (rawPoint !== undefined && rawPoint !== null) {
+ value = Array.isArray(rawPoint) ? rawPoint.at(-1) : rawPoint;
+ }
+ }
+
+ if (value === undefined || value === null || Number.isNaN(value)) return;
+
+ return valueFormatter ? valueFormatter(value) : value;
+ }, [series.id, series.data, scrubberPosition, getSeriesData, valueFormatter]);
+
+ if (formattedValue === undefined) return;
+
+ return (
+
+
+ {typeof formattedValue === 'string' || typeof formattedValue === 'number' ? (
+
+ {formattedValue}
+
+ ) : (
+ formattedValue
+ )}
+
+ );
+ },
+);
diff --git a/packages/web-visualization/src/chart/PolarChart.tsx b/packages/web-visualization/src/chart/PolarChart.tsx
index 2dcce4a55..2fae9c791 100644
--- a/packages/web-visualization/src/chart/PolarChart.tsx
+++ b/packages/web-visualization/src/chart/PolarChart.tsx
@@ -1,9 +1,12 @@
-import React, { forwardRef, memo, useCallback, useMemo } from 'react';
+import React, { forwardRef, memo, useCallback, useMemo, useRef } from 'react';
import type { Rect } from '@coinbase/cds-common/types';
import { cx } from '@coinbase/cds-web';
import { useDimensions } from '@coinbase/cds-web/hooks/useDimensions';
import { Box, type BoxBaseProps, type BoxProps } from '@coinbase/cds-web/layout';
+import { css } from '@linaria/core';
+import { Legend } from './legend/Legend';
+import type { LegendPosition } from './CartesianChart';
import { PolarChartProvider } from './ChartProvider';
import {
type AngularAxisConfig,
@@ -24,6 +27,22 @@ import {
type RadialAxisConfigProps,
} from './utils';
+const rootCss = css`
+ display: flex;
+ overflow: hidden;
+`;
+const verticalCss = css`
+ flex-direction: column;
+`;
+const horizontalCss = css`
+ flex-direction: row;
+`;
+const chartContainerCss = css`
+ flex: 1;
+ min-height: 0;
+ min-width: 0;
+`;
+
export type PolarChartBaseProps = BoxBaseProps & {
/**
* Configuration object that defines the data to visualize.
@@ -97,6 +116,17 @@ export type PolarChartBaseProps = BoxBaseProps & {
* Inset around the entire chart (outside the drawing area).
*/
inset?: number | Partial;
+ /**
+ * Whether to show a legend, or a custom legend element.
+ * When `true`, renders the default Legend component.
+ * When a ReactNode, renders the provided element.
+ */
+ legend?: boolean | React.ReactNode;
+ /**
+ * Position of the legend relative to the chart.
+ * @default 'bottom'
+ */
+ legendPosition?: LegendPosition;
};
export type PolarChartProps = Omit, 'title'> &
@@ -117,6 +147,11 @@ export type PolarChartProps = Omit, 'title'> &
* Custom class name for the chart SVG element.
*/
chart?: string;
+ /**
+ * Custom class name for the legend element.
+ * @note not used when legend is a ReactNode.
+ */
+ legend?: string;
};
/**
* Custom styles for the root element.
@@ -134,6 +169,11 @@ export type PolarChartProps = Omit, 'title'> &
* Custom styles for the chart SVG element.
*/
chart?: React.CSSProperties;
+ /**
+ * Custom styles for the legend element.
+ * @note not used when legend is a ReactNode.
+ */
+ legend?: React.CSSProperties;
};
};
@@ -151,6 +191,8 @@ export const PolarChart = memo(
angularAxis,
radialAxis,
inset: insetInput,
+ legend,
+ legendPosition = 'bottom',
width = '100%',
height = '100%',
className,
@@ -319,6 +361,7 @@ export const PolarChart = memo(
const contextValue: PolarChartContextValue = useMemo(
() => ({
+ type: 'polar',
series: series ?? [],
getSeries,
getSeriesData,
@@ -350,35 +393,67 @@ export const PolarChart = memo(
],
);
+ const isVerticalLegend = useMemo(
+ () => legendPosition === 'top' || legendPosition === 'bottom',
+ [legendPosition],
+ );
+ const isLegendBefore = useMemo(
+ () => legendPosition === 'top' || legendPosition === 'left',
+ [legendPosition],
+ );
+
+ const legendElement = useMemo(() => {
+ if (!legend) return;
+ if (typeof legend !== 'boolean') return legend;
+ return (
+
+ );
+ }, [legend, isVerticalLegend, classNames?.legend, styles?.legend]);
+
const rootClassNames = useMemo(
- () => cx(className, classNames?.root),
- [className, classNames],
+ () =>
+ cx(rootCss, isVerticalLegend ? verticalCss : horizontalCss, className, classNames?.root),
+ [className, classNames, isVerticalLegend],
);
const rootStyles = useMemo(() => ({ ...style, ...styles?.root }), [style, styles?.root]);
return (
{
- observe(node as unknown as HTMLElement);
- }}
className={rootClassNames}
height={height}
style={rootStyles}
width={width}
{...props}
>
+ {isLegendBefore && legendElement}
{
+ const svgElement = node as unknown as SVGSVGElement;
+ observe(node as unknown as HTMLElement);
+ // Forward the ref to the user
+ if (ref) {
+ if (typeof ref === 'function') {
+ ref(svgElement);
+ } else {
+ (ref as React.MutableRefObject).current = svgElement;
+ }
+ }
+ }}
aria-live="polite"
as="svg"
- className={classNames?.chart}
+ className={cx(chartContainerCss, classNames?.chart)}
height="100%"
style={styles?.chart}
width="100%"
>
{children}
+ {!isLegendBefore && legendElement}
);
diff --git a/packages/web-visualization/src/chart/__stories__/ChartTooltip.stories.tsx b/packages/web-visualization/src/chart/__stories__/ChartTooltip.stories.tsx
new file mode 100644
index 000000000..808327ca5
--- /dev/null
+++ b/packages/web-visualization/src/chart/__stories__/ChartTooltip.stories.tsx
@@ -0,0 +1,365 @@
+import { useCallback, useMemo } from 'react';
+import { Box, HStack, VStack } from '@coinbase/cds-web/layout';
+import { Text } from '@coinbase/cds-web/typography';
+
+import { AreaChart } from '../area';
+import { XAxis, YAxis } from '../axis';
+import { BarPlot } from '../bar';
+import { CartesianChart } from '../CartesianChart';
+import { ChartTooltip } from '../ChartTooltip';
+import { Legend } from '../legend';
+import { LineChart } from '../line';
+import { Scrubber } from '../scrubber';
+
+export default {
+ component: ChartTooltip,
+ title: 'Components/Chart/ChartTooltip',
+};
+
+const Example: React.FC> = ({ children, title }) => {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+};
+
+const Basic = () => {
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+ const revenue = [120, 150, 180, 165, 190, 210];
+ const expenses = [80, 95, 110, 105, 120, 130];
+
+ return (
+
+
+ Hover over the chart to see the tooltip. The label defaults to the x-axis value.
+
+
+
+
+
+
+ );
+};
+
+const WithValueFormatter = () => {
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+ const sales = [45000, 52000, 48000, 61000, 55000, 67000];
+ const profit = [12000, 15000, 11000, 18000, 14000, 21000];
+
+ const currencyFormatter = useCallback(
+ (value: number) =>
+ new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: 'USD',
+ maximumFractionDigits: 0,
+ }).format(value),
+ [],
+ );
+
+ return (
+
+
+ Format tooltip values with a custom formatter function.
+
+
+
+
+
+
+ );
+};
+
+const CustomLabel = () => {
+ const dates = ['2024-01', '2024-02', '2024-03', '2024-04', '2024-05', '2024-06'];
+ const displayDates = [
+ 'January 2024',
+ 'February 2024',
+ 'March 2024',
+ 'April 2024',
+ 'May 2024',
+ 'June 2024',
+ ];
+ const temperature = [32, 35, 45, 58, 68, 78];
+ const humidity = [65, 60, 55, 50, 55, 60];
+
+ return (
+
+
+ Use a function to customize the tooltip label based on the data index.
+
+
+
+ (
+
+ {displayDates[dataIndex]}
+
+ )}
+ />
+
+
+ );
+};
+
+const FilteredSeries = () => {
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+ const primary = [100, 120, 140, 130, 150, 170];
+ const secondary = [60, 70, 80, 75, 85, 95];
+ const tertiary = [30, 35, 40, 38, 42, 48];
+
+ return (
+
+
+ Show only specific series in the tooltip using the seriesIds prop.
+
+
+
+
+
+
+ );
+};
+
+const WithBarChart = () => {
+ const categories = ['Q1', 'Q2', 'Q3', 'Q4'];
+ const revenue = [250, 320, 280, 410];
+ const costs = [180, 220, 200, 280];
+
+ const numberFormatter = useCallback((value: number) => `$${value}k`, []);
+
+ return (
+
+
+ ChartTooltip works with bar charts too.
+
+
+
+
+
+
+
+
+ );
+};
+
+const StackedAreaTooltip = () => {
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug'];
+ const desktop = [4000, 4200, 3800, 4500, 4800, 5200, 5000, 5500];
+ const mobile = [2400, 2800, 3000, 3200, 3500, 3800, 4000, 4200];
+ const tablet = [1200, 1300, 1400, 1500, 1600, 1700, 1800, 1900];
+
+ const numberFormatter = useCallback((value: number) => value.toLocaleString(), []);
+
+ return (
+
+
+ Tooltip shows individual series values for stacked charts.
+
+
+
+
+
+
+ );
+};
+
+const CustomValueDisplay = () => {
+ const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
+ const steps = [8500, 12000, 7200, 9800, 11500, 15000, 6500];
+ const goal = 10000;
+
+ const stepsFormatter = useCallback((value: number) => {
+ const percentage = Math.round((value / goal) * 100);
+ const isGoalMet = value >= goal;
+ return (
+
+
+ {value.toLocaleString()} steps
+
+
+ ({percentage}%)
+
+
+ );
+ }, []);
+
+ return (
+
+
+ Return a custom ReactNode from valueFormatter for rich value display.
+
+
+
+
+
+
+ );
+};
+
+const MultiAxisTooltip = () => {
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+ const revenue = [455, 520, 380, 455, 285, 235];
+ const profitMargin = [23, 20, 16, 38, 12, 9];
+
+ return (
+
+
+ Tooltip works with multiple y-axes, showing values from all series.
+
+
+
+ `$${value}k`}
+ />
+ `${value}%`}
+ />
+
+ {
+ // Simple formatter - in real usage you'd differentiate by series
+ return value < 100 ? `${value}%` : `$${value}k`;
+ }}
+ />
+
+
+ );
+};
+
+export const All = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/web-visualization/src/chart/area/AreaChart.tsx b/packages/web-visualization/src/chart/area/AreaChart.tsx
index 352534ef0..853c9c7c6 100644
--- a/packages/web-visualization/src/chart/area/AreaChart.tsx
+++ b/packages/web-visualization/src/chart/area/AreaChart.tsx
@@ -127,6 +127,7 @@ export const AreaChart = memo(
yAxisId: s.yAxisId,
stackId: s.stackId,
gradient: s.gradient,
+ legendShape: s.legendShape,
}),
);
}, [series]);
diff --git a/packages/web-visualization/src/chart/bar/Bar.tsx b/packages/web-visualization/src/chart/bar/Bar.tsx
index e7f3f7b8b..0dc9223a2 100644
--- a/packages/web-visualization/src/chart/bar/Bar.tsx
+++ b/packages/web-visualization/src/chart/bar/Bar.tsx
@@ -7,6 +7,10 @@ import { getBarPath } from '../utils';
import { DefaultBar } from './';
export type BarBaseProps = {
+ /**
+ * The series ID this bar belongs to.
+ */
+ seriesId?: string;
/**
* X coordinate of the bar (left edge).
*/
@@ -101,6 +105,7 @@ export type BarComponent = React.FC;
*/
export const Bar = memo(
({
+ seriesId,
x,
y,
width,
@@ -124,9 +129,7 @@ export const Bar = memo(
const effectiveOriginY = originY ?? y + height;
- if (!barPath) {
- return null;
- }
+ if (!barPath) return;
return (
(
originY={effectiveOriginY}
roundBottom={roundBottom}
roundTop={roundTop}
+ seriesId={seriesId}
stroke={stroke}
strokeWidth={strokeWidth}
transition={transition}
diff --git a/packages/web-visualization/src/chart/bar/BarChart.tsx b/packages/web-visualization/src/chart/bar/BarChart.tsx
index 038a5a263..0370fe8f8 100644
--- a/packages/web-visualization/src/chart/bar/BarChart.tsx
+++ b/packages/web-visualization/src/chart/bar/BarChart.tsx
@@ -8,13 +8,13 @@ import {
} from '../CartesianChart';
import {
type CartesianAxisConfigProps,
- type CartesianSeries,
defaultChartInset,
defaultStackId,
getChartInset,
} from '../utils';
import { BarPlot, type BarPlotProps } from './BarPlot';
+import type { BarSeries } from './BarStack';
export type BarChartBaseProps = Omit &
Pick<
@@ -35,7 +35,7 @@ export type BarChartBaseProps = Omit;
+ series?: Array;
/**
* Whether to stack the areas on top of each other.
* When true, each series builds cumulative values on top of the previous series.
diff --git a/packages/web-visualization/src/chart/bar/BarStack.tsx b/packages/web-visualization/src/chart/bar/BarStack.tsx
index 8b768a93f..d35a45898 100644
--- a/packages/web-visualization/src/chart/bar/BarStack.tsx
+++ b/packages/web-visualization/src/chart/bar/BarStack.tsx
@@ -11,6 +11,16 @@ import { DefaultBarStack } from './DefaultBarStack';
const EPSILON = 1e-4;
+/**
+ * Extended series type that includes bar-specific properties.
+ */
+export type BarSeries = CartesianSeries & {
+ /**
+ * Custom component to render bars for this series.
+ */
+ BarComponent?: BarComponent;
+};
+
export type BarStackBaseProps = Pick<
BarProps,
'BarComponent' | 'fillOpacity' | 'stroke' | 'strokeWidth' | 'borderRadius'
@@ -18,7 +28,7 @@ export type BarStackBaseProps = Pick<
/**
* Array of series configurations that belong to this stack.
*/
- series: CartesianSeries[];
+ series: BarSeries[];
/**
* The category index for this stack.
*/
@@ -276,6 +286,7 @@ export const BarStack = memo(
// Check if the bar should be rounded based on the baseline, with an epsilon to handle floating-point rounding
roundTop: roundBaseline || Math.abs(barTop - baseline) >= EPSILON,
roundBottom: roundBaseline || Math.abs(barBottom - baseline) >= EPSILON,
+ BarComponent: s.BarComponent,
shouldApplyGap,
});
});
@@ -687,6 +698,7 @@ export const BarStack = memo(
originY={baseline}
roundBottom={bar.roundBottom}
roundTop={bar.roundTop}
+ seriesId={bar.seriesId}
stroke={bar.stroke ?? defaultStroke}
strokeWidth={bar.strokeWidth ?? defaultStrokeWidth}
transition={transition}
diff --git a/packages/web-visualization/src/chart/index.ts b/packages/web-visualization/src/chart/index.ts
index 2265caa59..d88bf4e2a 100644
--- a/packages/web-visualization/src/chart/index.ts
+++ b/packages/web-visualization/src/chart/index.ts
@@ -4,8 +4,11 @@ export * from './axis/index';
export * from './bar/index';
export * from './CartesianChart';
export * from './ChartProvider';
+export * from './ChartTooltip';
+export * from './DefaultChartTooltipItem';
export * from './DonutChart';
export * from './gradient/index';
+export * from './legend/index';
export * from './line/index';
export * from './Path';
export * from './PeriodSelector';
diff --git a/packages/web-visualization/src/chart/legend/DefaultLegendItem.tsx b/packages/web-visualization/src/chart/legend/DefaultLegendItem.tsx
new file mode 100644
index 000000000..450f7d9e5
--- /dev/null
+++ b/packages/web-visualization/src/chart/legend/DefaultLegendItem.tsx
@@ -0,0 +1,147 @@
+import { memo } from 'react';
+import type { SharedProps } from '@coinbase/cds-common/types';
+import { cx } from '@coinbase/cds-web';
+import {
+ Box,
+ HStack,
+ type HStackBaseProps,
+ type HStackDefaultElement,
+ type HStackProps,
+} from '@coinbase/cds-web/layout';
+import { Text } from '@coinbase/cds-web/typography';
+import { css } from '@linaria/core';
+
+import type { Series } from '../utils';
+
+import { DefaultLegendShape, type LegendShapeComponent } from './DefaultLegendShape';
+
+const shapeWrapperCss = css`
+ width: 10px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
+
+const legendItemCss = css`
+ align-items: center;
+`;
+
+export type LegendItemBaseProps = Omit &
+ SharedProps & {
+ /**
+ * Id of the series.
+ */
+ seriesId: string;
+ /**
+ * Display label for the legend item.
+ * Can be a string or a custom ReactNode.
+ * If a ReactNode is provided, it replaces the default Text component.
+ */
+ label: React.ReactNode;
+ /**
+ * Color associated with the series.
+ */
+ color?: Series['color'];
+ /**
+ * Shape to display in the legend.
+ */
+ shape?: Series['legendShape'];
+ /**
+ * Custom component to render the legend shape.
+ * @default DefaultLegendShape
+ */
+ ShapeComponent?: LegendShapeComponent;
+ };
+
+export type LegendItemProps = Omit, 'children'> &
+ LegendItemBaseProps & {
+ /**
+ * Custom class names for the component parts.
+ */
+ classNames?: {
+ /**
+ * Custom class name for the root element.
+ */
+ root?: string;
+ /**
+ * Custom class name for the shape wrapper element.
+ */
+ shapeWrapper?: string;
+ /**
+ * Custom class name for the shape element.
+ */
+ shape?: string;
+ /**
+ * Custom class name for the label element.
+ * @note not applied when label is a ReactNode.
+ */
+ label?: string;
+ };
+ /**
+ * Custom styles for the component parts.
+ */
+ styles?: {
+ /**
+ * Custom styles for the root element.
+ */
+ root?: React.CSSProperties;
+ /**
+ * Custom styles for the shape wrapper element.
+ */
+ shapeWrapper?: React.CSSProperties;
+ /**
+ * Custom styles for the shape element.
+ */
+ shape?: React.CSSProperties;
+ /**
+ * Custom styles for the label element.
+ * @note not applied when label is a ReactNode.
+ */
+ label?: React.CSSProperties;
+ };
+ };
+
+export type LegendItemComponent = React.FC;
+
+export const DefaultLegendItem = memo(
+ ({
+ label,
+ color,
+ shape,
+ ShapeComponent = DefaultLegendShape,
+ gap = 1,
+ className,
+ classNames,
+ style,
+ styles,
+ testID,
+ ...props
+ }: LegendItemProps) => {
+ return (
+
+
+
+
+ {typeof label === 'string' ? (
+
+ {label}
+
+ ) : (
+ label
+ )}
+
+ );
+ },
+);
diff --git a/packages/web-visualization/src/chart/legend/DefaultLegendShape.tsx b/packages/web-visualization/src/chart/legend/DefaultLegendShape.tsx
new file mode 100644
index 000000000..7d0c6e01b
--- /dev/null
+++ b/packages/web-visualization/src/chart/legend/DefaultLegendShape.tsx
@@ -0,0 +1,77 @@
+import React, { memo } from 'react';
+import { cx } from '@coinbase/cds-web';
+import { Box, type BoxProps } from '@coinbase/cds-web/layout';
+import { css } from '@linaria/core';
+
+import type { LegendShape, LegendShapeVariant } from '../utils/chart';
+
+const pillCss = css`
+ width: 6px;
+ height: 24px;
+ border-radius: 3px;
+`;
+
+const circleCss = css`
+ width: 10px;
+ height: 10px;
+ border-radius: 5px;
+`;
+
+const squareCss = css`
+ width: 10px;
+ height: 10px;
+`;
+
+const squircleCss = css`
+ width: 10px;
+ height: 10px;
+ border-radius: 2px;
+`;
+
+const stylesByVariant: Record = {
+ pill: pillCss,
+ circle: circleCss,
+ square: squareCss,
+ squircle: squircleCss,
+};
+
+const isVariantShape = (shape: LegendShape): shape is LegendShapeVariant =>
+ typeof shape === 'string' && shape in stylesByVariant;
+
+export type LegendShapeProps = BoxProps<'div'> & {
+ /**
+ * Color of the legend shape.
+ * @default 'var(--color-fgPrimary)'
+ */
+ color?: string;
+ /**
+ * Shape to display. Can be a preset shape or a custom ReactNode.
+ * @default 'circle'
+ */
+ shape?: LegendShape;
+};
+
+export type LegendShapeComponent = React.FC;
+
+/**
+ * Default shape component for chart legends.
+ * Renders a colored shape (pill, circle, square, or squircle) or a custom ReactNode.
+ */
+export const DefaultLegendShape = memo(
+ ({ color = 'var(--color-fgPrimary)', shape = 'circle', className, style, ...props }) => {
+ // If shape is a custom ReactNode, render it directly
+ if (!isVariantShape(shape)) {
+ return <>{shape}>;
+ }
+
+ const variantStyle = stylesByVariant[shape];
+
+ return (
+
+ );
+ },
+);
diff --git a/packages/web-visualization/src/chart/legend/Legend.tsx b/packages/web-visualization/src/chart/legend/Legend.tsx
new file mode 100644
index 000000000..95fdf5527
--- /dev/null
+++ b/packages/web-visualization/src/chart/legend/Legend.tsx
@@ -0,0 +1,156 @@
+import { forwardRef, memo, useMemo } from 'react';
+import {
+ Box,
+ type BoxBaseProps,
+ type BoxDefaultElement,
+ type BoxProps,
+} from '@coinbase/cds-web/layout';
+
+import { useChartContext } from '../ChartProvider';
+
+import { DefaultLegendItem, type LegendItemComponent } from './DefaultLegendItem';
+import type { LegendShapeComponent } from './DefaultLegendShape';
+
+export type LegendBaseProps = BoxBaseProps & {
+ /**
+ * Array of series IDs to display in the legend.
+ * By default, all series will be displayed.
+ */
+ seriesIds?: string[];
+ /**
+ * Custom component to render each legend item.
+ * @default DefaultLegendItem
+ */
+ ItemComponent?: LegendItemComponent;
+ /**
+ * Custom component to render the legend shape within each item.
+ * Only used when ItemComponent is not provided or is DefaultLegendItem.
+ * @default DefaultLegendShape
+ */
+ ShapeComponent?: LegendShapeComponent;
+};
+
+export type LegendProps = BoxProps &
+ LegendBaseProps & {
+ /**
+ * Custom class names for the component parts.
+ */
+ classNames?: {
+ /**
+ * Custom class name for the root element.
+ */
+ root?: string;
+ /**
+ * Custom class name for each item element.
+ */
+ item?: string;
+ /**
+ * Custom class name for the shape wrapper element within each item.
+ */
+ itemShapeWrapper?: string;
+ /**
+ * Custom class name for the shape element within each item.
+ */
+ itemShape?: string;
+ /**
+ * Custom class name for the label element within each item.
+ * @note not applied when label is a ReactNode.
+ */
+ itemLabel?: string;
+ };
+ /**
+ * Custom styles for the component parts.
+ */
+ styles?: {
+ /**
+ * Custom styles for the root element.
+ */
+ root?: React.CSSProperties;
+ /**
+ * Custom styles for each item element.
+ */
+ item?: React.CSSProperties;
+ /**
+ * Custom styles for the shape wrapper element within each item.
+ */
+ itemShapeWrapper?: React.CSSProperties;
+ /**
+ * Custom styles for the shape element within each item.
+ */
+ itemShape?: React.CSSProperties;
+ /**
+ * Custom styles for the label element within each item.
+ * @note not applied when label is a ReactNode.
+ */
+ itemLabel?: React.CSSProperties;
+ };
+ };
+
+export const Legend = memo(
+ forwardRef(
+ (
+ {
+ flexDirection = 'row',
+ justifyContent = 'center',
+ alignItems = flexDirection === 'row' ? 'center' : 'flex-start',
+ flexWrap = 'wrap',
+ gap = 1,
+ seriesIds,
+ ItemComponent = DefaultLegendItem,
+ ShapeComponent,
+ className,
+ classNames,
+ style,
+ styles,
+ ...props
+ },
+ ref,
+ ) => {
+ const { series } = useChartContext();
+
+ const filteredSeries = useMemo(() => {
+ if (seriesIds === undefined) return series;
+ return series.filter((s) => seriesIds.includes(s.id));
+ }, [series, seriesIds]);
+
+ if (filteredSeries.length === 0) return;
+
+ return (
+
+ {filteredSeries.map((s) => (
+
+ ))}
+
+ );
+ },
+ ),
+);
diff --git a/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx b/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx
new file mode 100644
index 000000000..c54303ed6
--- /dev/null
+++ b/packages/web-visualization/src/chart/legend/__stories__/Legend.stories.tsx
@@ -0,0 +1,828 @@
+import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
+import { Chip } from '@coinbase/cds-web/chips';
+import { Box, HStack, VStack } from '@coinbase/cds-web/layout';
+import { Text } from '@coinbase/cds-web/typography';
+
+import { XAxis, YAxis } from '../../axis';
+import { BarChart, type BarComponentProps, BarPlot, type BarSeries, DefaultBar } from '../../bar';
+import { CartesianChart } from '../../CartesianChart';
+import { useCartesianChartContext } from '../../ChartProvider';
+import { ChartTooltip } from '../../ChartTooltip';
+import { DonutChart } from '../../DonutChart';
+import { LineChart } from '../../line';
+import { PieChart } from '../../pie';
+import { Scrubber } from '../../scrubber';
+import type { CartesianSeries, LegendShapeVariant } from '../../utils/chart';
+import { useScrubberContext } from '../../utils/context';
+import { type LegendItemProps } from '../DefaultLegendItem';
+import { DefaultLegendShape } from '../DefaultLegendShape';
+import { Legend } from '../Legend';
+
+export default {
+ component: Legend,
+ title: 'Components/Chart/Legend',
+};
+
+const Example: React.FC> = ({ children, title }) => {
+ return (
+
+
+ {title}
+
+ {children}
+
+ );
+};
+
+const spectrumColors = [
+ 'blue',
+ 'green',
+ 'orange',
+ 'yellow',
+ 'gray',
+ 'indigo',
+ 'pink',
+ 'purple',
+ 'red',
+ 'teal',
+ 'chartreuse',
+];
+
+const shapes: LegendShapeVariant[] = ['pill', 'circle', 'squircle', 'square'];
+
+const Shapes = () => {
+ return (
+
+
+ {shapes.map((shape) => (
+
+ {spectrumColors.map((color) => (
+
+
+
+ ))}
+
+ ))}
+
+
+ );
+};
+
+const Basic = () => {
+ const pages = useMemo(
+ () => ['Page A', 'Page B', 'Page C', 'Page D', 'Page E', 'Page F', 'Page G'],
+ [],
+ );
+ const pageViews = useMemo(() => [2400, 1398, 9800, 3908, 4800, 3800, 4300], []);
+ const uniqueVisitors = useMemo(() => [4000, 3000, 2000, 2780, 1890, 2390, 3490], []);
+
+ const numberFormatter = useCallback(
+ (value: number) => new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value),
+ [],
+ );
+
+ return (
+
+
+
+
+
+ );
+};
+
+const AutoScale = () => {
+ const precipitationData = [
+ {
+ id: 'northeast',
+ label: 'Northeast',
+ data: [5.14, 1.53, 5.73, 4.29, 3.78, 3.92, 4.19, 5.54, 2.03, 1.42, 2.95, 3.89],
+ color: 'rgb(var(--blue40))',
+ },
+ {
+ id: 'upperMidwest',
+ label: 'Upper Midwest',
+ data: [1.44, 0.49, 2.16, 3.67, 5.44, 6.21, 4.02, 3.67, 0.92, 1.47, 3.05, 1.48],
+ color: 'rgb(var(--green40))',
+ },
+ {
+ id: 'ohioValley',
+ label: 'Ohio Valley',
+ data: [4.74, 1.83, 3.1, 5.42, 5.69, 3.29, 5.02, 2.57, 4.13, 0.79, 4.31, 3.67],
+ color: 'rgb(var(--orange40))',
+ },
+ {
+ id: 'southeast',
+ label: 'Southeast',
+ data: [5.48, 3.11, 5.73, 2.97, 5.45, 3.28, 7.18, 5.67, 7.93, 1.33, 2.69, 3.21],
+ color: 'rgb(var(--yellow40))',
+ },
+ {
+ id: 'northernRockiesAndPlains',
+ label: 'Northern Rockies and Plains',
+ data: [0.64, 1.01, 1.06, 2.12, 3.34, 2.65, 1.54, 1.89, 0.95, 0.57, 1.23, 0.67],
+ color: 'rgb(var(--indigo40))',
+ },
+ {
+ id: 'south',
+ label: 'South',
+ data: [4.19, 1.79, 2.93, 3.84, 5.25, 3.4, 4.27, 1.84, 3.08, 0.52, 4.5, 2.62],
+ color: 'rgb(var(--pink40))',
+ },
+ {
+ id: 'southwest',
+ label: 'Southwest',
+ data: [1.12, 1.5, 1.52, 0.75, 0.76, 1.27, 1.44, 2.01, 0.62, 1.08, 1.23, 0.25],
+ color: 'rgb(var(--purple40))',
+ },
+ {
+ id: 'northwest',
+ label: 'Northwest',
+ data: [5.69, 3.67, 3.32, 1.95, 2.08, 1.31, 0.28, 0.81, 0.95, 2.03, 5.45, 5.8],
+ color: 'rgb(var(--red40))',
+ },
+ {
+ id: 'west',
+ label: 'West',
+ data: [3.39, 4.7, 3.09, 1.07, 0.55, 0.12, 0.23, 0.26, 0.22, 0.4, 2.7, 2.54],
+ color: 'rgb(var(--teal40))',
+ },
+ ];
+
+ const xAxisData = [
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December',
+ ];
+
+ return (
+
+
+
+ 2024 Precipitation by Climate Region
+
+
+
+ `${value} in`} />
+
+
+
+ );
+};
+
+const Position = () => {
+ return (
+
+ }
+ legendPosition="bottom"
+ series={[
+ {
+ id: 'revenue',
+ label: 'Revenue',
+ data: [455, 520, 380, 455, 285, 235],
+ yAxisId: 'revenue',
+ color: 'rgb(var(--yellow40))',
+ legendShape: 'squircle',
+ },
+ {
+ id: 'profitMargin',
+ label: 'Profit Margin',
+ data: [23, 20, 16, 38, 12, 9],
+ yAxisId: 'profitMargin',
+ color: 'var(--color-fgPositive)',
+ legendShape: 'squircle',
+ },
+ ]}
+ xAxis={{
+ data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'],
+ scaleType: 'band',
+ }}
+ yAxis={[
+ {
+ id: 'revenue',
+ domain: { min: 0 },
+ },
+ {
+ id: 'profitMargin',
+ domain: { max: 100, min: 0 },
+ },
+ ]}
+ >
+
+ `$${value}k`}
+ width={60}
+ />
+ `${value}%`}
+ />
+
+
+
+ );
+};
+
+const ShapeVariants = () => {
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'];
+
+ return (
+
+ }
+ legendPosition="left"
+ series={[
+ {
+ id: 'pill',
+ label: 'Pill',
+ data: [120, 150, 130, 170, 160, 190],
+ color: 'rgb(var(--blue40))',
+ legendShape: 'pill',
+ },
+ {
+ id: 'circle',
+ label: 'Circle',
+ data: [80, 110, 95, 125, 115, 140],
+ color: 'rgb(var(--green40))',
+ legendShape: 'circle',
+ },
+ {
+ id: 'square',
+ label: 'Square',
+ data: [60, 85, 70, 100, 90, 115],
+ color: 'rgb(var(--orange40))',
+ legendShape: 'square',
+ },
+ {
+ id: 'squircle',
+ label: 'Squircle',
+ data: [40, 60, 50, 75, 65, 85],
+ color: 'rgb(var(--purple40))',
+ legendShape: 'squircle',
+ },
+ ]}
+ xAxis={{ data: months }}
+ yAxis={{ domain: { min: 0 }, showGrid: true }}
+ />
+
+ );
+};
+
+const DynamicData = () => {
+ const timeLabels = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ];
+
+ const series: CartesianSeries[] = [
+ {
+ id: 'candidate-a',
+ label: 'Candidate A',
+ data: [48, 47, 46, 45, 44, 43, 42, 41, 40, 39, 38, 38],
+ color: 'rgb(var(--blue40))',
+ legendShape: 'circle',
+ },
+ {
+ id: 'candidate-b',
+ label: 'Candidate B',
+ data: [null, null, null, 6, 10, 14, 18, 22, 26, 29, 32, 35],
+ color: 'rgb(var(--orange40))',
+ legendShape: 'circle',
+ },
+ {
+ id: 'candidate-c',
+ label: 'Candidate C',
+ data: [52, 53, 54, 49, 46, 43, 40, 37, 34, 32, 30, 27],
+ color: 'rgb(var(--gray40))',
+ legendShape: 'circle',
+ },
+ ];
+
+ const ValueLegendItem = memo(function ValueLegendItem({
+ seriesId,
+ label,
+ color,
+ shape,
+ }: LegendItemProps) {
+ const { scrubberPosition } = useScrubberContext();
+ const { series, dataLength } = useCartesianChartContext();
+
+ const dataIndex = scrubberPosition ?? dataLength - 1;
+
+ const seriesData = series.find((s) => s.id === seriesId);
+ const rawValue = seriesData?.data?.[dataIndex];
+
+ const formattedValue =
+ rawValue === null || rawValue === undefined ? '--' : `${Math.round(rawValue as number)}%`;
+
+ return (
+
+
+ {label}
+
+ {formattedValue}
+
+
+ );
+ });
+
+ return (
+
+
+
+ Election Polls
+
+
+ }
+ legendPosition="top"
+ series={series}
+ xAxis={{
+ data: timeLabels,
+ }}
+ yAxis={{
+ domain: { max: 100, min: 0 },
+ showGrid: true,
+ tickLabelFormatter: (value) => `${value}%`,
+ }}
+ >
+
+
+
+
+ );
+};
+
+const Interactive = () => {
+ const [emphasizedId, setEmphasizedId] = useState(null);
+
+ const months = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ];
+
+ const seriesConfig = useMemo(
+ () => [
+ {
+ id: 'revenue',
+ label: 'Revenue',
+ data: [120, 150, 180, 165, 190, 210, 240, 220, 260, 280, 310, 350],
+ baseColor: '--blue',
+ },
+ {
+ id: 'expenses',
+ label: 'Expenses',
+ data: [80, 95, 110, 105, 120, 130, 145, 140, 155, 165, 180, 195],
+ baseColor: '--orange',
+ },
+ {
+ id: 'profit',
+ label: 'Profit',
+ data: [40, 55, 70, 60, 70, 80, 95, 80, 105, 115, 130, 155],
+ baseColor: '--green',
+ },
+ ],
+ [],
+ );
+
+ const handleToggle = useCallback((seriesId: string) => {
+ setEmphasizedId((prev) => (prev === seriesId ? null : seriesId));
+ }, []);
+
+ const ChipLegendItem = memo(function ChipLegendItem({ seriesId, label }: LegendItemProps) {
+ const chipRef = useRef(null);
+ const isEmphasized = emphasizedId === seriesId;
+ const config = seriesConfig.find((s) => s.id === seriesId);
+ const baseColor = config?.baseColor ?? '--gray';
+
+ // Restore focus when chip becomes emphasized
+ useEffect(() => {
+ if (isEmphasized && chipRef.current) {
+ chipRef.current.focus();
+ }
+ }, [isEmphasized]);
+
+ return (
+ handleToggle(seriesId)}
+ style={{
+ backgroundColor: `rgb(var(${baseColor}10))`,
+ borderWidth: 0,
+ color: 'var(--color-fg)',
+ outlineColor: `rgb(var(${baseColor}50))`,
+ }}
+ >
+
+
+ {label}
+
+
+ );
+ });
+
+ const series = useMemo(() => {
+ return seriesConfig.map((config) => {
+ const isEmphasized = emphasizedId === config.id;
+ const isDimmed = emphasizedId !== null && !isEmphasized;
+
+ return {
+ id: config.id,
+ label: config.label,
+ data: config.data,
+ color: `rgb(var(${config.baseColor}40))`,
+ opacity: isDimmed ? 0.3 : 1,
+ };
+ });
+ }, [emphasizedId, seriesConfig]);
+
+ return (
+
+
+
+ Financial Overview
+
+ }
+ legendPosition="top"
+ series={series}
+ xAxis={{
+ data: months,
+ }}
+ yAxis={{
+ domain: { min: 0 },
+ showGrid: true,
+ tickLabelFormatter: (value) => `$${value}k`,
+ }}
+ />
+
+
+ );
+};
+
+const PieChartLegend = () => {
+ const series = [
+ {
+ id: 'stocks',
+ data: 45,
+ label: 'Stocks',
+ color: 'rgb(var(--blue40))',
+ legendShape: 'circle' as const,
+ },
+ {
+ id: 'bonds',
+ data: 25,
+ label: 'Bonds',
+ color: 'rgb(var(--green40))',
+ legendShape: 'circle' as const,
+ },
+ {
+ id: 'realEstate',
+ data: 15,
+ label: 'Real Estate',
+ color: 'rgb(var(--orange40))',
+ legendShape: 'circle' as const,
+ },
+ {
+ id: 'commodities',
+ data: 10,
+ label: 'Commodities',
+ color: 'rgb(var(--purple40))',
+ legendShape: 'circle' as const,
+ },
+ {
+ id: 'cash',
+ data: 5,
+ label: 'Cash',
+ color: 'rgb(var(--gray40))',
+ legendShape: 'circle' as const,
+ },
+ ];
+
+ return (
+
+
+
+ Portfolio Allocation
+
+ }
+ legendPosition="right"
+ series={series}
+ />
+
+
+ );
+};
+
+const LegendShapes = () => {
+ const months = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ];
+
+ // Actual revenue (first 9 months)
+ const actualRevenue = [320, 380, 420, 390, 450, 480, 520, 490, 540, null, null, null];
+
+ // Forecasted revenue (last 3 months)
+ const forecastRevenue = [null, null, null, null, null, null, null, null, null, 580, 620, 680];
+
+ const numberFormatter = useCallback(
+ (value: number) =>
+ `$${new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }).format(value)}k`,
+ [],
+ );
+
+ // Pattern settings for dotted fill
+ const patternSize = 4;
+ const dotSize = 1;
+ const patternId = useId();
+ const maskId = useId();
+ const legendPatternId = useId();
+
+ // Custom legend indicator that matches the dotted bar pattern
+ const DottedLegendIndicator = (
+
+ );
+
+ // Custom bar component that renders bars with dotted pattern fill
+ const DottedBarComponent = memo((props) => {
+ const { dataX, x, y } = props;
+ // Create unique IDs per bar so patterns are scoped to each bar
+ const uniqueMaskId = `${maskId}-${dataX}`;
+ const uniquePatternId = `${patternId}-${dataX}`;
+ return (
+ <>
+
+ {/* Pattern positioned relative to this bar's origin */}
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ });
+
+ return (
+
+
+
+ );
+};
+
+const DonutChartLegend = () => {
+ const series = [
+ {
+ id: 'completed',
+ data: 68,
+ label: 'Completed',
+ color: 'var(--color-fgPositive)',
+ legendShape: 'squircle' as const,
+ },
+ {
+ id: 'inProgress',
+ data: 22,
+ label: 'In Progress',
+ color: 'rgb(var(--blue40))',
+ legendShape: 'squircle' as const,
+ },
+ {
+ id: 'pending',
+ data: 10,
+ label: 'Pending',
+ color: 'rgb(var(--gray40))',
+ legendShape: 'squircle' as const,
+ },
+ ];
+
+ return (
+
+
+
+ Task Status
+
+
+
+
+ );
+};
+
+export const All = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/web-visualization/src/chart/legend/index.ts b/packages/web-visualization/src/chart/legend/index.ts
new file mode 100644
index 000000000..ba2be3549
--- /dev/null
+++ b/packages/web-visualization/src/chart/legend/index.ts
@@ -0,0 +1,3 @@
+export * from './DefaultLegendItem';
+export * from './DefaultLegendShape';
+export * from './Legend';
diff --git a/packages/web-visualization/src/chart/line/LineChart.tsx b/packages/web-visualization/src/chart/line/LineChart.tsx
index 5e3b71d00..753b50042 100644
--- a/packages/web-visualization/src/chart/line/LineChart.tsx
+++ b/packages/web-visualization/src/chart/line/LineChart.tsx
@@ -126,6 +126,7 @@ export const LineChart = memo(
yAxisId: s.yAxisId,
stackId: s.stackId,
gradient: s.gradient,
+ legendShape: s.legendShape,
}),
);
}, [series]);
diff --git a/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx b/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx
index 3d0386eb9..419934fe0 100644
--- a/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx
+++ b/packages/web-visualization/src/chart/scrubber/DefaultScrubberBeaconLabel.tsx
@@ -18,12 +18,9 @@ export type DefaultScrubberBeaconLabelProps = ScrubberBeaconLabelProps &
*/
export const DefaultScrubberBeaconLabel = memo(
({
- background = 'var(--color-bg',
color = 'var(--color-fgPrimary)',
elevated = true,
- borderRadius = 4,
font = 'label1',
- verticalAlignment = 'middle',
inset = {
left: labelHorizontalInset,
right: labelHorizontalInset,
@@ -36,13 +33,10 @@ export const DefaultScrubberBeaconLabel = memo(
return (
{label}
diff --git a/packages/web-visualization/src/chart/scrubber/Scrubber.tsx b/packages/web-visualization/src/chart/scrubber/Scrubber.tsx
index cc4b1a05e..d8f32c94d 100644
--- a/packages/web-visualization/src/chart/scrubber/Scrubber.tsx
+++ b/packages/web-visualization/src/chart/scrubber/Scrubber.tsx
@@ -135,6 +135,10 @@ export type ScrubberBaseProps = SharedProps &
* By default, all series will be highlighted.
*/
seriesIds?: string[];
+ /**
+ * Hides the beacon labels while keeping the line label visible (if provided).
+ */
+ hideBeaconLabels?: boolean;
/**
* Hides the scrubber line.
* @note This hides Scrubber's ReferenceLine including the label.
@@ -226,6 +230,7 @@ export const Scrubber = memo(
(
{
seriesIds,
+ hideBeaconLabels,
hideLine,
label,
accessibilityLabel,
@@ -383,7 +388,7 @@ export const Scrubber = memo(
testID={testID}
transitions={beaconTransitions}
/>
- {beaconLabels.length > 0 && (
+ {!hideBeaconLabels && beaconLabels.length > 0 && (
> & {
children: React.ReactNode;
- /**
- * A reference to the root SVG element, where interaction event handlers will be attached.
- */
- svgRef: React.RefObject | null;
};
/**
@@ -19,7 +15,6 @@ export type ScrubberProviderProps = Partial<
*/
export const ScrubberProvider: React.FC = ({
children,
- svgRef,
enableScrubbing,
onScrubberPositionChange,
}) => {
@@ -29,7 +24,7 @@ export const ScrubberProvider: React.FC = ({
throw new Error('ScrubberProvider must be used within a ChartContext');
}
- const { getXScale, getXAxis, series } = chartContext;
+ const { getXScale, getXAxis, series, ref } = chartContext;
const [scrubberPosition, setScrubberPosition] = useState(undefined);
const getDataIndexFromX = useCallback(
@@ -233,9 +228,9 @@ export const ScrubberProvider: React.FC = ({
// Attach event listeners to SVG element
useEffect(() => {
- if (!svgRef?.current || !enableScrubbing) return;
+ if (!ref?.current || !enableScrubbing) return;
- const svg = svgRef.current;
+ const svg = ref.current;
// Add event listeners
svg.addEventListener('mousemove', handleMouseMove);
@@ -258,7 +253,7 @@ export const ScrubberProvider: React.FC = ({
svg.removeEventListener('blur', handleBlur);
};
}, [
- svgRef,
+ ref,
enableScrubbing,
handleMouseMove,
handleMouseLeave,
diff --git a/packages/web-visualization/src/chart/text/ChartText.tsx b/packages/web-visualization/src/chart/text/ChartText.tsx
index 8e731cafb..d442503c7 100644
--- a/packages/web-visualization/src/chart/text/ChartText.tsx
+++ b/packages/web-visualization/src/chart/text/ChartText.tsx
@@ -175,8 +175,8 @@ export const ChartText = memo(
fontWeight,
elevated,
color = 'var(--color-fgMuted)',
- background = elevated ? 'var(--color-bg)' : 'transparent',
- borderRadius,
+ background = elevated ? 'var(--color-bgElevation1)' : 'transparent',
+ borderRadius = 4,
inset: insetInput,
onDimensionsChange,
style,
diff --git a/packages/web-visualization/src/chart/utils/chart.ts b/packages/web-visualization/src/chart/utils/chart.ts
index ca9330e37..df0be9f64 100644
--- a/packages/web-visualization/src/chart/utils/chart.ts
+++ b/packages/web-visualization/src/chart/utils/chart.ts
@@ -17,6 +17,10 @@ export type AxisBounds = {
export const isValidBounds = (bounds: Partial): bounds is AxisBounds =>
bounds.min !== undefined && bounds.max !== undefined;
+export type LegendShapeVariant = 'circle' | 'square' | 'squircle' | 'pill';
+
+export type LegendShape = LegendShapeVariant | React.ReactNode;
+
export type Series = {
/**
* Id of the series.
@@ -33,6 +37,12 @@ export type Series = {
* Color will still be used by scrubber beacon labels
*/
color?: string;
+ /**
+ * Legend shape for this series.
+ * Can be a LegendShapeVariant or a custom ReactNode.
+ * @default 'circle'
+ */
+ legendShape?: LegendShape;
};
export type CartesianSeries = Series & {
diff --git a/packages/web-visualization/src/chart/utils/context.ts b/packages/web-visualization/src/chart/utils/context.ts
index 95ed097f1..588014b1e 100644
--- a/packages/web-visualization/src/chart/utils/context.ts
+++ b/packages/web-visualization/src/chart/utils/context.ts
@@ -2,13 +2,27 @@ import { createContext, useContext } from 'react';
import type { Rect } from '@coinbase/cds-common/types';
import type { AngularAxisConfig, CartesianAxisConfig, RadialAxisConfig } from './axis';
-import type { CartesianSeries, PolarSeries } from './chart';
+import type { CartesianSeries, PolarSeries, Series } from './chart';
import type { ChartScaleFunction } from './scale';
+/**
+ * Chart context type discriminator.
+ */
+export type ChartType = 'cartesian' | 'polar';
+
/**
* Base context value for all chart types.
*/
export type ChartContextValue = {
+ /**
+ * The type of chart.
+ */
+ type: ChartType;
+ /**
+ * The series data for the chart.
+ * Contains common series properties (id, label, color, legendShape).
+ */
+ series: Series[];
/**
* Whether to animate the chart.
*/
@@ -29,13 +43,21 @@ export type ChartContextValue = {
* Length of the data domain.
*/
dataLength: number;
+ /**
+ * Reference to the chart's root element (SVG on web, Canvas on mobile).
+ */
+ ref?: React.RefObject;
};
/**
* Context value for Cartesian (X/Y) coordinate charts.
* Contains axis-specific methods and properties for rectangular coordinate systems.
*/
-export type CartesianChartContextValue = ChartContextValue & {
+export type CartesianChartContextValue = Omit & {
+ /**
+ * The type of chart.
+ */
+ type: 'cartesian';
/**
* The series data for the chart.
*/
@@ -92,7 +114,11 @@ export type CartesianChartContextValue = ChartContextValue & {
* Context value for Polar (Angular/Radial) coordinate charts.
* Contains axis-specific methods and properties for polar coordinate systems.
*/
-export type PolarChartContextValue = ChartContextValue & {
+export type PolarChartContextValue = Omit & {
+ /**
+ * The type of chart.
+ */
+ type: 'polar';
/**
* The series data for the chart.
*/
diff --git a/yarn.lock b/yarn.lock
index 8a5d2380a..286d26860 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2609,6 +2609,7 @@ __metadata:
"@coinbase/cds-lottie-files": "workspace:^"
"@coinbase/cds-utils": "workspace:^"
"@coinbase/cds-web": "workspace:^"
+ "@floating-ui/react-dom": ^2.1.1
react: ^18.3.1
react-dom: ^18.3.1
languageName: unknown