Skip to content

fix: flicker-free mobile formatting toolbar via CSS custom properties#2617

Open
Movm wants to merge 1 commit intoTypeCellOS:mainfrom
Movm:fix/flicker-free-mobile-toolbar
Open

fix: flicker-free mobile formatting toolbar via CSS custom properties#2617
Movm wants to merge 1 commit intoTypeCellOS:mainfrom
Movm:fix/flicker-free-mobile-toolbar

Conversation

@Movm
Copy link
Copy Markdown

@Movm Movm commented Apr 2, 2026

Summary

Replaces the React state-driven positioning in ExperimentalMobileFormattingToolbarController with a CSS custom property (--bn-mobile-keyboard-offset), eliminating the re-render storm that causes visible flickering during keyboard animation.

Tested on iOS Safari (iPhone) — the Visual Viewport API fallback works great, smooth positioning with no flicker.

Relates to #938, #2122, #2616

Rationale

The current implementation calls setTransform() → React re-render on every visualViewport scroll/resize event. During keyboard animation this fires dozens of times per second, causing visible toolbar flickering. Moving positioning to a CSS custom property lets the browser compositor handle animation via transition: bottom 0.15s ease-out — zero React re-renders for positioning.

Changes

ExperimentalMobileFormattingToolbarController.tsx

Two-tier keyboard detection (progressive enhancement):

  1. VirtualKeyboard API (Chrome/Edge 94+, Samsung Internet) — sets overlaysContent = true and listens to geometrychange for exact keyboard bounding rect before animation starts
  2. Visual Viewport API fallback (Safari iOS 13+, Firefox Android 68+) — computes keyboard height from clientHeight - vp.height - vp.offsetTop, with focus-based prediction that immediately applies the last-known keyboard height on focusin to avoid toolbar jumping after the keyboard animates in

Both tiers write a single CSS custom property --bn-mobile-keyboard-offset directly to the wrapper DOM element — no useState, no re-renders.

Additional improvements:

  • scrollSelectionIntoView() — auto-scrolls the cursor/selection into view when the keyboard opens, accounting for toolbar height
  • focusout listener resets offset to 0 when keyboard dismisses
  • Proper cleanup of all event listeners

styles.css

New .bn-mobile-formatting-toolbar class:

  • bottom: var(--bn-mobile-keyboard-offset, 0px) — positions above keyboard
  • transition: bottom 0.15s ease-out — smooth animation
  • touch-action: pan-x — allows horizontal scrolling on toolbar buttons without vertical scroll interference
  • padding-bottom: env(safe-area-inset-bottom) — handles notch/home indicator on modern devices

Browser support

Browser API Used Quality
Chrome/Edge Android 94+ VirtualKeyboard Instant, exact geometry
Samsung Internet VirtualKeyboard Instant, exact geometry
Safari iOS 13+ Visual Viewport + prediction Smooth, tested on device ✅
Firefox Android 68+ Visual Viewport + prediction Smooth

Note on interactive-widget

For best results, consumers can add interactive-widget=resizes-content to their viewport meta tag. This makes position: fixed elements work naturally with the keyboard. However, the implementation works without it — the Visual Viewport fallback handles both cases.

Summary by CodeRabbit

  • Improvements
    • Enhanced mobile formatting toolbar positioning with improved keyboard detection using native Virtual Keyboard API support
    • Better handling of keyboard visibility on mobile devices with fallback detection for older browsers
    • Optimized selection visibility when using the formatting toolbar with on-screen keyboards

Replace React state-driven positioning with CSS custom property
(--bn-mobile-keyboard-offset) for zero re-render toolbar positioning.

Two-tier keyboard detection:
1. VirtualKeyboard API (Chrome/Edge 94+) — exact geometry, no delay
2. Visual Viewport API fallback (Safari iOS 13+, Firefox 68+) — with
   focus-based prediction for instant initial positioning

Additional improvements:
- Auto-scroll selection into view when keyboard opens
- touch-action: pan-x for horizontal toolbar scrolling
- env(safe-area-inset-bottom) for notch/home indicator handling
- Smooth 150ms CSS transition instead of React re-renders

Closes TypeCellOS#2616
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 2, 2026

@Movm is attempting to deploy a commit to the TypeCell Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 2, 2026

📝 Walkthrough

Walkthrough

Refactored mobile formatting toolbar positioning from React state-driven transforms to CSS custom property mutations, implementing enhanced keyboard detection via the Virtual Keyboard API with fallback to Visual Viewport computation, and added selection-aware scroll adjustments to maintain visibility.

Changes

