Repository: facebook/react
Pull request: [DevTools] Custom Scrubber Design

Stacked on #34620.

This will let us use different color for different segments of the timeline.

Since we're modeling discrete steps (sometimes just a couple), a scrubber with a handle that you have to move is quite annoying and misleading. Doesn't show you how many steps there are. Therefore I went with a design that highlights each segment as its own step and you can click to jump to a step.

This is still backed by an input range for accessibility and keyboard controls.

<img width="1213" height="434" alt="Screenshot 2025-09-27 at 4 50 21 PM" src="https://github.com/user-attachments/assets/2c81753d-1b66-4434-8b1d-0a163fa22ab3" />
<img width="1213" height="430" alt="Screenshot 2025-09-27 at 4 50 45 PM" src="https://github.com/user-attachments/assets/07983978-a8f6-46ed-8c51-6ec96487af66" />

https://github.com/user-attachments/assets/bc725f01-f0b5-40a8-bbb5-24cc4e84e86d



Changed files:
filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css
patch: @@ -0,0 +1,57 @@
+.SuspenseScrubber {
+  position: relative;
+  width: 100%;
+  height: 1.5rem;
+  border-radius: 0.75rem;
+  padding: 0.25rem;
+  box-sizing: border-box;
+  display: flex;
+  align-items: center;
+}
+
+.SuspenseScrubber:has(.SuspenseScrubberInput:focus-visible) {
+  outline: 2px solid var(--color-button-background-focus);
+}
+
+.SuspenseScrubberInput {
+  position: absolute;
+  width: 100%;
+  opacity: 0;
+  height: 0px;
+  overflow: hidden;
+}
+
+.SuspenseScrubberInput:focus {
+  outline: none;
+}
+
+.SuspenseScrubberStep {
+  cursor: pointer;
+  flex: 1;
+  height: 100%;
+  padding-right: 1px; /* we use this instead of flex gap to make every pixel clickable */
+  display: flex;
+  align-items: center;
+}
+.SuspenseScrubberStep:last-child {
+  padding-right: 0;
+}
+
+.SuspenseScrubberBead, .SuspenseScrubberBeadSelected {
+  flex: 1;
+  height: 0.5rem;
+  background: var(--color-background-selected);
+  border-radius: 0.5rem;
+  background: var(--color-selected-tree-highlight-active);
+  transition: all 0.3s ease-in-out;
+}
+
+.SuspenseScrubberBeadSelected {
+  height: 1rem;
+  background: var(--color-background-selected);
+}
+
+.SuspenseScrubberStep:hover > .SuspenseScrubberBead,
+.SuspenseScrubberStep:hover > .SuspenseScrubberBeadSelected {
+  height: 0.75rem;
+}

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.js
patch: @@ -0,0 +1,86 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
+
+import * as React from 'react';
+import {useRef} from 'react';
+
+import styles from './SuspenseScrubber.css';
+
+export default function SuspenseScrubber({
+  min,
+  max,
+  value,
+  onBlur,
+  onChange,
+  onFocus,
+  onHoverSegment,
+  onHoverLeave,
+}: {
+  min: number,
+  max: number,
+  value: number,
+  onBlur: () => void,
+  onChange: (index: number) => void,
+  onFocus: () => void,
+  onHoverSegment: (index: number) => void,
+  onHoverLeave: () => void,
+}): React$Node {
+  const inputRef = useRef();
+  function handleChange(event: SyntheticEvent) {
+    const newValue = +event.currentTarget.value;
+    onChange(newValue);
+  }
+  function handlePress(index: number, event: SyntheticEvent) {
+    event.preventDefault();
+    if (inputRef.current == null) {
+      throw new Error(
+        'The input should always be mounted while we can click things.',
+      );
+    }
+    inputRef.current.focus();
+    onChange(index);
+  }
+  const steps = [];
+  for (let index = min; index <= max; index++) {
+    steps.push(
+      <div
+        key={index}
+        className={styles.SuspenseScrubberStep}
+        onPointerDown={handlePress.bind(null, index)}
+        onMouseEnter={onHoverSegment.bind(null, index)}>
+        <div
+          className={
+            index <= value
+              ? styles.SuspenseScrubberBeadSelected
+              : styles.SuspenseScrubberBead
+          }
+        />
+      </div>,
+    );
+  }
+
+  return (
+    <div className={styles.SuspenseScrubber} onMouseLeave={onHoverLeave}>
+      <input
+        className={styles.SuspenseScrubberInput}
+        type="range"
+        min={min}
+        max={max}
+        value={value}
+        onBlur={onBlur}
+        onChange={handleChange}
+        onFocus={onFocus}
+        ref={inputRef}
+      />
+      {steps}
+    </div>
+  );
+}

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.css
patch: @@ -9,11 +9,6 @@
   display: flex;
   flex-direction: column;
   flex-grow: 1;
