# Scrubber

An interactive scrubber component for exploring individual data points in charts. Displays values on hover or drag and supports custom labels and formatting.

## Import

```tsx
import { Scrubber } from '@coinbase/cds-web-visualization'
```

## Examples

### 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.

```jsx live
<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.

```jsx live
<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.

```jsx live
<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.

```jsx live
<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.

```jsx live
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.

```jsx live
function OutlineBeacon() {
  // Simple outline beacon with no pulse animation
  const OutlineBeaconComponent = memo(({ dataX, dataY, seriesId, color, isIdle }: ScrubberBeaconProps) => {
    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]);

    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.

```jsx live
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.

```jsx live
<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.

```jsx live
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.

```jsx live
<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.

```jsx live
<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>
```

```jsx live
<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.

```jsx live
<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.

```jsx live
function HiddenScrubberWhenIdle() {
  const MyScrubberBeacon = memo((props: ScrubberBeaconProps) => {
    const { scrubberPosition } = useScrubberContext();
    const isScrubbing = scrubberPosition !== undefined;

    return <DefaultScrubberBeacon {...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`.

```jsx live
<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>
```

## Props

| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `BeaconComponent` | `ScrubberBeaconComponent` | No | `DefaultScrubberBeacon` | Custom component for the scrubber beacon. |
| `BeaconLabelComponent` | `ScrubberBeaconLabelComponent` | No | `DefaultScrubberBeaconLabel` | Custom component to render as a scrubber beacon label. |
| `LabelComponent` | `ReferenceLineLabelComponent` | No | `DefaultReferenceLineLabel` | Component to render the label. |
| `LineComponent` | `LineComponent` | No | `DottedLine` | Component to render the line. |
| `accessibilityLabel` | `string \| ((dataIndex: number) => string)` | No | `-` | Accessibility label for the scrubber. Can be a static string or a function that receives the current dataIndex. If not provided, label will be used if it resolves to a string. |
| `beaconLabelFont` | `ResponsiveProp<FontFamily \| inherit>` | No | `-` | Font style for the beacon labels. |
| `beaconLabelHorizontalOffset` | `number` | No | `-` | Horizontal offset for beacon labels from their beacon position. Measured in pixels. |
| `beaconLabelMinGap` | `number` | No | `-` | Minimum gap between beacon labels to prevent overlap. Measured in pixels. |
| `beaconTransitions` | `{ update?: Transition$1; pulse?: Transition$1 \| undefined; pulseRepeatDelay?: number \| undefined; } \| undefined` | No | `-` | Transition configuration for the scrubber beacon. |
| `classNames` | `{ overlay?: string; beacon?: string \| undefined; line?: string \| undefined; beaconLabel?: string \| undefined; } \| undefined` | No | `-` | Custom class names for scrubber elements. |
| `hideLine` | `boolean` | No | `-` | Hides the scrubber line. |
| `hideOverlay` | `boolean` | No | `-` | Hides the overlay rect which obscures data beyond the scrubber position. |
| `idlePulse` | `boolean` | No | `-` | Pulse the beacons while at rest. |
| `key` | `Key \| null` | No | `-` | - |
| `label` | `ChartTextChildren \| ((dataIndex: number) => ChartTextChildren)` | No | `-` | Label text displayed above the scrubber line. Can be a static string or a function that receives the current dataIndex. |
| `labelBoundsInset` | `number \| ChartInset` | No | `{ top: 4, bottom: 20, left: 12, right: 12 } when labelElevated is true, otherwise none` | Bounds inset for the scrubber line label to prevent cutoff at chart edges. |
| `labelElevated` | `boolean` | No | `-` | Whether to elevate the label with a shadow. When true, applies elevation and automatically adds bounds to keep label within chart area. |
| `labelFont` | `ResponsiveProp<FontFamily \| inherit>` | No | `-` | Font style for the scrubber line label. |
| `lineStroke` | `string` | No | `-` | Stroke color for the scrubber line. |
| `overlayOffset` | `number` | No | `2` | Offset of the overlay rect relative to the drawing area. Useful for when scrubbing over lines, where the stroke width would cause part of the line to be visible. |
| `ref` | `((instance: ScrubberBeaconGroupRef \| null) => void) \| RefObject<ScrubberBeaconGroupRef> \| null` | No | `-` | - |
| `seriesIds` | `string[]` | No | `-` | Array of series IDs to highlight when scrubbing with scrubber beacons. By default, all series will be highlighted. |
| `styles` | `{ overlay?: CSSProperties; beacon?: CSSProperties \| undefined; line?: CSSProperties \| undefined; beaconLabel?: CSSProperties \| undefined; } \| undefined` | No | `-` | Custom styles for scrubber elements. |
| `testID` | `string` | No | `-` | Used to locate this element in unit and end-to-end tests. Under the hood, testID translates to data-testid on Web. On Mobile, testID stays the same - testID |