Cohort / File(s) Summary
Toolbar Controller Refactoring
packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx
Replaced React state-based transform computation with direct DOM CSS custom property mutation (--bn-mobile-keyboard-offset). Implemented two-tier keyboard detection: primary path uses navigator.virtualKeyboard API on geometrychange event; fallback computes keyboard height from visualViewport vs layout viewport dimensions. Added selection-based scrollBy adjustments to keep content visible relative to toolbar. Changed toolbar rendering from inline styles to class-based styling.
Mobile Toolbar Styling
packages/react/src/editor/styles.css
Added .bn-mobile-formatting-toolbar CSS class for fixed bottom-positioned toolbar with CSS custom property-driven vertical offset, smooth 0.15s ease-out transitions, horizontal scroll support, and safe-area padding integration.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 A keyboard hops into view,
and the toolbar knows just what to do—
no React state weighs it down,
CSS whispers make it float and frown,
keeping words within the crown! ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately reflects the main change: replacing React state-driven positioning with CSS custom properties to eliminate toolbar flickering on mobile.
Description check ✅ Passed The description comprehensively covers all template sections with detailed technical context, rationale, browser support tables, and testing evidence.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx (2)

120-122: focusout may briefly flash toolbar to bottom when focus moves between inputs.

When the user taps from one input to another, focusout fires before focusin, causing a momentary reset to 0 before the next focusin restores the offset. This could cause a brief visual jump.

Consider using a small delay or checking relatedTarget to avoid resetting when focus moves to another editable element.

Proposed fix using relatedTarget check
     const onFocusOut = (e: FocusEvent) => {
+      const related = e.relatedTarget as HTMLElement | null;
+      if (
+        related &&
+        (related.isContentEditable ||
+          related.tagName === "INPUT" ||
+          related.tagName === "TEXTAREA")
+      ) {
+        return; // Focus moving to another input, don't reset
+      }
       setOffset(0);
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx`
around lines 120 - 122, The onFocusOut handler currently unconditionally calls
setOffset(0) on the focusout event which can cause a brief visual jump when
focus moves between inputs; modify onFocusOut to inspect the event.relatedTarget
(or use a tiny debounce) and only call setOffset(0) when relatedTarget is null
or not an editable element (or after a short timeout if relatedTarget is
unavailable), so transitions between editable fields in
ExperimentalMobileFormattingToolbarController do not reset the offset
prematurely.

73-90: Reset overlaysContent on cleanup to prevent unexpected behavior in single-page apps.

The overlaysContent property persists at the document level even after component unmount. While not strictly required by the spec, explicitly resetting it to false on cleanup is defensive best practice—especially for single-page apps, where a known Chromium bug may fail to clear the state across virtual navigations.

Proposed fix
       return () => {
+        vk.overlaysContent = false;
         vk.removeEventListener("geometrychange", onGeometryChange);
         document.removeEventListener("selectionchange", onSelectionChange);
         clearTimeout(scrollTimer);
       };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx`
around lines 73 - 90, The VirtualKeyboard handler sets vk.overlaysContent = true
but never restores it; update ExperimentalMobileFormattingToolbarController.tsx
to capture the previous value (const previousOverlays = vk.overlaysContent)
before setting it, and in the cleanup returned from the vk branch (the same
place that calls vk.removeEventListener and document.removeEventListener)
restore vk.overlaysContent = previousOverlays (or false if you prefer a
defensive reset) to avoid leaking the overlaysContent state across navigations;
reference the vk variable and the existing onGeometryChange/removeEventListener
cleanup when applying the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx`:
- Around line 120-122: The onFocusOut handler currently unconditionally calls
setOffset(0) on the focusout event which can cause a brief visual jump when
focus moves between inputs; modify onFocusOut to inspect the event.relatedTarget
(or use a tiny debounce) and only call setOffset(0) when relatedTarget is null
or not an editable element (or after a short timeout if relatedTarget is
unavailable), so transitions between editable fields in
ExperimentalMobileFormattingToolbarController do not reset the offset
prematurely.
- Around line 73-90: The VirtualKeyboard handler sets vk.overlaysContent = true
but never restores it; update ExperimentalMobileFormattingToolbarController.tsx
to capture the previous value (const previousOverlays = vk.overlaysContent)
before setting it, and in the cleanup returned from the vk branch (the same
place that calls vk.removeEventListener and document.removeEventListener)
restore vk.overlaysContent = previousOverlays (or false if you prefer a
defensive reset) to avoid leaking the overlaysContent state across navigations;
reference the vk variable and the existing onGeometryChange/removeEventListener
cleanup when applying the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 567f3c38-9b91-4702-9f05-5d0d0ced3135

📥 Commits

Reviewing files that changed from the base of the PR and between 07df972 and 207c506.

📒 Files selected for processing (2)
  • packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx
  • packages/react/src/editor/styles.css

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant