TL;DR: a toolbar click moves the viewport in up to three stacked ways, and each needs its own stop. First, mousedown on the button steals focus from the editor, so prevent the default on mousedown and the editor never blurs and the selection never collapses. Second, the usual recovery code refocuses with scrolling turned on: in Tiptap that is the focus command, whose scrollIntoView option defaults to true, so pass it as false, and in plain DOM code call focus with preventScroll set to true. Third, keep a belt-and-braces guard for popovers and nested scrollers: save every scroll position before the command and restore it after. Finally, set scroll padding on the scrolling element equal to the sticky toolbar height, so the scrolls that should happen land clear of the bar instead of underneath it.

Verbatim problem (the search that created the source thread):

```text
Rich text editor sticky toolbar clicks scroll jump focus scrollIntoView preserve viewport selection
```

Symptoms: the user selects text, clicks Bold, Link, or a heading button in a sticky toolbar, and the page or the editor pane jumps: up to the caret, down a screen, or to the top. The caret often ends up hidden under the sticky toolbar after the jump, and in plain contenteditable editors the selection itself is gone, so the format applies to nothing or to the wrong place.

## Steps

### 1. Stop the focus theft at mousedown, not at click

Focus moves on mousedown, before the click handler runs. If the button takes focus, the browser scrolls the newly focused element into view and the editor selection collapses in the DOM. Put a mousedown handler on the toolbar container that prevents the default for simple command buttons; the click event still fires afterwards, so the command runs exactly as before.

```js
function keepEditorFocus(event) {
  // only for plain command buttons; step 4 covers inputs and popovers
  if (event.target.closest('[data-keep-selection]')) {
    event.preventDefault()
  }
}

// React: onMouseDown={keepEditorFocus} on the toolbar wrapper
// Vanilla: toolbarEl.addEventListener('mousedown', keepEditorFocus)
```

Expected output: clicking a toolbar button never changes document.activeElement; it stays on the editor element. Success check: the selected text stays visibly selected while the button is pressed, and window scroll position does not change between mousedown and mouseup.

### 2. Refocus without scrolling when you do refocus

Some buttons legitimately move focus (menus, dialogs). When your code puts focus back, turn the scroll off at that call. In Tiptap, chain the format command with a focus call that passes scrollIntoView as false; the default is true, which is the jump most people are chasing.

```js
editor.chain()
  .focus(null, { scrollIntoView: false })
  .toggleBold()
  .run()
```

For a single command it is the same idea: editor.commands.focus(null, { scrollIntoView: false }). In raw ProseMirror, call view.focus() and do not add a scrollIntoView call to the transaction you dispatch for the format. In plain DOM code, use the preventScroll option: editorEl.focus({ preventScroll: true }). Never call a bare element scrollIntoView on the caret or its paragraph as part of a toolbar action.

Expected output: the format applies, the editor regains focus, and the viewport does not move. Success check: record the scroll positions of the window and the editor pane before the click and compare after; they are unchanged.

### 3. Guard nested scrollers with a save and restore of scroll positions

Steps 1 and 2 miss cases where something else scrolls: a popover library returning focus on close, a validation effect, or a browser that scrolls on focus in the next frame anyway. Wrap toolbar commands in a guard that snapshots the window plus every scrollable ancestor of the editor, runs the command, and restores synchronously and once more on the next animation frame.

```js
function scrollablesOf(el) {
  const out = []
  let node = el
  while (node && node !== document.body) {
    if (node.scrollHeight !== node.clientHeight || node.scrollWidth !== node.clientWidth) {
      out.push(node)
    }
    node = node.parentElement
  }
  out.push(document.scrollingElement)
  return out
}

function runWithoutScrollJump(editorEl, command) {
  const saved = scrollablesOf(editorEl).map(function (el) {
    return [el, el.scrollTop, el.scrollLeft]
  })
  command()
  function restore() {
    saved.forEach(function (entry) {
      entry[0].scrollTop = entry[1]
      entry[0].scrollLeft = entry[2]
    })
  }
  restore()
  requestAnimationFrame(restore)
}
```

Expected output: even if a library or the browser scrolls during the command, the positions are put back in the same frame, before paint, so there is no visible flicker. Success check: the guard works with the editor inside a modal or a scrolling sidebar, not just with plain page scroll.

### 4. Preserve the selection explicitly where focus must move

Link fields, color pickers, and other popover inputs need real focus, so step 1 must not apply inside them. For those, snapshot the selection before the popover opens and restore it before running the command. In Tiptap and ProseMirror the selection lives in the editor state, so read selection.from and selection.to from editor.state.selection, then set it back with setTextSelection using those positions before applying the mark. In a plain contenteditable, clone the current Range from window.getSelection before opening, and on apply, re-add that range and refocus with preventScroll. In popovers built on dialog primitives, also prevent the automatic focus events: an open-auto-focus and close-auto-focus handler that calls preventDefault stops the library from focusing its first item and from returning focus with a scroll.

Expected output: typing a URL into the link field, confirming, and the link lands on exactly the text that was selected before the popover opened. Success check: the applied range matches the saved from and to positions, and the scroll guard from step 3 reports no net movement.

### 5. Give legitimate scrolls a sticky offset with scroll padding

Even with the jumps gone, a real scroll to the caret (keyboard navigation, a deliberate scrollIntoView command) aligns the caret with the top of the scrolling area, directly under a sticky toolbar, so it looks lost and the next click jumps again as the browser corrects. Declare the offset once on the element that actually scrolls:

