Foundations

Scrubbing

Changing a value by dragging across what names it, without losing the click, the scroll or the way back.

Markdown

Scrubbing changes a value by dragging sideways across something that names it: a field's label, a letter or an icon at the start of the field. It is how the numbers of an inspector are nudged while watching what they change, and it should cost nothing: the label is still a label, the page still scrolls, and a drag that was a mistake can be taken back.

When to use

Offer scrubbing where a value is adjusted by eye — a position, a size, an angle, an opacity — and the result is visible as it changes. A value people know and type once — a price, a quantity in a form — gains nothing from it, and its label is text to read rather than something to drag.

Best practices

Keep the click. A press becomes a scrub only once it has travelled a few pixels. Short of that it is a click, and a label's click gives its field focus as it always did. The release of a scrub is not a click: the field is not given focus, and on a phone no keyboard appears.

Count travel in steps. A fixed distance — about two pixels — makes one step of the value, and what is left over waits for the next movement, so a slow drag and a fast one over the same distance arrive at the same value. The distance that made the press a scrub is not counted: crossing the threshold is not itself a jump.

Step as the keyboard steps. A scrubbed step is the arrow key's step: it lands on the same multiples, Shift makes it ten times larger and Option or Alt ten times smaller, whichever is held as each step is made.

Stop at the ends without winding up. When the value reaches the end of its range, further travel is spent and gone. The first movement back moves the value; there is no distance to give back first. A range that is a cycle — an angle — goes round instead.

Follow the writing direction. Toward the end of the line increases the value: to the right in English, to the left in Arabic or Hebrew.

Let the drag leave. Once begun, a scrub continues wherever the pointer goes; what was pressed holds the pointer until it is released.

Take it back on Escape. Escape during a scrub restores the value the scrub began from, and the rest of the drag does nothing. That Escape belongs to the scrub alone: a popover around the field stays open. A touch the system takes over, and the window losing focus, take the scrub back in the same way.

Report the change once. Every value on the way is shown as it is reached; the scrub as a whole is one change — one entry to undo — reported at the release, and not at all if the value ended where it began or was taken back.

Show what scrubs, and only that. What scrubs shows a sideways resize cursor and is not selected as text. A field that cannot be changed has a label that does neither.

Leave the controls inside alone. Where a label wraps its field, a press in the field is the field's: it places the caret and selects text as in any field.

Platform considerations

Mouse. Where the browser allows, hold the pointer in place for the length of the scrub, so that the edge of the screen does not end it, and let it go when the scrub ends. This is never required: where the lock is refused or does not exist, the scrub goes as far as the screen does. A browser gives the lock up on Escape and keeps that key to itself, so losing the lock takes the scrub back. Only the main button scrubs.

Pen. A pen is where it points: it is never locked, and otherwise scrubs as a mouse does.

Touch. Only a sideways drag belongs to the scrub. A drag up or down that starts on what scrubs still scrolls the page, and nothing else on the page gives up any scrolling at all.

Keyboard. Scrubbing has no keyboard form of its own, and needs none: the field's arrow keys make the same steps.

Assistive technology. What scrubs keeps its role and its name. A label is still a label, and activating it without a pointer still gives its field focus.

Behaviour

Travel

Travel is counted in whole steps, and what is left over waits for the next movement
expect(scrubTravel(0, 5, 2)).toEqual({ steps: 2, rest: 1 });
expect(scrubTravel(1, 1, 2)).toEqual({ steps: 1, rest: 0 });
expect(scrubTravel(0, 1.5, 2)).toEqual({ steps: 0, rest: 1.5 });
fc.assert(
  fc.property(
    fc.array(fc.integer({ min: -50, max: 50 })),
    fc.integer({ min: 1, max: 8 }),
    (moves, distance) => {
      let rest = 0;
      let steps = 0;
      for (const pixels of moves) {
        const travel = scrubTravel(rest, pixels, distance);
        rest = travel.rest;
        steps += travel.steps;
      }
      const travelled = moves.reduce((sum, pixels) => sum + pixels, 0);
      // However it was broken into movements, none of it is lost, and less than a step is waiting.
      return steps * distance + rest === travelled && Math.abs(rest) < distance;
    },
  ),
);
A movement back counts as readily as one forward
expect(scrubTravel(0, -5, 2)).toEqual({ steps: -2, rest: -1 });
// A pixel short of a step forward, then two pixels back: one pixel back, and no step yet.
expect(scrubTravel(1, -2, 2)).toEqual({ steps: 0, rest: -1 });
expect(scrubTravel(-1, -1, 2)).toEqual({ steps: -1, rest: 0 });
Shift asks for a large step and Alt for a small one, as they do of the arrow keys
expect(scrubSize({ shiftKey: false, altKey: false })).toBe("normal");
expect(scrubSize({ shiftKey: true, altKey: false })).toBe("large");
expect(scrubSize({ shiftKey: false, altKey: true })).toBe("small");
expect(scrubSize({ shiftKey: true, altKey: true })).toBe("large");

