Skip to main content
Back to blog
iOSSafariWebKitLexicalcontenteditableDebugging

Four bugs killed text selection on iOS — and each one hid the next

A Lexical editor inside a vaul drawer worked everywhere except on a real iPhone: selection handles wouldn't drag, scrolling locked at the edges, the toolbar dove under the keyboard. Guessing fixed nothing, because there was no single culprit — there were four independent ones, each masking the next. What worked was a bisection stand: the same skeleton with one toggle per suspect, tested on a physical device. This is the full hunt — href poisoning selection, vaul's drag handlers, our own tap-focus, a container moving under the finger — plus the scroll-lock autopsy and the three one-shot scrollTop writes that made rubber-band scrolling feel native, including catching a swipe mid-bounce.

Published September 5, 202615 min read

The mobile text editor in landee is Lexical inside a vaul drawer: tap a text block, a full-screen drawer slides up, you edit, a checkmark closes it. On desktop and in the simulator everything was fine. On a physical iPhone it was a disaster with several independent faces: the selection handles — the two blue pins iOS gives you to stretch a selection — would not drag at all, or only after holding them for about two seconds; dragging the right handle toward the left collapsed the whole selection; scrolling locked at the bottom edge until you tugged further down to «unhook» it; and the toolbar dove under the keyboard whenever the device put an accessory bar above it.

This is a chronicle of how that got fixed — the route, not just the fixes, because the route is the transferable part. The headline lesson: when «selection doesn't work» on iOS, there is no single culprit to find. There were four, independent of each other, and every one of them masked the next — fixing one changed the symptoms just enough to make the next hypothesis look wrong.

Stack: Next.js 16, React 19, Lexical 0.50, a drawer built on vaul. Everything below was verified on a physical iPhone against production builds; the code samples are the shipped code, trimmed to the point.

The method that failed, and the one that worked

The first hours went to guessing: maybe the drawer's transform layer, maybe the CSS mask on the scroller, maybe Lexical's selection commands. Every guess produced a plausible patch and zero change on the device. Two of those patches later turned out to be bugs of their own: a silencer on Lexical's SELECTION_CHANGE_COMMAND, and a guard over Selection.prototype write methods that made typing land at the start of the line — a tap no longer delivered the caret.

What worked was bisection on an isolated stand: a debug page with the same skeleton as the editor — the same vaul drawer, the same column, the same scroller — and a toggle button for every suspect. A bare contenteditable there was perfect. Each toggle added one layer back until something broke. Four rounds on a live phone convicted exactly four things and acquitted everything else — the body scroll lock, the masks, the transform layers, the dialog role — suspects we would otherwise have kept «fixing» for days.

Two constraints kept the stand honest. Only a physical device counts: the simulator has neither a real keyboard nor the device's accessory panels above it, and half of the bugs live exactly there. And only production builds count: development bundles are heavy enough that WebKit kills the tab out of memory, and everything looks broken before hydration even finishes.

Culprit one: href on a link inside contenteditable

For iOS, <a href> is interactive even inside editable text: a touch starts the «tap a link» gesture, and that gesture outranks the selection gesture — the moment a selection handle touches a link, the selection collapses. The same anchor without href is just text. -webkit-touch-callout: none does not save you. And a bare contenteditable with no editor library is poisoned exactly the same way — so Lexical was innocent.

The fix removes href from the editor's live DOM right after Lexical renders or updates a link node. Nothing is lost anywhere: navigation is disabled in edit mode anyway, the URL lives in the Lexical node and stays editable, and the saved HTML keeps its href — export goes through exportDOM, which builds its own elements and never sees the live DOM.

text-editor.tsx
// In the editor's live DOM links live without href — otherwise iOS
// kills the selection handles. The saved HTML is untouched: exportDOM
// builds its own elements and never sees this DOM.
useEffect(() => {
  const strip = (keys: Map<string, unknown>) => {
    for (const [key, kind] of keys) {
      if (kind === "destroyed") continue;

      const dom = editor.getElementByKey(key);

      if (dom instanceof HTMLAnchorElement) dom.removeAttribute("href");
    }
  };

  const unregisterLink = editor.registerMutationListener(LinkNode, strip, {
    skipInitialization: false,
  });
  const unregisterAuto = editor.registerMutationListener(AutoLinkNode, strip, {
    skipInitialization: false,
  });

  return () => {
    unregisterLink();
    unregisterAuto();
  };
}, [editor]);

