Skip to main content
Scrubber
@coinbase/cds-web-visualization@3.4.0-beta.8
An interactive scrubber component for exploring individual data points in charts. Displays values on hover or drag and supports custom labels and formatting.
Import
import { Scrubber } from '@coinbase/cds-web-visualization'
SourceView source code
Peer dependencies
  • framer-motion: ^10.18.0
Related components
View as Markdown

Basics

Scrubber can be used to provide horizontal interaction with a chart. As your mouse hovers over the chart, you will see a line and scrubber beacon following.

Loading...
Live Code
<LineChart
  enableScrubbing
  showArea
  showYAxis
  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={{
    /* Give space between the scrubber and the axis */
    range: ({ min, max }) => ({ min, max: max - 8 }),
  }}
  yAxis={{
    showGrid: true,
  }}
>
  <Scrubber idlePulse />
</LineChart>

All series will be scrubbed by default. You can set seriesIds to show only specific series.

Loading...
Live Code
<LineChart
  enableScrubbing
  height={{ base: 150, tablet: 200, desktop: 250 }}
  series={[
    {
      id: 'top',
      data: [15, 28, 32, 44, 46, 36, 40, 45, 48, 38],
    },
    {
      id: 'upperMiddle',
      data: [12, 23, 21, 29, 34, 28, 31, 38, 42, 35],
      color: '#ef4444',
      type: 'dotted',
    },
    {
      id: 'lowerMiddle',
      data: [8, 15, 14, 25, 20, 18, 22, 28, 24, 30],
      color: '#f59e0b',
      curve: 'natural',
      gradient: {
        axis: 'y',
        stops: [
          { offset: 0, color: '#E3D74D' },
          { offset: 100, color: '#F7931A' },
        ],
      },
      LineComponent: (props) => <SolidLine {...props} strokeWidth={4} />,
    },
    {
      id: 'bottom',
      data: [4, 8, 11, 15, 16, 14, 16, 10, 12, 14],
      color: '#800080',
      curve: 'step',
      AreaComponent: DottedArea,
      showArea: true,
    },
  ]}
>
  <Scrubber seriesIds={['top', 'lowerMiddle']} />
</LineChart>

Labels

Setting label on a series will display a label to the side of the scrubber beacon, and setting label on Scrubber displays a label above the scrubber line.

Loading...
Live Code
<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],
      label: 'Price',
    },
  ]}
  showArea
>
  <Scrubber label={(dataIndex: number) => `Day ${dataIndex + 1}`} />
</LineChart>

Pulsing

Setting idlePulse to true will cause the scrubber beacons to pulse when the user is not actively scrubbing.

Loading...
Live Code
<LineChart
  enableScrubbing
  showArea
  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],
      color: 'var(--color-fgPositive)',
    },
  ]}
>
  <ReferenceLine
    LineComponent={(props) => <DottedLine {...props} strokeDasharray="0 16" strokeWidth={3} />}
    dataY={10}
    stroke="var(--color-fg)"
  />
  <Scrubber idlePulse />
</LineChart>

You can also use the imperative handle to pulse the scrubber beacons programmatically.

Loading...
Live Code
function ImperativeHandle() {
  const scrubberRef = useRef(null);
  return (
    <VStack gap={2}>
      <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],
          },
        ]}
        showArea
      >
        <Scrubber ref={scrubberRef} />
      </LineChart>
      <Button onClick={() => scrubberRef.current?.pulse()}>Pulse</Button>
    </VStack>
  );
}

Styling

Beacons

You can use BeaconComponent to customize the visual appearance of scrubber beacons.

