Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
93ca570
Build out interaction provider
hcopp Jan 15, 2026
5aef1c3
Support highlighting bars
hcopp Jan 15, 2026
a154505
wip lines
hcopp Jan 16, 2026
d6e61f4
Merge branch 'master' into hunter/chart-interaction
hcopp Jan 26, 2026
c6910e3
Continue highlight support
hcopp Jan 28, 2026
f1468b4
Update chart
hcopp Jan 28, 2026
39914a4
Fix docs
hcopp Jan 28, 2026
ad7f18b
Remove research docs
hcopp Jan 28, 2026
f34a46c
Cleanup logic
hcopp Jan 28, 2026
7e921f1
Add ref forwarding to doc examples
hcopp Jan 28, 2026
efdfaab
Update default insets
hcopp Jan 29, 2026
9d1489c
Fix scrubber types
hcopp Jan 29, 2026
b229625
Separate cartesianseries from series
hcopp Jan 29, 2026
e2024af
Improve highlight
hcopp Jan 30, 2026
3ff34e0
Cleanup web line
hcopp Jan 30, 2026
169c50c
Merge branch 'master' into hunter/chart-interaction
hcopp Feb 9, 2026
e7f0ca6
Merge branch 'master' into hunter/chart-interaction
hcopp Feb 15, 2026
26eb184
Continue work
hcopp Feb 26, 2026
db1cf8d
Merge branch 'hunter/chart-interaction' into hunter/chart-interaction-2
hcopp Mar 13, 2026
9960ba1
Update series
hcopp Mar 13, 2026
ea5a06c
Merge branch 'master' into hunter/chart-interaction-2
hcopp Apr 21, 2026
92d6744
Cleanup types
hcopp Apr 21, 2026
2bce320
Fix lint and drop CartesianSeries
hcopp Apr 21, 2026
593ef9a
Cleanup types and jsdocs
hcopp Apr 21, 2026
5be4937
Update JSDocs
hcopp Apr 21, 2026
6d9d5c9
Update types
hcopp Apr 21, 2026
b9a62fd
Omit highlight functionality from pbc
hcopp Apr 22, 2026
56b5903
Support horizontal layouts
hcopp Apr 22, 2026
c1c218b
Cleanup opacity logic
hcopp Apr 22, 2026
c34bfe9
Drop falsy defaults
hcopp Apr 22, 2026
17c2710
Fix chart transition
hcopp Apr 22, 2026
f61524f
Update jsdocs
hcopp Apr 22, 2026
405c289
Merge branch 'master' into hunter/chart-interaction-2
hcopp Apr 22, 2026
9eb6d87
Update BarChart docs
hcopp Apr 22, 2026
4105210
Switch from scrubbing to highlighting in docs
hcopp Apr 23, 2026
5af08a2
Deprecate ScrubberContext
hcopp Apr 23, 2026
04f7d1a
Improve docs and storyboook
hcopp Apr 23, 2026
f05dfd2
Support optional highlight items
hcopp Apr 23, 2026
72e32a4
Update highlight provider
hcopp Apr 23, 2026
0719408
Remove web interaction stories
hcopp Apr 23, 2026
572413f
Update highlight context
hcopp Apr 23, 2026
e45d5e6
Update CartesianChart docs
hcopp Apr 23, 2026
1900ac5
Update highlight context jsdocs
hcopp Apr 23, 2026
1279480
Simplify logic for highlighting
hcopp Apr 23, 2026
74e6a54
Simplify enableHighlighting
hcopp Apr 23, 2026
2c15d04
Adjust highlighting
hcopp Apr 23, 2026
c342286
Update LineChart docs
hcopp Apr 23, 2026
e6752dd
Deprecate ScrubberProvider
hcopp Apr 23, 2026
155dec9
Merge branch 'master' into hunter/chart-interaction-2
hcopp Apr 24, 2026
5a2eaf1
Merge branch 'hunter/chart-interaction-2' of https://github.com/coinb…
Apr 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ function CustomBaseline() {

return (
<AreaChart
enableScrubbing
enableHighlighting
showLines
showYAxis
accessibilityLabel={chartAccessibilityLabel}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ function CustomBaseline() {

return (
<AreaChart
enableScrubbing
enableHighlighting
showLines
showYAxis
accessibilityLabel={chartAccessibilityLabel}
Expand Down
51 changes: 42 additions & 9 deletions apps/docs/docs/components/charts/BarChart/_mobileExamples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -871,27 +871,51 @@ You can set `delay` (in milliseconds) on transitions to add a pause before the a

## Accessibility

BarChart supports screen reader accessibility through `enableScrubbing` and `getScrubberAccessibilityLabel`. You do not need to add a [Scrubber](/components/charts/Scrubber) component—the chart renders invisible tap targets that screen reader users can navigate with swipe or tap.
With `enableHighlighting`, use `accessibilityLabel` for VoiceOver:

- Pass a **string** for a static summary on the chart surface (no extra tap targets).
- Pass a **function** `(items: HighlightedItem[]) => string` to label invisible regions the chart overlays for swipe/tap navigation. The array is the current highlight state: multi-touch includes one entry per pointer, and VoiceOver region mode invokes the function with a **single-element** array for that region’s bucket (for example `[{ dataIndex: 2 }]`). Handle `items.length === 0` for summaries, and check `typeof item.dataIndex === 'number'` on each entry before indexing your data. Set `accessibilityMode` to `'item'` for one region per category (typical for bars) or leave the default `'chunked'` for fewer regions on long series.

You do not need a [Scrubber](/components/charts/Scrubber) for screen reader support.

`getScrubberAccessibilityLabel` is still supported as an alternative: it drives the separate sampled segments from `ScrubberAccessibilityView` (by `dataIndex`), if you prefer that stepping model instead of `accessibilityLabel` as a function.

```tsx
import type { HighlightedItem } from '@coinbase/cds-mobile-visualization/chart';

function AccessibleBarChart() {
const categories = useMemo(() => ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun'], []);
const values = useMemo(() => [40, 65, 55, 80, 72, 90], []);

const getScrubberAccessibilityLabel = useCallback(
(index: number) => `${categories[index]}: ${values[index]}`,
const accessibilityLabel = useCallback(
(items: HighlightedItem[]) => {
if (items.length === 0) {
return `Bar chart, ${values.length} months. Swipe to navigate.`;
}
const parts = items
.map((item) => {
if (typeof item.dataIndex !== 'number') return null;
const i = item.dataIndex;
return `${categories[i]}: ${values[i]}`;
})
.filter((line): line is string => line !== null);
return (
parts.join('; ') || `Bar chart, ${values.length} months. Swipe to navigate.`
);
},
[categories, values],
);

return (
<BarChart
enableScrubbing
enableHighlighting
accessibilityLabel={accessibilityLabel}
accessibilityMode="item"
fadeOnHighlight
showXAxis
showYAxis
accessibilityLabel={`Bar chart with ${values.length} months. Swipe to navigate.`}
height={180}
inset={{ top: 16, right: 16, bottom: 0, left: 0 }}
getScrubberAccessibilityLabel={getScrubberAccessibilityLabel}
series={[{ id: 'bars', data: values, color: 'var(--color-accentBoldPurple)' }]}
xAxis={{ data: categories, showGrid: true }}
yAxis={{ domain: { min: 0 }, showGrid: true }}
Expand All @@ -918,7 +942,12 @@ function Candlesticks() {

const BandwidthHighlight = memo(({ stroke }: LineComponentProps) => {
const { getXSerializableScale, drawingArea } = useCartesianChartContext();
const { scrubberPosition } = useScrubberContext();
const { highlight, enabled } = useHighlightContext();
const scrubberPosition = useDerivedValue(() => {
if (!enabled) return undefined;
const idx = highlight.value[0]?.dataIndex;
return typeof idx === 'number' ? idx : undefined;
}, [highlight, enabled]);
const xScale = useMemo(() => getXSerializableScale(), [getXSerializableScale]);

const rectWidth = useMemo(() => {
Expand Down Expand Up @@ -1009,6 +1038,10 @@ function Candlesticks() {
[stockData],
);

const onHighlightChange = useCallback((items) => {
setCurrentIndex(items[0]?.dataIndex ?? undefined);
}, []);

return (
<VStack gap={2}>
<Text aria-live="polite" font="headline" id={infoTextId}>
Expand All @@ -1017,13 +1050,13 @@ function Candlesticks() {
: formatThousandsPrice(parseFloat(stockData[stockData.length - 1].close))}
</Text>
<CartesianChart
enableScrubbing
enableHighlighting
animate={false}
aria-labelledby={infoTextId}
borderRadius={0}
height={150}
inset={{ top: 8, bottom: 8, left: 0, right: 0 }}
onScrubberPositionChange={setCurrentIndex}
onHighlightChange={onHighlightChange}
series={[
{
id: 'stock-prices',
Expand Down
174 changes: 103 additions & 71 deletions apps/docs/docs/components/charts/BarChart/_webExamples.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ function HorizontalBars() {

return (
<BarChart
enableHighlighting
fadeOnHighlight
height={400}
layout="horizontal"
series={[
Expand Down Expand Up @@ -932,74 +934,101 @@ function Candlesticks() {

const ThinSolidLine = memo((props) => <SolidLine {...props} strokeWidth={1} />);

// Custom line component that renders a rect to highlight the entire bandwidth
const BandwidthHighlight = memo(({ d, stroke }) => {
const { getXScale, drawingArea, getXAxis } = useCartesianChartContext();
const { scrubberPosition } = useScrubberContext();
const xScale = getXScale();
const xAxis = getXAxis();

if (!xScale || scrubberPosition === undefined) return;

const xPos = xScale(scrubberPosition);

if (xPos === undefined) return;

return (
<rect
fill={stroke}
height={drawingArea.height}
width={xScale.bandwidth()}
x={xPos}
y={drawingArea.y}
/>
);
});

const candlesData = stockData.map((data) => [parseFloat(data.low), parseFloat(data.high)]);

const staggerDelay = 0.25;

const CandlestickBarComponent = memo(({ x, y, width, height, originY, dataX, ...props }) => {
const { getYScale, drawingArea } = useCartesianChartContext();
const yScale = getYScale();
const CandlestickBarComponent = memo(
({ x, y, width, height, originY, dataX, dataY, fadeOnHighlight, seriesId, ...props }) => {
const { getYScale, drawingArea } = useCartesianChartContext();
const highlightContext = useHighlightContext();
const yScale = getYScale();

const dataIndex = React.useMemo(() => {
if (typeof dataX === 'number') return dataX;
if (typeof dataY === 'number') return dataY;
return null;
}, [dataX, dataY]);

const { enabled: highlightEnabled, highlight, scope } = highlightContext;
const highlightByDataIndex = scope.dataIndex ?? false;
const highlightBySeries = scope.series ?? false;

const highlightOpacity = React.useMemo(() => {
if (!fadeOnHighlight || !highlightEnabled) return 1;

let opacity = 1;
if (highlight.length > 0) {
const isHighlighted = highlight.some((item) => {
const indexMatch =
!highlightByDataIndex ||
(typeof item.dataIndex === 'number' && item.dataIndex === dataIndex);
const seriesMatch =
!highlightBySeries || item.seriesId == null || item.seriesId === seriesId;
return indexMatch && seriesMatch;
});
opacity = isHighlighted ? 1 : 0.3;
}
return opacity;
}, [
fadeOnHighlight,
highlightEnabled,
highlight,
highlightByDataIndex,
highlightBySeries,
dataIndex,
seriesId,
]);

const normalizedX = React.useMemo(
() => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0),
[x, drawingArea.x, drawingArea.width],
);

const normalizedX = React.useMemo(
() => (drawingArea.width > 0 ? (x - drawingArea.x) / drawingArea.width : 0),
[x, drawingArea.x, drawingArea.width],
);
const transition = React.useMemo(
() => ({
type: 'tween',
duration: 0.325,
delay: normalizedX * staggerDelay,
}),
[normalizedX],
);

const transition = React.useMemo(
() => ({
type: 'tween',
duration: 0.325,
delay: normalizedX * staggerDelay,
}),
[normalizedX],
);
const fadeTransition = { duration: 0.1, ease: 'easeOut' };

const wickX = x + width / 2;
const wickX = x + width / 2;

const timePeriodValue = stockData[dataX];
const timePeriodValue = stockData[dataX];

const open = parseFloat(timePeriodValue.open);
const close = parseFloat(timePeriodValue.close);
const open = parseFloat(timePeriodValue.open);
const close = parseFloat(timePeriodValue.close);

const bullish = open < close;
const color = bullish ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)';
const openY = yScale?.(open) ?? 0;
const closeY = yScale?.(close) ?? 0;
const bullish = open < close;
const color = bullish ? 'var(--color-fgPositive)' : 'var(--color-fgNegative)';
const openY = yScale?.(open) ?? 0;
const closeY = yScale?.(close) ?? 0;

const bodyHeight = Math.abs(openY - closeY);
const bodyY = openY < closeY ? openY : closeY;
const bodyHeight = Math.abs(openY - closeY);
const bodyY = openY < closeY ? openY : closeY;

return (
<m.g animate={{ opacity: 1, y: 0 }} initial={{ opacity: 0, y: 12 }} transition={transition}>
<line stroke={color} strokeWidth={1} x1={wickX} x2={wickX} y1={y} y2={y + height} />
<rect fill={color} height={bodyHeight} width={width} x={x} y={bodyY} />
</m.g>
);
});
const candlestick = (
<m.g animate={{ opacity: 1, y: 0 }} initial={{ opacity: 0, y: 12 }} transition={transition}>
<line stroke={color} strokeWidth={1} x1={wickX} x2={wickX} y1={y} y2={y + height} />
<rect fill={color} height={bodyHeight} width={width} x={x} y={bodyY} />
</m.g>
);

if (fadeOnHighlight) {
return (
<m.g animate={{ opacity: highlightOpacity }} initial={false} transition={fadeTransition}>
{candlestick}
</m.g>
);
}

return candlestick;
},
);

const formatPrice = React.useCallback((price) => {
return new Intl.NumberFormat('en-US', {
Expand Down Expand Up @@ -1043,20 +1072,29 @@ function Candlesticks() {
);

const updateInfoText = React.useCallback(
(index) => {
(items) => {
if (!infoTextRef.current) return;

const fallbackText = formatPrice(stockData[stockData.length - 1].close);

if (items.length === 0) {
infoTextRef.current.textContent = fallbackText;
selectedIndexRef.current = undefined;
return;
}

const index = items[0]?.dataIndex;
const text =
index !== null && index !== undefined
typeof index === 'number' && index >= 0 && index < stockData.length
? `Open: ${formatThousandsPrice(stockData[index].open)}, Close: ${formatThousandsPrice(
stockData[index].close,
)}, Volume: ${(parseFloat(stockData[index].volume) / 1000).toFixed(2)}k`
: formatPrice(stockData[stockData.length - 1].close);
: fallbackText;

infoTextRef.current.textContent = text;
selectedIndexRef.current = index;
selectedIndexRef.current = typeof index === 'number' ? index : undefined;
},
[stockData, formatPrice, formatVolume],
[stockData, formatPrice, formatThousandsPrice],
);
const initialInfo = formatPrice(stockData[stockData.length - 1].close);

Expand All @@ -1066,15 +1104,16 @@ function Candlesticks() {
<span ref={infoTextRef}>{initialInfo}</span>
</Text>
<BarChart
enableScrubbing
enableHighlighting
fadeOnHighlight
showXAxis
showYAxis
BarComponent={CandlestickBarComponent}
BarStackComponent={({ children }) => <g>{children}</g>}
borderRadius={0}
height={{ base: 150, tablet: 200, desktop: 250 }}
inset={{ top: 8, bottom: 8, left: 0, right: 0 }}
onScrubberPositionChange={updateInfoText}
onHighlightChange={updateInfoText}
series={[
{
id: 'stock-prices',
Expand All @@ -1092,14 +1131,7 @@ function Candlesticks() {
GridLineComponent: ThinSolidLine,
}}
aria-labelledby={infoTextId}
>
<Scrubber
hideOverlay
LineComponent={BandwidthHighlight}
lineStroke="var(--color-fgMuted)"
seriesIds={[]}
/>
</BarChart>
/>
</VStack>
);
}
Expand Down
Loading
Loading