Culprit two: vaul's drag handlers on the drawer content

vaul keeps its drag-to-close logic attached to the drawer content even with handleOnly — and that logic was killing the handle grabs. The proof was a matrix on the stand: Lexical with links inside a fake drawer of the same geometry — perfect; inside the real vaul — dead. Then, one change at a time: stripping vaul's styles (transform, will-change, touch-action) — still dead; removing the dialog role and aria attributes — still dead; unlocking the body — still dead; muting events so they stop reaching vaul's handlers — fully alive.

The mute is stopPropagation, not preventDefault: the browser and Lexical hear everything, because their listeners sit deeper in the tree — only vaul goes deaf. And it must cover both pointer and touch events: the matrix showed that pointer-silence alone is not enough, the handles stay dead while vaul can still hear touch.

text-editor.tsx
{/* stopPropagation, not preventDefault: the browser and Lexical hear
    everything (their listeners are deeper in the tree) — only vaul's
    drag logic on the drawer content goes deaf. Both pointer AND touch:
    pointer-silence alone leaves the handles dead. */}
<div
  onPointerDown={(e) => e.stopPropagation()}
  onPointerMove={(e) => e.stopPropagation()}
  onPointerUp={(e) => e.stopPropagation()}
  onPointerCancel={(e) => e.stopPropagation()}
  onPointerOut={(e) => e.stopPropagation()}
  onTouchStart={(e) => e.stopPropagation()}
  onTouchMove={(e) => e.stopPropagation()}
  onTouchEnd={(e) => e.stopPropagation()}
>
  {/* the editor column */}
</div>

Culprit three: our own tap-to-focus

A helper hook forced focus() on every touch shorter than 300 ms — and a quick grab of a selection handle is precisely a short touch. That explains the legendary symptom «hold it for two seconds, then it drags»: a long press is not a tap, so focus() never fired and the drag survived. The fix is one line — call focus() only when the editor does not already contain the focus.

Culprit four: a container that moves under the finger

The first architecture resized the whole drawer to fit above the keyboard. During selection iOS pans the screen — and the drawer followed the pan, so the text fled from under the finger and WebKit dropped the drag. The «obvious» fix — freeze the drawer while a finger is down — failed in a different way: the text shifted relative to the finger by exactly the pan amount, and the selection landed one line off target.

The rule that came out of this: you may move only what is not being touched. The drawer and the text stand at the full height of the layout viewport — on iOS the keyboard does not compress layout, it only covers the bottom of it. Concretely:

  • Only the toolbar chases the keyboard; the text's bottom inset travels through a CSS variable (--keyboard-inset) instead of anything moving.
  • A frame loop (requestAnimationFrame), not events: iOS pans during selection without reliable visualViewport events, and after the keyboard closes offsetTop sometimes never resets (an iOS 26 regression). A loop that simply looks every frame needs no signal.
  • Styles are written straight to the DOM, bypassing React — going through state would re-render the whole Lexical tree on every pan frame.
  • The wrapper scrolls, not the contenteditable. Lexical's own playground does the same (editor-scroller): a focused contenteditable that scrolls itself is the worst configuration WebKit knows — the gesture sometimes scrolls, sometimes starts a selection, sometimes feeds the page.
  • Half a second after opening, vaul's transform, will-change and touch-action are removed from the drawer: the composited layer shifts the touch target of the selection handles, and the opening animation no longer needs those styles.

The toolbar above the keyboard — and above whatever iOS puts above the keyboard

«100svh minus the keyboard» is a lie on an iPhone: the address bar takes its own slice, and the device likes to put accessory panels above the keyboard. visualViewport knows the truth, and the number the toolbar needs is how much of the bottom of the layout viewport is occupied:

text-editor.tsx
// Inside the requestAnimationFrame loop. Layout coordinates — the same
// space the toolbar's absolute position lives in. On iOS the keyboard
// never compresses layout; it only covers the bottom of it.
const inset = Math.max(
  0,
  Math.round(window.innerHeight - vv.height - vv.offsetTop),
);

toolbarHost.style.bottom = `${inset}px`;
drawer.style.setProperty("--keyboard-inset", `${inset + toolbarHeight}px`);