Dragging

A press that does not travel is a click: nothing is scrubbed, and a label still gives its field focus
const heard = scrubbed();
const screen = await render(<Width {...heard} />);
await commands.dragAlong(HANDLE, [2]);
expect(heard.onScrubStart).not.toHaveBeenCalled();
expect(heard.onScrub).not.toHaveBeenCalled();
expect(heard.onScrubEnd).not.toHaveBeenCalled();
await expect.element(input(screen)).toHaveFocus();
A drag sideways scrubs: once it has travelled three pixels, a step for every two more
const heard = scrubbed();
await render(<Width {...heard} />);
await commands.dragAlong(HANDLE, [4], { release: false });
expect(heard.onScrubStart).toHaveBeenCalledTimes(1);
expect(heard.onScrub).not.toHaveBeenCalled();
await commands.releasePointer();
await commands.dragAlong(HANDLE, [4, 5, 7, 45]);
expect(heard.onScrub.mock.calls).toEqual([
  [1, "normal"],
  [19, "normal"],
]);
A drag toward the start of the line counts down
const heard = scrubbed();
await render(<Width {...heard} />);
await commands.dragAlong(HANDLE, [-4, -25]);
expect(total(heard.onScrub)).toBe(-10);
In a line that runs right to left, the directions are mirrored
const heard = scrubbed();
await render(<Width dir="rtl" {...heard} />);
await commands.dragAlong(HANDLE, [-4, -25]);
expect(total(heard.onScrub)).toBe(10);
await commands.dragAlong(HANDLE, [4, 11]);
expect(total(heard.onScrub)).toBe(7);
With Shift held the steps are large, with Alt small — whichever is held as each is made
const heard = scrubbed();
await render(<Width {...heard} />);
await commands.dragAlong(HANDLE, [4, 9], { release: false });
await userEvent.keyboard("{Shift>}");
await commands.dragAlong(HANDLE, [13], { press: false, release: false });
await userEvent.keyboard("{/Shift}{Alt>}");
await commands.dragAlong(HANDLE, [15], { press: false });
await userEvent.keyboard("{/Alt}");
expect(heard.onScrub.mock.calls).toEqual([
  [2, "normal"],
  [2, "large"],
  [1, "small"],
]);
The drag goes on outside what was pressed, which holds the pointer until it is released
const heard = scrubbed();
const screen = await render(<Width {...heard} />);
let id = -1;
handle(screen).addEventListener("pointerdown", (event) => (id = event.pointerId));
await commands.dragAlong(HANDLE, [4, 205], { release: false });
expect(total(heard.onScrub)).toBe(100);
expect(handle(screen).hasPointerCapture(id)).toBe(true);
expect(handle(screen).dataset.scrubbing).toBe("");
await commands.releasePointer();
expect(handle(screen).hasPointerCapture(id)).toBe(false);
expect(handle(screen).dataset.scrubbing).toBeUndefined();
expect(heard.onScrubEnd).toHaveBeenCalledTimes(1);
The release of a drag is not a click: a label does not give its field focus
const heard = scrubbed();
const screen = await render(<Width {...heard} />);
// Out and back, so that the release is on the label itself.
await commands.dragAlong(HANDLE, [4, 15, 0]);
expect(heard.onScrubEnd).toHaveBeenCalledTimes(1);
await new Promise((resolve) => setTimeout(resolve, 50));
await expect.element(input(screen)).not.toHaveFocus();
// The next press is its own: a click again.
await commands.dragAlong(HANDLE, []);
await expect.element(input(screen)).toHaveFocus();
A drag from a handle inside a field, out across the field, neither selects in it nor gives it focus
function Inside() {
  const ref = useScrub({ onScrub: () => undefined });
  return (
    <div className="relative w-40">
      <span ref={ref} data-testid="handle" className="absolute inset-y-0 start-0 w-7">
        W
      </span>
      <input aria-label="Width" className="w-full border ps-7" defaultValue="120" />
    </div>
  );
}
const screen = await render(<Inside />);
// Twice: a browser's first press in a page is not like its later ones.
await commands.dragAlong(HANDLE, [4, 65]);
await commands.dragAlong(HANDLE, [4, 65]);
await expect.element(input(screen)).not.toHaveFocus();
After a finger's scrub, which ends in no click, a click made later is a click
const screen = await render(<Width onScrub={() => undefined} />);
pointer(handle(screen), "pointerdown", 0, { pointerType: "touch" });
pointer(handle(screen), "pointermove", 3, { pointerType: "touch" });
pointer(handle(screen), "pointermove", 23, { pointerType: "touch" });
pointer(handle(screen), "pointerup", 23, { pointerType: "touch" });
await new Promise((resolve) => setTimeout(resolve, 50));
// As assistive technology activates a label: with no press before it.
handle(screen).click();
await expect.element(input(screen)).toHaveFocus();
A press on a control inside what scrubs is the control's
const heard = scrubbed();
function Around() {
  const ref = useScrub(heard);
  return (
    <label ref={ref} className="inline-block p-4">
      Width <input data-testid="inside" className="w-40 border" defaultValue="1234567890" />
    </label>
  );
}
await render(<Around />);
await commands.dragAlong('[data-testid="inside"]', [4, 45]);
expect(heard.onScrubStart).not.toHaveBeenCalled();
Only the main button scrubs
const heard = scrubbed();
const screen = await render(<Width {...heard} />);
pointer(handle(screen), "pointerdown", 0, { button: 2 });
pointer(handle(screen), "pointermove", 20);
pointer(handle(screen), "pointermove", 40);
pointer(handle(screen), "pointerup", 40);
expect(heard.onScrubStart).not.toHaveBeenCalled();