Loading...
Live Code
function OutlineBeacon() {
  // Simple outline beacon with no pulse animation
  const OutlineBeaconComponent = memo(
    forwardRef(({ dataX, dataY, seriesId, color, isIdle }: ScrubberBeaconProps, ref) => {
      const { getSeries, getXScale, getYScale } = useCartesianChartContext();
      const targetSeries = getSeries(seriesId);
      const xScale = getXScale();
      const yScale = getYScale(targetSeries?.yAxisId);

      const pixelCoordinate = useMemo(() => {
        if (!xScale || !yScale) return;
        return projectPoint({ x: dataX, y: dataY, xScale, yScale });
      }, [dataX, dataY, xScale, yScale]);

      // Provide a no-op pulse implementation for simple beacons
      useImperativeHandle(ref, () => ({ pulse: () => {} }), []);

      if (!pixelCoordinate) return;

      if (isIdle) {
        return (
          <>
            <m.circle
              animate={{ cx: pixelCoordinate.x, cy: pixelCoordinate.y }}
              cx={pixelCoordinate.x}
              cy={pixelCoordinate.y}
              transition={defaultTransition}
              r={6}
              fill={color}
            />
            <m.circle
              animate={{ cx: pixelCoordinate.x, cy: pixelCoordinate.y }}
              cx={pixelCoordinate.x}
              cy={pixelCoordinate.y}
              transition={defaultTransition}
              r={3}
              fill="var(--color-bg)"
            />
          </>
        );
      }

      return (
        <>
          <circle cx={pixelCoordinate.x} cy={pixelCoordinate.y} r={6} fill={color} />
          <circle cx={pixelCoordinate.x} cy={pixelCoordinate.y} r={3} fill="var(--color-bg)" />
        </>
      );
    }),
  );

  const dataCount = 14;
  const minDataValue = 0;
  const maxDataValue = 100;
  const minStepOffset = 5;
  const maxStepOffset = 20;
  const updateInterval = 2000;

  function generateNextValue(previousValue) {
    const range = maxStepOffset - minStepOffset;
    const offset = Math.random() * range + minStepOffset;

    let direction;
    if (previousValue >= maxDataValue) {
      direction = -1;
    } else if (previousValue <= minDataValue) {
      direction = 1;
    } else {
      direction = Math.random() < 0.5 ? -1 : 1;
    }

    let newValue = previousValue + offset * direction;
    return Math.max(minDataValue, Math.min(maxDataValue, newValue));
  }

  function generateInitialData() {
    const data = [];
    let previousValue = Math.random() * (maxDataValue - minDataValue) + minDataValue;
    data.push(previousValue);

    for (let i = 1; i < dataCount; i++) {
      const newValue = generateNextValue(previousValue);
      data.push(newValue);
      previousValue = newValue;
    }
    return data;
  }


  const OutlineBeaconChart = memo(() => {
    const [data, setData] = useState(generateInitialData);

    useEffect(() => {
      const intervalId = setInterval(() => {
        setData((currentData) => {
          const lastValue = currentData[currentData.length - 1] ?? 50;
          const newValue = generateNextValue(lastValue);
          return [...currentData.slice(1), newValue];
        });
      }, updateInterval);

      return () => clearInterval(intervalId);
    }, []);

    return (
      <LineChart
        enableScrubbing
        showArea
        showYAxis
        height={{ base: 150, tablet: 200, desktop: 250 }}
        series={[
          {
            id: 'prices',
            data,
            color: 'var(--color-fg)',
          },
        ]}
        xAxis={{
          range: ({ min, max }) => ({ min, max: max - 16 }),
        }}
        yAxis={{
          showGrid: true,
          domain: { min: 0, max: 100 }
        }}
      >
        <Scrubber BeaconComponent={OutlineBeaconComponent} />
      </LineChart>
    );
  });

  return <OutlineBeaconChart />;
}

Labels

You can use BeaconLabelComponent to customize the labels for each scrubber beacon.

Loading...
Live Code
function CustomBeaconLabel() {
  // This custom component label shows the percentage value of the data at the scrubber position.
  const MyScrubberBeaconLabel = memo(({ seriesId, color, label, ...props}: ScrubberBeaconLabelProps) => {
    const { getSeriesData, dataLength } = useCartesianChartContext();
    const { scrubberPosition } = useScrubberContext();

    const seriesData = useMemo(() => getLineData(getSeriesData(seriesId)), [getSeriesData, seriesId]);

    const dataIndex = useMemo(() => {
      return scrubberPosition ?? Math.max(0, dataLength - 1);
    }, [scrubberPosition, dataLength]);

    const percentageLabel = useMemo(() => {
      if (seriesData !== undefined) {
        const dataAtPosition = seriesData[dataIndex];
        return `${label} · ${dataAtPosition}%`;
      }
      return label;
    }, [label, seriesData, dataIndex])

    return (
      <DefaultScrubberBeaconLabel
        {...props}
        seriesId={seriesId}
        color="rgb(var(--gray0))"
        background={color}
        label={percentageLabel}
      />
    );
  });

  return (
    <LineChart
      enableScrubbing
      height={{ base: 150, tablet: 200, desktop: 250 }}
      series={[
        {
          id: 'Boston',
          data: [25, 30, 35, 45, 60, 100],
          color: 'rgb(var(--green40))',
          label: 'Boston',
        },
        {
          id: 'Miami',
          data: [20, 25, 30, 35, 20, 0],
          color: 'rgb(var(--blue40))',
          label: 'Miami',
        },
        {
          id: 'Denver',
          data: [10, 15, 20, 25, 40, 0],
          color: 'rgb(var(--orange40))',
          label: 'Denver',
        },
        {
          id: 'Phoenix',
          data: [15, 10, 5, 0, 0, 0],
          color: 'rgb(var(--red40))',
          label: 'Phoenix',
        },
      ]}
      showYAxis
      showArea
      areaType="dotted"
      yAxis={{
        showGrid: true,
      }}
    >
      <Scrubber BeaconLabelComponent={MyScrubberBeaconLabel} />
    </LineChart>
  );
}

Using labelElevated will elevate the Scrubber's reference line label with a shadow.

Loading...
Live Code
<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],
    },
  ]}
  showArea
  inset={{ top: 60 }}
>
  <Scrubber label={(dataIndex: number) => `Day ${dataIndex + 1}`} labelElevated />
</LineChart>