One subtlety cost an evening. When focus moves into a field of the toolbar itself — font size, link URL — iOS switches the keyboard type, and visualViewport reports transitional numbers for a few dozen frames. Applying them immediately dove the toolbar under the keyboard; freezing the loop entirely (the previous attempt) made it blind exactly when the height genuinely changed. The compromise: while focus is inside the toolbar, a new value must hold steady for ten frames before it is applied; in every other case it applies instantly, so the toolbar rides the keyboard's animation instead of chasing it.

Focusing those fields needs a trick of its own. When a real input inside a fixed full-screen container receives focus, iOS scrolls the page to center the field — the page has nowhere to scroll, and the layout tears apart: the drawer slides, the toolbar vanishes. vaul cures this in its own focus interceptor, which we had just muted — so the cure is reproduced by hand: cancel the native focus, teleport the field up with a transform, focus it manually, put it back next frame. Safari believes the field is «up top» and leaves the page alone.

text-editor.tsx
const onTouchEnd = (event: TouchEvent) => {
  const target = event.target as HTMLElement;

  if (
    !(target instanceof HTMLInputElement) ||
    target === document.activeElement
  ) {
    return;
  }

  // Cancel the native focus (it drags the page scroll along) and focus
  // ourselves while the field is "up top".
  event.preventDefault();
  target.style.transform = "translateY(-2000px)";
  target.focus();
  requestAnimationFrame(() => {
    target.style.transform = "";
  });
};

// passive: false — without it preventDefault has no power.
toolbarHost.addEventListener("touchend", onTouchEnd, { passive: false });

The scroll lock: both causes were ours

Scrolling «locked» at the bottom: upward gestures dead until you tugged further down, as if unhooking it. The stand — this time with a live keyboard — acquitted the entire geometry (the spacer, the overlay toolbar, the drawer itself) and convicted two things, both of them our own:

  • The keyboard padding lived inside the contenteditable. At the bottom, the visible strip above the keyboard was an empty editable zone — and iOS treats a touch on a focused contenteditable as working with text, not as scrolling. The room for the keyboard must be a separate, non-editable block after the contenteditable — not its padding.
  • Our own «safety» guard, built against the first cause. Near an edge scrollTop wanders in fractional values, and a per-frame watchdog kept «correcting» it with programmatic writes — and continuous programmatic scroll writes kill the human gesture. With the keyboard open, the guard itself became the lock.

The rule: no preventDefault at the edges and no scrollTop watchers, ever. A plain overflow-y-auto wrapper with the spacer outside the contenteditable scrolls perfectly — plus overscroll-contain, so an edge gesture doesn't feed the page, which iOS makes scrollable for the sake of the focused field.

text-editor.tsx
<div
  ref={scrollerRef}
  className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
>
  <ContentEditable className="p-5 outline-none" />

  {/* Room for the keyboard as a SEPARATE non-editable block, not as
      padding of the contenteditable: editable padding at the bottom
      turns the scroll gesture into "working with text" and locks the
      scroller. */}
  <div aria-hidden style={{ height: "var(--keyboard-inset, 0px)" }} />
</div>

Three one-shot writes: the whole rubber-band protocol

With the locks gone, one family of quirks remained — and all three cures turned out to be single scrollTop writes. Never loops, never guards. First: a scroller resting on an exact edge (0 or max) sometimes «sticks» — the reverse gesture is dead until you tug past the edge. The canonical cure, documented for a decade (iNoBounce and company): on touchstart, nudge scrollTop one pixel off the edge — before WebKit decides what the gesture is. The edge becomes unreachable, and the stuck state never forms.

Second: momentum can carry the scroller into the edge after the finger is gone. The touchstart nudge is one swipe too late for that. And an immediate nudge from the scroll handler cut the native spring short — the edge felt like hitting a wall. So the settle nudge is deferred: when the scroll events go quiet for 140 ms with no finger down and the position is an exact edge, one quiet write moves it a pixel in.

Third, the hardest: a swipe during the bounce animation was simply eaten. This is platform behavior — while the rubber-band animation plays, WebKit attaches the gesture to nothing until the animation completes. You swiped, nothing moved, you swiped again. No article we found offers an interruption; the trail ends at «wait for it to finish».

The way in: iOS exposes scrollTop beyond the bounds during the bounce — negative above, greater than max below. That makes the moment strictly detectable: st < 0 || st > max is a genuine overscroll and never a calm rest at an edge. On touchstart in that state, one clamped write interrupts the animation — and the very same gesture grabs the scroller. One guard ships with it: first finger only (touching === 1), because an animation only plays when no finger held the scroller, and a second finger must not yank the text from under the first.