Taking it back

Escape takes the scrub back, unheard by what contains it, and the rest of the drag scrubs nothing
const heard = scrubbed();
const escaped = vi.fn();
const screen = await render(
  // oxlint-disable-next-line jsx-a11y/no-static-element-interactions -- stands for a popover that closes on Escape
  <div onKeyDown={(event) => event.key === "Escape" && escaped()}>
    <Width {...heard} />
  </div>,
);
input(screen).element().focus();
await commands.dragAlong(HANDLE, [4, 15], { release: false });
await userEvent.keyboard("{Escape}");
expect(heard.onScrubCancel).toHaveBeenCalledTimes(1);
expect(escaped).not.toHaveBeenCalled();
heard.onScrub.mockClear();
await commands.dragAlong(HANDLE, [25, 35], { press: false });
expect(heard.onScrub).not.toHaveBeenCalled();
expect(heard.onScrubEnd).not.toHaveBeenCalled();
Escape during a press that has not become a scrub is left for what contains it
const heard = scrubbed();
const escaped = vi.fn();
document.addEventListener("keydown", escaped);
try {
  await render(<Width {...heard} />);
  await commands.dragAlong(HANDLE, [1], { release: false });
  await userEvent.keyboard("{Escape}");
  await commands.releasePointer();
  expect(escaped).toHaveBeenCalledTimes(1);
  expect(heard.onScrubCancel).not.toHaveBeenCalled();
} finally {
  document.removeEventListener("keydown", escaped);
}
The window losing focus takes the scrub back
const heard = scrubbed();
await render(<Width {...heard} />);
await commands.dragAlong(HANDLE, [4, 15], { release: false });
window.dispatchEvent(new Event("blur"));
expect(heard.onScrubCancel).toHaveBeenCalledTimes(1);
await commands.releasePointer();
expect(heard.onScrubEnd).not.toHaveBeenCalled();
A touch that the browser takes for itself, to scroll, takes the scrub back
const heard = scrubbed();
const screen = await render(<Width {...heard} />);
pointer(handle(screen), "pointerdown", 0, { pointerType: "touch" });
pointer(handle(screen), "pointermove", 3, { pointerType: "touch" });
pointer(handle(screen), "pointermove", 13, { pointerType: "touch" });
pointer(handle(screen), "pointercancel", 13, { pointerType: "touch" });
expect(total(heard.onScrub)).toBe(5);
expect(heard.onScrubCancel).toHaveBeenCalledTimes(1);
expect(heard.onScrubEnd).not.toHaveBeenCalled();

Pointers