You can use LabelComponent to customize this label even further.

Loading...
Live Code
function CustomLabelComponent() {
  const CustomLabelComponent = memo((props: ScrubberLabelProps) => {
    const { drawingArea } = useCartesianChartContext();

    if (!drawingArea) return;

    return (
      <DefaultScrubberLabel
        {...props}
        background="var(--color-bgPrimary)"
        color="var(--color-bgPrimaryWash)"
        dy={32}
        elevated
        fontWeight="label1"
        y={drawingArea.y + drawingArea.height}
      />
    );
  });
  return (
    <LineChart
      enableScrubbing
      showArea
      height={{ base: 150, tablet: 200, desktop: 250 }}
      inset={{ top: 16, bottom: 64 }}
      series={[
        {
          id: 'prices',
          data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58],
        },
      ]}
    >
      <Scrubber
        LabelComponent={CustomLabelComponent}
        label={(dataIndex: number) => `Day ${dataIndex + 1}`}
      />
    </LineChart>
  );
}

Fonts

You can use labelFont to customize the font of the scrubber line label and beaconLabelFont to customize the font of the beacon labels.

Loading...
Live Code
<LineChart
  enableScrubbing
  showArea
  showYAxis
  height={{ base: 150, tablet: 200, desktop: 250 }}
  series={[
    {
      id: 'btc',
      data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58],
      label: 'BTC',
      color: assets.btc.color,
    },
    {
      id: 'eth',
      data: [5, 15, 18, 30, 65, 30, 15, 35, 15, 2, 45, 12, 15, 40],
      label: 'ETH',
      color: assets.eth.color,
    },
  ]}
  yAxis={{
    showGrid: true,
  }}
>
  <Scrubber
    label={(dataIndex: number) => `Day ${dataIndex + 1}`}
    labelFont="legal"
    beaconLabelFont="legal"
  />
</LineChart>

Bounds

Use labelBoundsInset to prevent the scrubber line label from getting too close to chart edges.

Loading...
Live Code
<Box style={{ marginLeft: 'calc(-1 * var(--space-3))', marginRight: 'calc(-1 * var(--space-3))' }}>
  <LineChart
    enableScrubbing
    showArea
    height={{ base: 150, tablet: 200, desktop: 250 }}
    inset={{ left: 0, right: 0 }}
    series={[
      {
        id: 'prices',
        data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58],
      },
    ]}
  >
    <Scrubber label="Without bounds - text touches edge" labelBoundsInset={0} />
  </LineChart>
</Box>
Loading...
Live Code
<Box style={{ marginLeft: 'calc(-1 * var(--space-3))', marginRight: 'calc(-1 * var(--space-3))' }}>
  <LineChart
    enableScrubbing
    showArea
    height={{ base: 150, tablet: 200, desktop: 250 }}
    inset={{ left: 0, right: 0 }}
    series={[
      {
        id: 'prices',
        data: [10, 22, 29, 45, 98, 45, 22, 52, 21, 4, 68, 20, 21, 58],
      },
    ]}
  >
    <Scrubber
      label="With bounds inset - text has space"
      labelBoundsInset={{ left: 12, right: 12 }}
    />
  </LineChart>
</Box>

Line

You can use LineComponent to customize Scrubber's line. In this case, as a user scrubs, they will see a solid line instead of dotted.

Loading...
Live Code
<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],
    },
  ]}
  showArea
>
  <Scrubber LineComponent={SolidLine} />
</LineChart>

Opacity

You can use BeaconComponent and BeaconLabelComponent with the opacity prop to hide scrubber beacons and labels when idle.

Loading...
Live Code
function HiddenScrubberWhenIdle() {
  const MyScrubberBeacon = memo(
    forwardRef((props: ScrubberBeaconProps, ref) => {
      const { scrubberPosition } = useScrubberContext();
      const isScrubbing = scrubberPosition !== undefined;

      return <DefaultScrubberBeacon ref={ref} {...props} opacity={isScrubbing ? 1 : 0} />;
    }),
  );

  const MyScrubberBeaconLabel = memo((props: ScrubberBeaconLabelProps) => {
    const { scrubberPosition } = useScrubberContext();
    const isScrubbing = scrubberPosition !== undefined;

    return <DefaultScrubberBeaconLabel {...props} opacity={isScrubbing ? 1 : 0} />;
  });

  return (
    <LineChart
      enableScrubbing
      showArea
      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],
          label: 'Price',
        },
      ]}
    >
      <Scrubber BeaconComponent={MyScrubberBeacon} BeaconLabelComponent={MyScrubberBeaconLabel} />
    </LineChart>
  );
}

Overlay

By default, Scrubber will show an overlay to de-emphasize future data. You can hide this by setting hideOverlay to true.

Loading...
Live Code
<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],
    },
  ]}
  showArea
>
  <Scrubber hideOverlay />
</LineChart>

Is this page useful?

Coinbase Design is an open-source, adaptable system of guidelines, components, and tools that aid the best practices of user interface design for crypto products.