import { PercentageBarChart } from '@coinbase/cds-web-visualization'
- framer-motion: ^10.18.0
PercentageBarChart is a wrapper for BarChart that simplifies the creation of segmented, part-to-whole horizontal visualizations. Charts are built using SVGs.
Basics
The only prop required is series, which takes an array of series objects. Each series object needs an id and a value for data.
<PercentageBarChart height={16} series={[ { id: 'a', data: 70, label: 'Segment A', color: 'var(--color-fgPositive)' }, { id: 'b', data: 45, label: 'Segment B', color: 'var(--color-fgNegative)' }, ]} />
Stack Gap
Use stackGap to add space between segments while keeping the full bar length.
<PercentageBarChart height={20} series={[ { id: 'a', data: 40, label: 'A', color: 'var(--color-fgPositive)' }, { id: 'b', data: 35, label: 'B', color: 'var(--color-fgWarning)' }, { id: 'c', data: 20, label: 'C', color: 'var(--color-accentBoldPurple)' }, ]} stackGap={6} />
Border Radius
Bars use borderRadius like in BarChart.
<PercentageBarChart borderRadius={1000} height={28} series={[ { id: 'a', data: 45, color: 'rgb(var(--purple30))', label: 'A' }, { id: 'b', data: 30, color: 'rgb(var(--blue30))', label: 'B' }, { id: 'c', data: 20, color: 'rgb(var(--teal30))', label: 'C' }, ]} stackGap={2} />
Data
Negative values, null, and missing indices from a shorter data array are treated as zero for that segment at that category. A single-number data value applies to the first category only—later categories count as zero for that series.
<PercentageBarChart height={100} showXAxis showYAxis barMinSize={12} borderRadius={8} series={[ { id: 'a', data: [40, null, 20], label: 'A', color: 'var(--color-fgPositive)' }, { id: 'b', data: [-10, 60, 30], label: 'B', color: 'var(--color-fgWarning)' }, { id: 'c', data: [null, 50], label: 'C', color: 'var(--color-fgMuted)' }, { id: 'd', data: 45, label: 'D', color: 'var(--color-fgNegative)' }, ]} stackGap={2} xAxis={{ showTickMarks: true }} yAxis={{ data: ['Q1', 'Q2', 'Q3'], position: 'left', categoryPadding: 0.45, }} />
If every group sums to zero after clamping, nothing is drawn—handle that in surrounding UI (empty state or copy).
Customization
Bar Stack Spacing
Use categoryPadding on the band axis to adjust spacing between stacks.
<PercentageBarChart legend showXAxis showYAxis barMinSize={18} borderRadius={24} height={240} series={[ { id: 'a', data: [55, 40, 35], label: 'A', color: 'var(--color-fgWarning)' }, { id: 'b', data: [30, 45, 25], label: 'B', color: 'var(--color-accentBoldPurple)' }, { id: 'c', data: [15, 15, 40], label: 'C', color: 'var(--color-fgMuted)' }, ]} stackGap={4} xAxis={{ showTickMarks: true }} yAxis={{ data: ['Q1', 'Q2', 'Q3'], position: 'left', categoryPadding: 0.7, }} />
Minimum Bar Size
barMinSize enforces a minimum pixel size for individual segments (non-zero values), similar to BarChart. Use it when a small share would otherwise be too narrow to see or interact with:
<PercentageBarChart barMinSize={16} height={16} series={[ { id: 'a', data: 99, label: 'Segment A', color: 'var(--color-fgPositive)' }, { id: 'b', data: 0.001, label: 'Segment B', color: 'var(--color-fgNegative)' }, ]} stackGap={2} />
Custom Components
Slanted Stack Gap
A custom BarComponent that replaces the default rectangular inner edges with slanted cuts, creating a parallelogram-shaped gap purely from the path geometry—no stackGap needed. Outer ends stay pill-shaped.
function SlantedStackExample() { function getSlantedHorizontalBarPath( x, y, width, height, borderRadius, pillLeft, pillRight, slantDx, ) { if (width <= 0 || height <= 0 || pillLeft === pillRight) return undefined; const r = Math.min(borderRadius, height / 2, width / 2); const s = Math.min(Math.max(0, slantDx), width - r * 2); const x0 = x, x1 = x + width, y0 = y, y1 = y + height; if (pillLeft && !pillRight) { return [ `M ${x0 + r} ${y0}`, `L ${x1} ${y0}`, `L ${x1 - s} ${y1}`, `L ${x0 + r} ${y1}`, `A ${r} ${r} 0 0 1 ${x0} ${y1 - r}`, `L ${x0} ${y0 + r}`, `A ${r} ${r} 0 0 1 ${x0 + r} ${y0}`, 'Z', ].join(' '); } return [ `M ${x0 + s} ${y0}`, `L ${x1 - r} ${y0}`, `A ${r} ${r} 0 0 1 ${x1} ${y0 + r}`, `L ${x1} ${y1 - r}`, `A ${r} ${r} 0 0 1 ${x1 - r} ${y1}`, `L ${x0} ${y1}`, 'Z', ].join(' '); } const SLANT_DX = 8; const SlantedStackBar = memo(function SlantedStackBar(props) { const { layout } = useCartesianChartContext(); const { x, y, width, height, borderRadius = 4, roundTop, roundBottom, dataX, d: defaultD, fill, fillOpacity, ...rest } = props; const d = useMemo(() => { if (layout !== 'horizontal') { return ( defaultD ?? getBarPath(x, y, width, height, borderRadius, !!roundTop, !!roundBottom, layout) ); } const isLeftmost = Array.isArray(dataX) && Math.abs(dataX[0]) < 1; return ( getSlantedHorizontalBarPath( x, y, width, height, borderRadius, isLeftmost, !isLeftmost, SLANT_DX, ) ?? defaultD ?? getBarPath(x, y, width, height, borderRadius, !!roundTop, !!roundBottom, layout) ); }, [layout, defaultD, dataX, x, y, width, height, borderRadius, roundTop, roundBottom]); if (!d) return null; return ( <Path {...rest} animate clipRect={null} d={d} fill={fill} fillOpacity={fillOpacity} transitions={props.transitions} /> ); }); return ( <PercentageBarChart animate={false} BarComponent={SlantedStackBar} barMinSize={12} borderRadius={24} height={12} series={[ { id: 'team-a', data: 40, color: 'rgb(var(--teal60))' }, { id: 'team-b', data: 61, color: 'var(--color-accentBoldBlue)' }, ]} /> ); }
Dotted bar
A custom BarComponent can render a dotted fill (SVG pattern mask plus outline). Set BarComponent on one series to emphasize a single segment, or on the chart to apply the same look to every segment.
function DottedBarExamples() { const DOTTED_BAR_OUTLINE_STROKE_WIDTH = 2; const DottedBarComponent = memo((props) => { const { dataX, x, y, width, height, borderRadius = 4, roundTop = true, roundBottom = true, } = props; const { layout } = useCartesianChartContext(); const patternSize = 4; const dotSize = 1; const patternId = useId(); const maskId = useId(); const outlineInset = DOTTED_BAR_OUTLINE_STROKE_WIDTH / 2; const outlineGeometry = useMemo(() => { const insetWidth = width - 2 * outlineInset; const insetHeight = height - 2 * outlineInset; if (insetWidth <= 0 || insetHeight <= 0) { return null; } const insetX = x + outlineInset; const insetY = y + outlineInset; const insetRadius = Math.max(0, borderRadius - outlineInset); return { d: getBarPath( insetX, insetY, insetWidth, insetHeight, insetRadius, roundTop, roundBottom, layout, ), height: insetHeight, width: insetWidth, x: insetX, y: insetY, }; }, [borderRadius, height, layout, outlineInset, roundBottom, roundTop, width, x, y]); const uniqueMaskId = `${maskId}-${dataX}`; const uniquePatternId = `${patternId}-${dataX}`; return ( <> <defs> <pattern height={patternSize} id={uniquePatternId} patternUnits="userSpaceOnUse" width={patternSize} x={x} y={y} > <circle cx={patternSize / 2} cy={patternSize / 2} fill="white" r={dotSize} /> </pattern> <mask id={uniqueMaskId}> <DefaultBar {...props} fill={`url(#${uniquePatternId})`} /> </mask> </defs> <g mask={`url(#${uniqueMaskId})`}> <DefaultBar {...props} /> </g> {outlineGeometry ? ( <DefaultBar {...props} {...outlineGeometry} fill="transparent" stroke={props.fill} strokeWidth={DOTTED_BAR_OUTLINE_STROKE_WIDTH} /> ) : ( <DefaultBar {...props} fill="transparent" stroke={props.fill} strokeWidth={DOTTED_BAR_OUTLINE_STROKE_WIDTH} /> )} </> ); }); const dottedBarSeries = [ { id: 'segment-a', data: 60, label: 'Segment A', color: 'rgb(var(--teal60))', BarComponent: DottedBarComponent, }, { id: 'segment-b', data: 30, label: 'Segment B', color: 'rgb(var(--chartreuse50))' }, { id: 'segment-c', data: 10, label: 'Segment C', color: 'rgb(var(--indigo40))' }, ]; const dottedBarSeriesPlain = [ { id: 'segment-a', data: 60, label: 'Segment A', color: 'rgb(var(--teal60))' }, { id: 'segment-b', data: 30, label: 'Segment B', color: 'rgb(var(--chartreuse50))' }, { id: 'segment-c', data: 10, label: 'Segment C', color: 'rgb(var(--indigo40))' }, ]; return ( <VStack gap={3}> <VStack gap={1}> <Text color="fgMuted" font="label2"> First series only </Text> <PercentageBarChart barMinSize={24} height={24} series={dottedBarSeries} stackGap={4} /> </VStack> <VStack gap={1}> <Text color="fgMuted" font="label2"> Chart-level BarComponent </Text> <PercentageBarChart BarComponent={DottedBarComponent} barMinSize={24} height={24} series={dottedBarSeriesPlain} stackGap={4} /> </VStack> </VStack> ); }
Animations
Configure motion with the transitions prop (forwarded to BarChart). Toggle motion with animate.
function AnimationsExample() { const [animate, setAnimate] = useState(true); function randomShares() { const raw = [Math.random() + 0.1, Math.random() + 0.1, Math.random() + 0.1]; const sum = raw[0] + raw[1] + raw[2]; return raw.map((v) => Math.max(1, Math.round((v / sum) * 100))); } function generateData() { return [randomShares(), randomShares(), randomShares()]; } const [data, setData] = useState(generateData); useEffect(() => { const id = setInterval(() => setData(generateData()), 800); return () => clearInterval(id); }, []); const series = [ { id: 'btc', data: data.map((q) => q[0]), label: 'BTC', color: assets.btc.color }, { id: 'eth', data: data.map((q) => q[1]), label: 'ETH', color: assets.eth.color, }, { id: 'other', data: data.map((q) => q[2]), label: 'Other', color: 'var(--color-fgMuted)', }, ]; return ( <VStack gap={2}> <HStack justifyContent="flex-end" alignItems="center" gap={1}> <Switch checked={animate} onChange={() => setAnimate((v) => !v)}> Animate </Switch> </HStack> <PercentageBarChart animate={animate} legend showXAxis showYAxis barMinSize={14} borderRadius={48} height={220} inset={{ left: 24, right: 0, top: 0, bottom: 0 }} legendPosition="top" transitions={{ enter: { type: 'tween', staggerDelay: 0.5 }, update: { type: 'tween' }, }} series={series} stackGap={2} xAxis={{ showTickMarks: true, tickLabelFormatter: (value) => `${value}%`, }} yAxis={{ categoryPadding: 0.75, data: ['Q1 2025', 'Q2 2025', 'Q3 2025'], position: 'left', requestedTickCount: 5, showTickMarks: true, }} /> </VStack> ); }
Composed Examples
Live-updating Data
Using a custom legend, you can create a prediction markets-style chart that stays in sync when data changes.
function LiveFeedExample() { const liveFeedSubtitleBase = 100; const liveFeedYesDollarsPerPercentPoint = (182 - liveFeedSubtitleBase) / 50; const liveFeedNoDollarsPerPercentPoint = (222 - liveFeedSubtitleBase) / 50; function getLiveFeedProjectedValue(seriesId, percentage) { const inverseShare = 100 - percentage; if (seriesId === 'yes') { return Math.round(liveFeedSubtitleBase + inverseShare * liveFeedYesDollarsPerPercentPoint); } if (seriesId === 'no') { return Math.round(liveFeedSubtitleBase + inverseShare * liveFeedNoDollarsPerPercentPoint); } return undefined; } const liveFeedCurrencyFormat = { style: 'currency', currency: 'USD', maximumFractionDigits: 0, }; const LiveFeedCTALegendEntry = memo(function LiveFeedCTALegendEntry({ seriesId, label, color }) { const { series } = useCartesianChartContext(); const seriesData = series.find((s) => s.id === seriesId); const percentage = seriesData?.data?.[0] ?? 0; const projectedValue = getLiveFeedProjectedValue(seriesId, percentage); return ( <Button compact borderRadius={200} style={{ backgroundColor: color, borderColor: color }} width="25%" > <VStack alignItems="center" gap={0.25}> <HStack alignItems="center" gap={0.5}> <Text color="fgInverse" font="label1"> {label} {'· '} </Text> <RollingNumber color="fgInverse" font="label1" format={{ style: 'percent', maximumFractionDigits: 0 }} value={percentage / 100} /> </HStack> {projectedValue != null && ( <HStack alignItems="center" gap={0.5}> <Text color="fgInverse" font="legal"> ${liveFeedSubtitleBase} → </Text> <RollingNumber color="fgInverse" font="legal" format={liveFeedCurrencyFormat} value={projectedValue} /> </HStack> )} </VStack> </Button> ); }); function LiveFeedChart() { const [tick, setTick] = useState(0); const yesValue = 50 + Math.sin(tick * 0.05) * 49; const noValue = 50 - Math.sin(tick * 0.05) * 49; const series = [ { id: 'yes', data: yesValue, label: 'Yes', color: 'var(--color-fgPositive)' }, { id: 'no', data: noValue, label: 'No', color: 'var(--color-fgNegative)' }, ]; useEffect(() => { const id = setInterval(() => setTick((t) => t + 4), 1000); return () => clearInterval(id); }, []); return ( <PercentageBarChart barMinSize={16} borderRadius={1000} height={64} legend={ <Legend EntryComponent={LiveFeedCTALegendEntry} justifyContent="space-evenly" paddingTop={1} /> } legendPosition="bottom" series={series} stackGap={2} /> ); } return <LiveFeedChart />; }
Vertical Mix
Monthly BTC / ETH / Other portfolio allocation across a full year, with layout="vertical" and the legend on the right.
<PercentageBarChart legend showXAxis showYAxis barMinSize={28} borderRadius={48} height={240} layout="vertical" legendPosition="right" series={[ { id: 'btc', data: [55, 52, 48, 45, 50, 58, 62, 57, 53, 49, 44, 46], label: 'BTC', color: assets.btc.color, }, { id: 'eth', data: [30, 33, 35, 38, 32, 27, 25, 29, 34, 37, 40, 38], label: 'ETH', color: assets.eth.color, }, { id: 'other', data: [15, 15, 17, 17, 18, 15, 13, 14, 13, 14, 16, 16], label: 'Other', color: 'var(--color-fgMuted)', }, ]} stackGap={1} xAxis={{ categoryPadding: 0.5, data: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], position: 'bottom', showTickMarks: true, }} />
Buy vs Sell
You can combine a PercentageBarChart with a custom legend to create a buy vs sell chart.
function BuyVsSellExample() { const series = [ { id: 'buy', data: 76, color: 'var(--color-fgPositive)', legendShape: 'circle' }, { id: 'sell', data: 24, color: 'var(--color-fgNegative)', legendShape: 'square' }, ]; function BuyVsSellLegend() { const [buy, sell] = series; return ( <HStack gap={1} justifyContent="space-between"> <DefaultLegendEntry color={buy.color} label={ <Text color="fgMuted" font="legal"> {`${buy.data}% bought`} </Text> } seriesId={buy.id} shape={buy.legendShape} /> <DefaultLegendEntry color={sell.color} label={ <Text color="fgMuted" font="legal"> {`${sell.data}% sold`} </Text> } seriesId={sell.id} shape={sell.legendShape} /> </HStack> ); } return ( <VStack gap={1.5}> <PercentageBarChart barMinSize={8} borderRadius={24} height={8} series={series} stackGap={4} /> <BuyVsSellLegend /> </VStack> ); }