What scrubs shows a sideways resize cursor, is not selected as text, and leaves scrolling up and down to the page
const screen = await render(<Width onScrub={() => undefined} />);
const style = getComputedStyle(handle(screen));
expect(style.cursor).toBe("ew-resize");
expect(style.touchAction).toBe("pan-y");
expect(style.userSelect || style.webkitUserSelect).toBe("none");
expect(handle(screen).dataset.scrub).toBe("");
// And nothing else does: the field beside it is as any field.
expect(getComputedStyle(input(screen).element()).touchAction).toBe("auto");
A pen and a finger scrub as a mouse does
const heard = scrubbed();
const screen = await render(<Width {...heard} />);
for (const pointerType of ["pen", "touch"]) {
  pointer(handle(screen), "pointerdown", 100, { pointerType });
  pointer(handle(screen), "pointermove", 103, { pointerType });
  pointer(handle(screen), "pointermove", 123, { pointerType });
  // Released anywhere: the window hears it.
  pointer(document.body, "pointerup", 123, { pointerType });
}
expect(heard.onScrub.mock.calls).toEqual([
  [10, "normal"],
  [10, "normal"],
]);
expect(heard.onScrubEnd).toHaveBeenCalledTimes(2);
A finger dragged sideways across a touchscreen scrubsChromium, Chromium · touch

Not yet written.

A mouse asks to be held in place once the scrub is under way; a pen and a finger never doChromium, Chromium · touch, Firefox, WebKit

Not yet written.

Where the browser allows, the mouse is held in place for the length of the scrub, and let go when it endsFirefox

Not yet written.

When the browser takes the mouse back, as it does on Escape, the scrub is taken backFirefox

Not yet written.

API

distance is the travel that makes a step
const heard = scrubbed();
expect(SCRUB_DISTANCE).toBe(2);
await render(<Width {...heard} distance={10} />);
await commands.dragAlong(HANDLE, [4, 49]);
expect(total(heard.onScrub)).toBe(4);
useScrub hears the options of the latest render, and the drag goes on across renders
function Counter() {
  const [count, setCount] = useState(0);
  const ref = useScrub({ onScrub: (steps) => setCount(count + steps) });
  return (
    <>
      <span ref={ref} data-testid="handle" className="inline-block w-24">
        Count
      </span>
      <output>{count}</output>
    </>
  );
}
const screen = await render(<Counter />);
await commands.dragAlong(HANDLE, [4, 9, 15, 19, 25]);
await expect.element(screen.getByRole("status")).toHaveTextContent("10");
What scrub returns undoes it: the element is as it was, and its presses its own
const heard = scrubbed();
const element = document.createElement("span");
element.dataset.testid = "handle";
element.style.cssText = "display: inline-block; width: 96px; cursor: help";
element.textContent = "Width";
document.body.append(element);
try {
  const undo = scrub(element, () => heard);
  expect(element.style.cursor).toBe("ew-resize");
  undo();
  expect(element.style.cursor).toBe("help");
  expect(element.style.touchAction).toBe("");
  expect(element.style.userSelect).toBe("");
  expect(element.dataset.scrub).toBeUndefined();
  await commands.dragAlong(HANDLE, [4, 45]);
  expect(heard.onScrubStart).not.toHaveBeenCalled();
} finally {
  element.remove();
}

React

Install

npx shadcn@latest add https://ui.publictheta.com/r/scrub.json
Source

src/primitives/scrub.ts

/**
 * Scrubbing: changing a value by dragging sideways across something that names
 * it — a field's label, a handle inside the field — as an inspector's numbers
 * are dragged.
 *
 * - A press becomes a scrub only once it has travelled: a click is still a click.
 * - Travel is counted in whole steps, toward the end of the line in the writing
 *   direction; what is left over waits for the next movement. Shift makes a step
 *   large and Alt small, as they do for the arrow keys.
 * - The drag goes on outside what was pressed. A mouse is locked in place where
 *   the browser allows, so that the edge of the screen does not end it.
 * - Escape, a cancelled touch and the window losing focus take the scrub back.
 * - The page still scrolls: only a sideways drag belongs to the scrub.
 *
 * It reports steps, not values. What a step is, and where the range ends, is the
 * value's own business — which is also why a pointer held past an end has
 * nothing to unwind: the first step back is a step back.
 */
import { useCallback, useLayoutEffect, useRef } from "react";
import { isApplePlatform } from "../lib/platform.ts";