text-editor.tsx
const nudge = () => {
  const max = scroller.scrollHeight - scroller.clientHeight;

  if (max <= 1) return;

  if (scroller.scrollTop <= 0) scroller.scrollTop = 1;
  else if (scroller.scrollTop >= max) scroller.scrollTop = max - 1;
};

let touching = 0;
let settleTimer = 0;

const touchBegin = () => {
  touching += 1;
  window.clearTimeout(settleTimer);

  const max = scroller.scrollHeight - scroller.clientHeight;
  const st = scroller.scrollTop;

  // Strictly OUT of bounds — a finger landing mid-bounce, not resting
  // at an edge. One clamped write interrupts the animation, and the
  // same gesture grabs the scroller. Only for the first finger: an
  // animation only plays when no finger held the scroller.
  if (touching === 1 && max > 1 && (st < 0 || st > max)) {
    scroller.scrollTop = st < 0 ? 1 : max - 1;
    return;
  }

  nudge();
};
const touchFinish = () => {
  touching = Math.max(0, touching - 1);
};

// Deferred, not immediate: an instant nudge mid-bounce cut the native
// spring short and the edge felt like hitting a wall. Wait for the
// scroll stream to go quiet, then one quiet write.
const onScroll = () => {
  if (touching > 0) return;

  window.clearTimeout(settleTimer);
  settleTimer = window.setTimeout(nudge, 140);
};

scroller.addEventListener("touchstart", touchBegin, { passive: true });
scroller.addEventListener("touchend", touchFinish, { passive: true });
scroller.addEventListener("touchcancel", touchFinish, { passive: true });
scroller.addEventListener("scroll", onScroll, { passive: true });
  • The overflow-toggle hammer (set overflow: hidden, clamp, force a reflow, restore) — rejected twice on the device. Its first version also fired on calm touches at the edge (a <= 0 where a < 0 belonged) and broke normal scrolling along the way.
  • overscroll-behavior: none — removes the problem together with the spring itself: momentum stops dead at the edge, and the edge feels chopped off.

Proven innocent — don't hunt these again

  • vaul's position: fixed body lock
  • the CSS fade mask on the scroller
  • the transform layer by itself (removing it is right, but it was not the killer)
  • the drawer's dialog role, aria and data attributes
  • Lexical itself — a bare contenteditable reproduces both the href poison and the scroll lock
  • silencing Lexical's SELECTION_CHANGE_COMMAND — changed nothing
  • guarding Selection.prototype writes — became its own bug: the tap stopped delivering the caret and typing went to the start of the line

A bonus enemy: native selection versus the URL field

Editing a link has a built-in conflict: the native iOS selection lives only in a focused editor, and the URL field needs the focus for itself — they cannot coexist. So for the duration of editing, the selected text is wrapped in a Lexical MarkNode: a real highlight in the DOM that does not care where the focus is. Lexical's own playground highlights comment targets with the same mechanism.

The selection itself is saved continuously, on every selection change — the same lastSelection pattern as Lexical's FloatingLinkEditorPlugin — because saving it when the button is pressed is too late: the tap on the toolbar button collapses the selection first. On apply, releasing the mark recreates the real selection exactly in its place, and TOGGLE_LINK_COMMAND does the rest.

Takeaways

  • «Selection doesn't work on iOS» is not one bug. There were four independent causes, each masking the next — single-hypothesis debugging cannot converge on that.
  • Bisection beats deduction. An isolated stand with one toggle per suspect turned days of guessing into four device rounds — and acquitted the suspects that would otherwise have been «fixed» for days.
  • Only a physical device tells the truth: the simulator has no real keyboard and no accessory panels, and dev bundles die out of memory before the bugs even appear.
  • Move only what is not being touched: a static drawer, a toolbar on a frame loop, the bottom inset through a CSS variable.
  • Never fight iOS scrolling with continuous writes or with preventDefault at the edges — both become the very lock they were meant to prevent. Every cure that survived is a single scrollTop write at a precisely detected moment.
  • The full rubber-band protocol: a nudge on touchstart at a resting edge, a deferred settle nudge after momentum, and a clamp on touchstart mid-bounce — which iOS makes detectable by exposing out-of-bounds scrollTop.

Spot a mistake?

A wrong fact, an off translation, something that reads false in this article? Tell me — in your own language.