```css
/* the page is the scroller */
html {
  scroll-padding-top: 64px; /* sticky toolbar height plus a small gap */
}

/* or, when an inner pane scrolls, put it on that pane instead */
.editor-scroll-pane {
  scroll-padding-top: 64px;
}
```

Use the rendered height of your toolbar, including a second row when it wraps on narrow screens; a wrapping toolbar that doubles in height is a common reason the jump only happens on mobile widths. scroll-margin-top on the editor paragraphs is the per-element equivalent when scroll padding is not an option.

Expected output: a deliberate scroll to the caret stops with the caret line fully visible below the toolbar. Success check: scroll to a paragraph at the very top of the document and confirm no part of its line is covered by the toolbar.

### 6. Audit the other scrollIntoView callers in the editor code

Toolbar focus is the usual suspect, not the only one. Search the codebase for scrollIntoView and for focus calls, and classify each hit: toolbar and format paths get the no-scroll treatment above; selection-restored effects after external content loads should restore scroll from a snapshot, not scroll to the caret; find, outline navigation, and mention or comment jumps keep scrolling, but they inherit the step 5 offset. Also check error handling that focuses the first invalid field inside the editor, and any effect that refocuses the editor on every render.

Expected output: every remaining scrolling call is one a user asked for. Success check: clicking through every toolbar control, opening and closing every popover, and saving the document produces no viewport movement at all.

### 7. Test the fix in a real browser, at three caret positions

Layout and scrolling do not exist in jsdom, so verify in a real browser (a manual pass, or Playwright against the built app). Put the caret (a) at the first line, partly under where the toolbar sticks, (b) mid-document with the page scrolled, and (c) at the last line inside a nested scrolling pane. At each position select a word, click Bold, then a heading button, then open and confirm the link popover. Assert the window scroll position and the pane scroll position are unchanged, the selection still covers the same word, and the format was applied.

Expected output: all three positions pass with no jump and no lost selection. Success check: removing the step 1 mousedown handler or the step 2 scrollIntoView flag makes at least one position fail, which proves the test is guarding the real mechanism. Note: if the page uses smooth scrolling behavior, programmatic restores animate and fight the guard; assert positions after the animation settles, and prefer instant restores inside the guard.

## When this applies

- A rich text editor (Tiptap or ProseMirror, Lexical, Slate, Quill, CKEditor, TinyMCE, or a hand-rolled contenteditable) with a toolbar that is sticky, fixed, or inside a scrolling pane or modal.
- Clicking a format button, opening a toolbar popover, or closing one moves the page or pane, hides the caret under the toolbar, or drops the selection.
- The jump happens on toolbar actions only; normal typing and scrolling behave.

## When this does not apply

- The whole page scrolls while typing, with no toolbar involved. That is usually a caret-visibility or content-growing problem, not focus handling.
- You want the scroll: find results, outline and heading navigation, comment and mention deep links should scroll to the caret, with the step 5 offset.
- The editor unmounts and remounts on state changes (a key change or a conditional render). Fix the remount first; no focus trick survives the editor being recreated.

### Variant: clicking Bold scrolls the page to the top

Almost always step 2: a refocus or a fresh editor mount resets scroll. Check the focus call first, then check whether the editor component remounted (the does-not-apply case above).

### Variant: the selection disappears when I click the toolbar

That is step 1 in plain contenteditable and some Slate setups: the DOM selection collapses on blur. Prevent the mousedown default; do not try to rebuild the selection after the click, because by then it is already gone.

### Variant: the jump only happens when the link popover closes

That is the popover library returning focus on close, with scrolling. Prevent its close-auto-focus behavior and refocus the editor yourself with scrolling off, as in steps 2 and 4.

### Variant: the caret ends up hidden under the sticky header after scrolling

The scroll itself was legitimate but had no offset. That is step 5 alone: scroll padding on the scroller equal to the sticky height.

## Why it happens

Three different mechanisms produce the same visible jump. Browsers scroll a newly focused element into view, and focus changes on mousedown, so a toolbar click both blurs the editor and asks the browser to reveal the button. Editor libraries then helpfully scroll to the selection when refocusing: the Tiptap focus command documents a scrollIntoView option that defaults to true, and ProseMirror transactions can carry the same instruction. And neither the browser nor the library knows about your sticky toolbar, so even a correct scroll-to-caret aligns the caret with the viewport top, underneath the bar. Each mechanism needs its own one-line stop, which is why single fixes for this bug so often half-work.

## Edge cases and pitfalls

- Do not put the mousedown prevention on inputs inside the toolbar (link URL fields, search boxes in menus). Those need focus and a caret; use the save and restore path from step 4 for them instead.
- Keyboard and assistive-tech users still need to reach the toolbar. Preventing the mouse default does not change tab order, but do not pair it with removing the buttons from the tab sequence; a toolbar that only works by mouse is an accessibility regression.
- Touch: a pointerdown prevention can swallow the subsequent click in some mobile browsers. Apply prevention on mousedown for mouse input, test tap behavior on a real device, and if taps stop registering, run the command from the pointer handler for touch instead.
- Safari historically focuses differently: buttons may not take focus at all, so the jump there usually comes from the refocus call, not the click. Test step 2 separately per browser.
- A toolbar that wraps to two rows changes the sticky height and the step 5 offset. Measure the rendered height at runtime if your toolbar wraps or grows (for example when a second contextual row appears).
- Restoring scroll after the command hides real layout shifts too: if applying a format genuinely changes document height above the caret (a heading toggle can), a hard restore can leave the caret off-screen. In that case keep steps 1 and 2, and let step 3 restore only the window position, not the pane.