/** How far a press travels, in CSS pixels, before it is a scrub rather than a click. */
export const SCRUB_THRESHOLD = 3;

/** The travel, in CSS pixels, that makes one step. */
export const SCRUB_DISTANCE = 2;

export type ScrubSize = "small" | "normal" | "large";

export interface ScrubOptions {
  /** The travel that makes one step, when not `SCRUB_DISTANCE`. */
  readonly distance?: number | undefined;
  /** A press has travelled far enough to be a scrub. */
  readonly onScrubStart?: (() => void) | undefined;
  /** Whole steps since the last report: positive toward the end of the line, negative toward its start. */
  readonly onScrub: (steps: number, size: ScrubSize) => void;
  /** The pointer was released. */
  readonly onScrubEnd?: (() => void) | undefined;
  /** The scrub was taken back: by Escape, a cancelled touch, or the window losing focus. */
  readonly onScrubCancel?: (() => void) | undefined;
}

/** The size of step the modifiers ask for, as the arrow keys read them. */
export function scrubSize(modifiers: { readonly shiftKey: boolean; readonly altKey: boolean }): ScrubSize {
  return modifiers.shiftKey ? "large" : modifiers.altKey ? "small" : "normal";
}

/**
 * The whole steps a movement makes, and the travel left over toward the next:
 * `rest` is what the last movement left.
 */
export function scrubTravel(
  rest: number,
  pixels: number,
  distance: number = SCRUB_DISTANCE,
): { readonly steps: number; readonly rest: number } {
  const total = rest + pixels;
  const steps = Math.trunc(total / distance);
  // Not -0, which is no step either.
  return { steps: steps === 0 ? 0 : steps, rest: total - steps * distance };
}

/** What a press inside the scrubbed element may land on and keep for itself. */
const CONTROLS = "input, textarea, select, button, a[href], [contenteditable]";

interface Press {
  readonly pointer: number;
  /** Where the pointer was last seen. */
  x: number;
  rest: number;
  active: boolean;
  locked: boolean;
  sign: 1 | -1;
}

/**
 * Make `element` scrub. `options` is read at each event, so it may change.
 * Returns what undoes it.
 */
