import { CartesianChart } from '@coinbase/cds-web-visualization'
CartesianChart is a customizable, SVG based component that can be used to display a variety of data in a x/y coordinate space. The underlying logic is handled by D3.
Basic Example
AreaChart, BarChart, and LineChart are built on top of CartesianChart and have default functionality for your chart.
<VStack gap={2}> <AreaChart enableScrubbing height={{ base: 150, tablet: 200, desktop: 250 }} series={[ { id: 'prices', data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], }, ]} showYAxis yAxis={{ showGrid: true, }} > <Scrubber /> </AreaChart> <BarChart enableScrubbing height={{ base: 150, tablet: 200, desktop: 250 }} series={[ { id: 'prices', data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], }, ]} showYAxis yAxis={{ showGrid: true, }} > <Scrubber hideOverlay seriesIds={[]} /> </BarChart> <LineChart enableScrubbing height={{ base: 150, tablet: 200, desktop: 250 }} series={[ { id: 'prices', data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], }, ]} showYAxis showArea yAxis={{ showGrid: true, }} > <Scrubber /> </LineChart> </VStack>
Series
Series are the data that will be displayed on the chart. Each series must have a defined id.
Series Data
You can pass in an array of numbers or an array of tuples for the data prop. Passing in null values is equivalent to no data at that index.
function ForecastedPrice() { const ForecastRect = memo(({ startIndex, endIndex }) => { const { drawingArea, getXScale } = useCartesianChartContext(); const xScale = getXScale(); if (!xScale) return; const startX = xScale(startIndex); const endX = xScale(endIndex); return ( <rect x={startX} y={drawingArea.y} width={endX - startX} height={drawingArea.height} fill="var(--color-accentSubtleBlue)" /> ); }); return ( <CartesianChart enableScrubbing height={{ base: 150, tablet: 200, desktop: 250 }} series={[ { id: 'prices', data: [10, 22, 29, 45, 98, 45, 22, 52, 54, 60, 64, 68, 72, 76], color: 'var(--color-accentBoldBlue)', }, { id: 'variance', data: [ null, null, null, null, null, null, null, [52, 52], [50, 57], [52, 63], [55, 75], [57, 77], [59, 79], [60, 80], ], color: 'var(--color-accentBoldBlue)', }, ]} yAxis={{ showGrid: true, }} > <ForecastRect startIndex={7} endIndex={13} /> <Area seriesId="variance" type="solid" fillOpacity={0.3} /> <Line seriesId="prices" /> </CartesianChart> ); }
Series Axis IDs
Each series can have a different yAxisId, allowing you to compare data from different contexts.
<CartesianChart height={{ base: 150, tablet: 200, desktop: 250 }} series={[ { id: 'revenue', data: [455, 520, 380, 455, 190, 235], yAxisId: 'revenue', color: 'var(--color-accentBoldYellow)', }, { id: 'profit', data: [23, 15, 30, 56, 4, 12], yAxisId: 'profit', color: 'var(--color-fgPositive)', }, ]} xAxis={{ data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], scaleType: 'band', }} yAxis={[ { id: 'revenue', }, { id: 'profit', }, ]} > <XAxis showLine showTickMarks /> <YAxis showGrid showLine showTickMarks axisId="revenue" position="left" requestedTickCount={5} tickLabelFormatter={(value) => `$${value}k`} width={60} /> <YAxis showLine showTickMarks axisId="profit" requestedTickCount={5} tickLabelFormatter={(value) => `$${value}k`} /> <BarPlot /> </CartesianChart>
Series Stacks
You can provide a stackId to stack series together.
<AreaChart enableScrubbing height={{ base: 150, tablet: 200, desktop: 250 }} series={[ { id: 'pricesA', data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], stackId: 'prices', color: 'var(--color-accentBoldGreen)', }, { id: 'pricesB', data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], stackId: 'prices', color: 'var(--color-accentBoldPurple)', }, ]} showYAxis yAxis={{ showGrid: true, }} > <Scrubber /> </LineChart>
Axes
You can configure your x and y axes with the xAxis and yAxis props. xAxis accepts an object while yAxis accepts an object or array.
<CartesianChart height={{ base: 150, tablet: 200, desktop: 250 }} series={[ { id: 'prices', data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], }, ]} xAxis={{ scaleType: 'band', }} yAxis={{ domain: { min: 0 }, }} > <YAxis showLine showTickMarks showGrid /> <XAxis showLine showTickMarks /> <BarPlot /> </CartesianChart>
For more info, learn about XAxis and YAxis configuration.
Inset
You can adjust the inset around the entire chart (outside the axes) with the inset prop. This is useful for when you want to have components that are outside of the drawing area of the data but still within the chart svg.
You can also remove the default inset, such as to have a compact line chart.
function Insets() { const data = [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58]; const formatPrice = useCallback((dataIndex) => { const price = data[dataIndex]; return `$${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; }, []); return ( <HStack gap={2}> <VStack gap={1}> <Text font="label1">No inset</Text> <LineChart height={100} inset={0} series={[ { id: 'prices', data, }, ]} yAxis={{ domainLimit: 'strict' }} showArea style={{ border: '2px solid var(--color-fgPrimary)' }} /> </VStack> <VStack gap={1}> <Text font="label1">Custom inset</Text> <LineChart enableScrubbing height={100} inset={{ left: 10, top: 16, right: 10, bottom: 10 }} series={[ { id: 'prices', data, }, ]} yAxis={{ domainLimit: 'strict' }} showArea style={{ border: '2px solid var(--color-fgPrimary)' }} > <Scrubber label={formatPrice} /> </LineChart> </VStack> <VStack gap={1}> <Text font="label1">Default inset</Text> <LineChart enableScrubbing height={100} series={[ { id: 'prices', data, }, ]} yAxis={{ domainLimit: 'strict' }} showArea style={{ border: '2px solid var(--color-fgPrimary)' }} > <Scrubber label={formatPrice} /> </LineChart> </VStack> </HStack> ); }
Scrubbing
CartesianChart has built-in scrubbing functionality that can be enabled with the enableScrubbing prop. This will then enable the usage of onScrubberPositionChange to get the current position of the scrubber as the user interacts with the chart.
function Scrubbing() { const [scrubIndex, setScrubIndex] = useState(undefined); const onScrubberPositionChange = useCallback((index) => { setScrubIndex(index); }, []); return ( <VStack gap={2}> <Text font="label1">Scrubber index: {scrubIndex ?? 'none'}</Text> <LineChart enableScrubbing onScrubberPositionChange={onScrubberPositionChange} height={{ base: 150, tablet: 200, desktop: 250 }} series={[ { id: 'prices', data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58], }, ]} showYAxis showArea yAxis={{ showGrid: true, width: 32, }} inset={{ right: 0 }} > <Scrubber /> </LineChart> </VStack> ); }
Animations
CartesianChart delegates transition control to its child components. You can also disable all animations chart-wide by passing animate={false} on CartesianChart.
Enter Only
Disable the update morph animation while keeping a slow enter reveal. Data changes snap instantly but the initial chart appearance animates. Useful when new data arrives frequently and morphing would be distracting.
function EnterAnimationOnly() { const dataCount = 15; const updateInterval = 2500; function generateNextValue(prev: number) { const step = Math.random() * 30 - 15; return Math.max(0, Math.min(100, prev + step)); } function generateInitialData() { const data = [50]; for (let i = 1; i < dataCount; i++) { data.push(generateNextValue(data[i - 1])); } return data; } function Chart() { const [data, setData] = useState(generateInitialData); useEffect(() => { const intervalId = setInterval(() => { setData((current) => { const last = current[current.length - 1]; return [...current.slice(1), generateNextValue(last)]; }); }, updateInterval); return () => clearInterval(intervalId); }, []); return ( <CartesianChart height={{ base: 200, tablet: 225, desktop: 250 }} inset={{ top: 16, bottom: 16, left: 16, right: 16 }} series={[{ id: 'values', data }]} aria-hidden="true" > <Line seriesId="values" strokeWidth={3} transitions={{ update: null, enter: { type: 'tween', duration: 1.0 }, }} /> </CartesianChart> ); } return <Chart />; }
Update Only
Disable the enter reveal animation while keeping a slow update morph. The chart appears instantly but data changes animate smoothly. Useful when the chart is embedded in content that should not animate on load.
function UpdateAnimationOnly() { const dataCount = 15; const updateInterval = 2500; function generateNextValue(prev: number) { const step = Math.random() * 30 - 15; return Math.max(0, Math.min(100, prev + step)); } function generateInitialData() { const data = [50]; for (let i = 1; i < dataCount; i++) { data.push(generateNextValue(data[i - 1])); } return data; } function Chart() { const [data, setData] = useState(generateInitialData); useEffect(() => { const intervalId = setInterval(() => { setData((current) => { const last = current[current.length - 1]; return [...current.slice(1), generateNextValue(last)]; }); }, updateInterval); return () => clearInterval(intervalId); }, []); return ( <CartesianChart height={{ base: 200, tablet: 225, desktop: 250 }} inset={{ top: 16, bottom: 16, left: 16, right: 16 }} series={[{ id: 'values', data }]} aria-hidden="true" > <Line seriesId="values" strokeWidth={3} transitions={{ enter: null, update: { type: 'spring', stiffness: 900, damping: 120, mass: 8 }, }} /> </CartesianChart> ); } return <Chart />; }
Mixed Transitions Per Child
Each child component can define its own transitions independently. Here, the Line uses a spring morph while the bars snap with no update animation. This lets you fine-tune each visual layer within a single chart.
function MixedTransitions() { const dataCount = 10; const updateInterval = 2000; function generateNextValue(prev: number) { const step = Math.random() * 20 - 10; return Math.max(10, Math.min(100, prev + step)); } function generateInitialData() { const data = [50]; for (let i = 1; i < dataCount; i++) { data.push(generateNextValue(data[i - 1])); } return data; } function Chart() { const [data, setData] = useState(generateInitialData); useEffect(() => { const intervalId = setInterval(() => { setData((current) => { const last = current[current.length - 1]; return [...current.slice(1), generateNextValue(last)]; }); }, updateInterval); return () => clearInterval(intervalId); }, []); return ( <CartesianChart height={{ base: 200, tablet: 225, desktop: 250 }} inset={{ top: 16, bottom: 16, left: 16, right: 16 }} series={[ { id: 'line', data, color: 'var(--color-accentBoldBlue)', yAxisId: 'default' }, { id: 'bars', data: data.map((d) => d * 0.3), color: 'var(--color-accentBoldPurple)', yAxisId: 'bars', }, ]} xAxis={{ scaleType: 'band' }} yAxis={[ { id: 'default' }, { id: 'bars', range: ({ min, max }) => ({ min: max - 48, max }) }, ]} aria-hidden="true" > <BarPlot seriesIds={['bars']} transitions={{ update: null, enter: { type: 'tween', duration: 0.6 }, }} /> <Line seriesId="line" strokeWidth={3} transitions={{ update: { type: 'spring', stiffness: 700, damping: 20 }, }} /> </CartesianChart> ); } return <Chart />; }
No Animations
You can disable all animations chart-wide by setting animate to false on CartesianChart. This is useful for static snapshots or when performance is a concern. Compare this to the animated examples above — data still updates, but changes snap instantly without any transition.
function DisableAnimations() { const dataCount = 15; const updateInterval = 2500; function generateNextValue(prev: number) { const step = Math.random() * 30 - 15; return Math.max(0, Math.min(100, prev + step)); } function generateInitialData() { const data = [50]; for (let i = 1; i < dataCount; i++) { data.push(generateNextValue(data[i - 1])); } return data; } function Chart() { const [data, setData] = useState(generateInitialData); useEffect(() => { const intervalId = setInterval(() => { setData((current) => { const last = current[current.length - 1]; return [...current.slice(1), generateNextValue(last)]; }); }, updateInterval); return () => clearInterval(intervalId); }, []); return ( <CartesianChart animate={false} height={{ base: 200, tablet: 225, desktop: 250 }} inset={{ top: 16, bottom: 16, left: 16, right: 16 }} series={[{ id: 'values', data }]} aria-hidden="true" > <Line seriesId="values" showArea strokeWidth={3} /> </CartesianChart> ); } return <Chart />; }
Customization
Price with Volume
You can showcase the price and volume of an asset over time within one chart.
function PriceWithVolume() { const [scrubIndex, setScrubIndex] = useState(null); const btcData = btcCandles.slice(0, 180).reverse(); const btcPrices = btcData.map((candle) => parseFloat(candle.close)); const btcVolumes = btcData.map((candle) => parseFloat(candle.volume)); const btcDates = btcData.map((candle) => new Date(parseInt(candle.start) * 1000)); const formatPrice = useCallback((price) => { return `$${price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; }, []); const formatPriceInThousands = useCallback((price) => { return `$${(price / 1000).toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 2, })}k`; }, []); const formatVolume = useCallback((volume) => { return `${(volume / 1000).toFixed(2)}K`; }, []); const formatDate = useCallback((date) => { return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', }); }, []); const displayIndex = scrubIndex ?? btcPrices.length - 1; const currentPrice = btcPrices[displayIndex]; const currentVolume = btcVolumes[displayIndex]; const currentDate = btcDates[displayIndex]; const priceChange = displayIndex > 0 ? (currentPrice - btcPrices[displayIndex - 1]) / btcPrices[displayIndex - 1] : 0; const accessibilityLabel = useMemo(() => { if (scrubIndex === null) return `Current Bitcoin price: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; return `Bitcoin price at ${formatDate(currentDate)}: ${formatPrice(currentPrice)}, Volume: ${formatVolume(currentVolume)}`; }, [scrubIndex, currentPrice, currentVolume, currentDate, formatPrice, formatVolume, formatDate]); const ThinSolidLine = memo((props) => <SolidLine {...props} strokeWidth={1} />); const headerId = useId(); return ( <VStack gap={2}> <SectionHeader id={headerId} style={{ padding: 0 }} title={<Text font="title1">Bitcoin</Text>} balance={<Text font="title2">{formatPrice(currentPrice)}</Text>} end={ <HStack gap={2}> <VStack justifyContent="center" alignItems="flex-end"> <Text font="label1">{formatDate(currentDate)}</Text> <Text font="label2">{formatVolume(currentVolume)}</Text> </VStack> <VStack justifyContent="center"> <RemoteImage source={assets.btc.imageUrl} size="xl" shape="circle" /> </VStack> </HStack> } /> <CartesianChart enableScrubbing onScrubberPositionChange={setScrubIndex} height={250} series={[ { id: 'prices', data: btcPrices, color: assets.btc.color, yAxisId: 'price', }, { id: 'volume', data: btcVolumes, color: 'var(--color-fgMuted)', yAxisId: 'volume', }, ]} style={{ outlineColor: assets.btc.color }} xAxis={{ scaleType: 'band', range: ({ min, max }) => ({ min, max: max - 16 }) }} yAxis={[ { id: 'price', domain: ({ min, max }) => ({ min: min * 0.9, max }), }, { id: 'volume', range: ({ min, max }) => ({ min: max - 32, max }), }, ]} accessibilityLabel={accessibilityLabel} aria-labelledby={headerId} inset={{ top: 8, left: 8, right: 0, bottom: 0 }} > <YAxis axisId="price" showGrid tickLabelFormatter={formatPriceInThousands} width={48} GridLineComponent={ThinSolidLine} /> <BarPlot seriesIds={['volume']} /> <Line seriesId="prices" showArea /> <Scrubber seriesIds={['prices']} /> </CartesianChart> </VStack> ); }
Earnings History
You can also create your own type of cartesian chart by using getSeriesData, getXScale, and getYScale directly.
function EarningsHistory() { const CirclePlot = memo(({ seriesId, opacity = 1 }) => { const { drawingArea, getSeries, getSeriesData, getXScale, getYScale } = useCartesianChartContext(); const series = getSeries(seriesId); const data = getSeriesData(seriesId); const xScale = getXScale(); const yScale = getYScale(series?.yAxisId); if (!xScale || !yScale || !data || !isCategoricalScale(xScale)) return null; const yScaleSize = Math.abs(yScale.range()[1] - yScale.range()[0]); // Have circle diameter be the smaller of the x scale bandwidth or 10% of the y space available const diameter = Math.min(xScale.bandwidth(), yScaleSize / 10); return ( <g> {data.map((value, index) => { if (value === null || value === undefined) return null; // Get x position from band scale - center of the band const xPos = xScale(index); if (xPos === undefined) return null; const centerX = xPos + xScale.bandwidth() / 2; // Get y position from value const yValue = Array.isArray(value) ? value[1] : value; const centerY = yScale(yValue); if (centerY === undefined) return null; return ( <circle key={`${seriesId}-${index}`} cx={centerX} cy={centerY} fill={series?.color || 'var(--color-fgPrimary)'} opacity={opacity} r={diameter / 2} /> ); })} </g> ); }); const quarters = useMemo(() => ['Q1', 'Q2', 'Q3', 'Q4'], []); const estimatedEPS = useMemo(() => [1.71, 1.82, 1.93, 2.34], []); const actualEPS = useMemo(() => [1.68, 1.83, 2.01, 2.24], []); const formatEarningAmount = useCallback((value) => { return `$${value.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}`; }, []); const surprisePercentage = useCallback( (index) => { const percentage = (actualEPS[index] - estimatedEPS[index]) / estimatedEPS[index]; const percentageString = percentage.toLocaleString('en-US', { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2, }); return ( <tspan style={{ fill: percentage > 0 ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)', fontWeight: 'bold', }} > {percentage > 0 ? '+' : ''} {percentageString} </tspan> ); }, [actualEPS, estimatedEPS], ); const LegendEntry = memo(({ opacity = 1, label }) => { return ( <Box alignItems="center" gap={0.5}> <LegendDot opacity={opacity} /> <Text font="label2">{label}</Text> </Box> ); }); const LegendDot = memo((props) => { return <Box borderRadius={1000} width={10} height={10} background="bgPositive" {...props} />; }); return ( <VStack gap={0.5}> <CartesianChart animate={false} height={250} padding={0} series={[ { id: 'estimatedEPS', data: estimatedEPS, color: 'var(--color-bgPositive)', }, { id: 'actualEPS', data: actualEPS, color: 'var(--color-bgPositive)' }, ]} xAxis={{ scaleType: 'band', categoryPadding: 0.25 }} > <YAxis showGrid position="left" requestedTickCount={3} tickLabelFormatter={formatEarningAmount} /> <XAxis height={20} tickLabelFormatter={(index) => quarters[index]} /> <XAxis height={20} tickLabelFormatter={surprisePercentage} /> <CirclePlot opacity={0.5} seriesId="estimatedEPS" /> <CirclePlot seriesId="actualEPS" /> </CartesianChart> <HStack justifyContent="flex-end" gap={2}> <LegendEntry opacity={0.5} label="Estimated EPS" /> <LegendEntry label="Actual EPS" /> </HStack> </VStack> ); }
Trading Trends
You can have multiple axes with different domains and ranges to showcase different pieces of data over the time time period.
function TradingTrends() { const profitData = [34, 24, 28, -4, 8, -16, -3, 12, 24, 18, 20, 28]; const gains = profitData.map((value) => (value > 0 ? value : 0)); const losses = profitData.map((value) => (value < 0 ? value : 0)); const renderProfit = useCallback((value) => { return `$${value}M`; }, []); const ThinSolidLine = memo((props) => ( <SolidLine {...props} strokeWidth={1} strokeLinecap="butt" /> )); const ThickSolidLine = memo((props) => ( <SolidLine {...props} strokeWidth={2} strokeLinecap="butt" /> )); return ( <CartesianChart height={250} series={[ { id: 'gains', data: gains, yAxisId: 'profit', color: 'var(--color-bgPositive)', stackId: 'bars', }, { id: 'losses', data: losses, yAxisId: 'profit', color: 'var(--color-bgNegative)', stackId: 'bars', }, { id: 'revenue', data: [128, 118, 122, 116, 120, 114, 118, 122, 126, 130, 134, 138], yAxisId: 'revenue', color: 'var(--color-fgMuted)', }, ]} xAxis={{ scaleType: 'band', data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], }} yAxis={[ { id: 'profit', range: ({ min, max }) => ({ min: min, max: max - 64 }), domain: { min: -40, max: 40 }, }, { id: 'revenue', range: ({ min, max }) => ({ min: max - 64, max }), domain: { min: 100 } }, ]} > <YAxis axisId="profit" position="left" showGrid tickLabelFormatter={renderProfit} GridLineComponent={ThinSolidLine} /> <XAxis /> <ReferenceLine LineComponent={ThickSolidLine} dataY={0} yAxisId="profit" stroke="rgb(var(--gray15))" /> <BarPlot seriesIds={['gains', 'losses']} /> <Line seriesId="revenue" showArea /> </CartesianChart> ); }