-  /* 
-   * `overflow: auto` will add scrollbars but the input will not actually grow beyond visible content.
-   * `overflow: hidden` will constrain the input to its visible content.
-   */
-  overflow: hidden;
 }
 
 .SuspenseTimelineRootSwitcher {

filename: packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js
patch: @@ -8,7 +8,7 @@
  */
 
 import * as React from 'react';
-import {useContext, useLayoutEffect, useEffect, useRef} from 'react';
+import {useContext, useEffect} from 'react';
 import {BridgeContext, StoreContext} from '../context';
 import {TreeDispatcherContext} from '../Components/TreeContext';
 import {useHighlightHostInstance} from '../hooks';
@@ -17,10 +17,7 @@ import {
   SuspenseTreeStateContext,
 } from './SuspenseTreeContext';
 import styles from './SuspenseTimeline.css';
-import typeof {
-  SyntheticEvent,
-  SyntheticPointerEvent,
-} from 'react-dom-bindings/src/events/SyntheticEvent';
+import SuspenseScrubber from './SuspenseScrubber';
 import Button from '../Button';
 import ButtonIcon from '../ButtonIcon';
 
@@ -39,29 +36,6 @@ function SuspenseTimelineInput() {
     playing,
   } = useContext(SuspenseTreeStateContext);
 
-  const inputRef = useRef<HTMLElement | null>(null);
-  const inputBBox = useRef<ClientRect | null>(null);
-  useLayoutEffect(() => {
-    if (timeline.length === 0) {
-      return;
-    }
-
-    const input = inputRef.current;
-    if (input === null) {
-      throw new Error('Expected an input HTML element to be present.');
-    }
-
-    inputBBox.current = input.getBoundingClientRect();
-    const observer = new ResizeObserver(entries => {
-      inputBBox.current = input.getBoundingClientRect();
-    });
-    observer.observe(input);
-    return () => {
-      inputBBox.current = null;
-      observer.disconnect();
-    };
-  }, [timeline.length]);
-
   const min = 0;
   const max = timeline.length > 0 ? timeline.length - 1 : 0;
 
@@ -100,8 +74,7 @@ function SuspenseTimelineInput() {
     });
   }
 
-  function handleChange(event: SyntheticEvent) {
-    const pendingTimelineIndex = +event.currentTarget.value;
+  function handleChange(pendingTimelineIndex: number) {
     switchSuspenseNode(pendingTimelineIndex);
   }
 
@@ -113,25 +86,11 @@ function SuspenseTimelineInput() {
     switchSuspenseNode(timelineIndex);
   }
 
-  function handlePointerMove(event: SyntheticPointerEvent) {
-    const bbox = inputBBox.current;
-    if (bbox === null) {
-      throw new Error('Bounding box of slider is unknown.');
-    }
-
-    const hoveredValue = Math.max(
-      min,
-      Math.min(
-        Math.round(
-          min + ((event.clientX - bbox.left) / bbox.width) * (max - min),
-        ),
-        max,
-      ),
-    );
+  function handleHoverSegment(hoveredValue: number) {
     const suspenseID = timeline[hoveredValue];
     if (suspenseID === undefined) {
       throw new Error(
-        `Suspense node not found for value ${hoveredValue} in timeline when on ${event.clientX} in bounding box ${JSON.stringify(bbox)}.`,
+        `Suspense node not found for value ${hoveredValue} in timeline.`,
       );
     }
     highlightHostInstance(suspenseID);
@@ -239,18 +198,15 @@ function SuspenseTimelineInput() {
       <div
         className={styles.SuspenseTimelineInput}
         title={timelineIndex + '/' + max}>
-        <input
-          className={styles.SuspenseTimelineSlider}
-          type="range"
+        <SuspenseScrubber
           min={min}
           max={max}
           value={timelineIndex}
           onBlur={handleBlur}
           onChange={handleChange}
           onFocus={handleFocus}
-          onPointerMove={handlePointerMove}
-          onPointerUp={clearHighlightHostInstance}
-          ref={inputRef}
+          onHoverSegment={handleHoverSegment}
+          onHoverLeave={clearHighlightHostInstance}
         />
       </div>
     </>