export function scrub(element: HTMLElement, options: () => ScrubOptions): () => void {
  const { style } = element;
  const before = {
    cursor: style.cursor,
    touchAction: style.touchAction,
    userSelect: style.userSelect,
    webkitUserSelect: style.getPropertyValue("-webkit-user-select"),
  };
  style.cursor = "ew-resize";
  // A drag up or down is still the page's; only a sideways one is the scrub's.
  style.touchAction = "pan-y";
  style.userSelect = "none";
  style.setProperty("-webkit-user-select", "none");
  element.dataset.scrub = "";

  let press: Press | null = null;
  // Whether the press now ending was a scrub: its click is not a click.
  let scrubbed = false;

  function finish(how: "end" | "cancel"): void {
    const current = press;
    if (current === null) return;
    press = null;
    window.removeEventListener("pointermove", onPointerMove, true);
    window.removeEventListener("pointerup", onPointerUp, true);
    window.removeEventListener("pointercancel", onPointerCancel, true);
    window.removeEventListener("blur", onBlur);
    document.removeEventListener("keydown", onKeyDown, true);
    document.removeEventListener("pointerlockchange", onLockChange);
    try {
      element.releasePointerCapture(current.pointer);
    } catch {
      // Not captured: the pointer is gone, or was never this element's.
    }
    if (document.pointerLockElement === element) document.exitPointerLock();
    delete element.dataset.scrubbing;
    if (!current.active) return;
    if (how === "end") options().onScrubEnd?.();
    else options().onScrubCancel?.();
  }

  /** Whether a press is one that may become a scrub. */
  function mine(event: MouseEvent): boolean {
    if (event.button !== 0) return false;
    // On a Mac, Control with a press asks for the context menu.
    if (event.ctrlKey && isApplePlatform()) return false;
    const target = event.target as Element | null;
    const control = target?.closest?.(CONTROLS) ?? null;
    // A control inside what scrubs — a field inside its own label — keeps its presses.
    return control === null || control === element || !element.contains(control);
  }

  function onMouseDown(event: MouseEvent): void {
    // No selection starts here to be dragged into the field beside it, which
    // WebKit would then give focus. The click is untouched: a label still works.
    if (mine(event)) event.preventDefault();
  }

  function onPointerDown(event: PointerEvent): void {
    if (press !== null || !event.isPrimary || !mine(event)) return;
    scrubbed = false;
    press = { pointer: event.pointerId, x: event.clientX, rest: 0, active: false, locked: false, sign: 1 };
    try {
      element.setPointerCapture(event.pointerId);
    } catch {
      // A pointer that cannot be captured still scrubs: the window hears it.
    }
    window.addEventListener("pointermove", onPointerMove, true);
    window.addEventListener("pointerup", onPointerUp, true);
    window.addEventListener("pointercancel", onPointerCancel, true);
    window.addEventListener("blur", onBlur);
    document.addEventListener("keydown", onKeyDown, true);
    document.addEventListener("pointerlockchange", onLockChange);
  }

  function onPointerMove(event: PointerEvent): void {
    if (press === null || event.pointerId !== press.pointer) return;
    if (!press.active) {
      if (Math.abs(event.clientX - press.x) < SCRUB_THRESHOLD) return;
      press.active = true;
      scrubbed = true;
      // Counted from here, so that crossing the threshold is not itself a jump.
      press.x = event.clientX;
      press.sign = getComputedStyle(element).direction === "rtl" ? -1 : 1;
      element.dataset.scrubbing = "";
      options().onScrubStart?.();
      // A pen and a finger are where they point; only a mouse can be held in place.
      if (event.pointerType === "mouse") lock();
      return;
    }
    // A locked pointer does not move: it reports how far it would have.
    press.locked ||= document.pointerLockElement === element;
    const moved = press.locked ? event.movementX : event.clientX - press.x;
    press.x = event.clientX;
    const { distance } = options();
    const travel = scrubTravel(press.rest, moved * press.sign, distance);
    press.rest = travel.rest;
    if (travel.steps !== 0) options().onScrub(travel.steps, scrubSize(event));
  }

  function lock(): void {
    if (typeof element.requestPointerLock !== "function") return;
    try {
      // A promise in some engines, nothing in others; refused in either way.
      const asked = element.requestPointerLock() as Promise<void> | undefined;
      asked?.catch?.(() => undefined);
    } catch {
      // Never required: the drag goes on as far as the screen does.
    }
  }

  function onLockChange(): void {
    if (press === null) return;
    const locked = document.pointerLockElement === element;
    // The browser gives the lock up on Escape, and keeps the key to itself.
    if (press.locked && !locked) finish("cancel");
    else press.locked = locked;
  }

  function onPointerUp(event: PointerEvent): void {
    if (event.pointerId !== press?.pointer) return;
    finish("end");
    // Its click, if it has one, follows at once: a later one is somebody's own.
    setTimeout(() => {
      scrubbed = false;
    });
  }

  function onPointerCancel(event: PointerEvent): void {
    if (event.pointerId === press?.pointer) finish("cancel");
  }

  function onBlur(): void {
    finish("cancel");
  }

  function onKeyDown(event: KeyboardEvent): void {
    if (event.key !== "Escape" || press === null || !press.active) return;
    // The scrub's, and no one else's: a popover around the field stays open.
    event.preventDefault();
    event.stopPropagation();
    finish("cancel");
  }

  function onClick(event: MouseEvent): void {
    if (!scrubbed) return;
    scrubbed = false;
    // The release of a drag is not a click: a label would give its field focus.
    event.preventDefault();
    event.stopPropagation();
  }

  element.addEventListener("pointerdown", onPointerDown);
  element.addEventListener("mousedown", onMouseDown);
  element.addEventListener("click", onClick, true);

  return () => {
    finish("end");
    element.removeEventListener("pointerdown", onPointerDown);
    element.removeEventListener("mousedown", onMouseDown);
    element.removeEventListener("click", onClick, true);
    style.cursor = before.cursor;
    style.touchAction = before.touchAction;
    style.userSelect = before.userSelect;
    style.setProperty("-webkit-user-select", before.webkitUserSelect);
    delete element.dataset.scrub;
  };
}

/**
 * A ref for what scrubs — an element of one's own, as `ref`, or one found in
 * the document, called with it. It is the same ref for the life of the
 * component, and hears the options of the latest render.
 */
export function useScrub(options: ScrubOptions): (element: HTMLElement | null) => (() => void) | undefined {
  const latest = useRef(options);
  useLayoutEffect(() => {
    latest.current = options;
  });
  return useCallback(
    (element: HTMLElement | null) => (element === null ? undefined : scrub(element, () => latest.current)),
    [],
  );
}