Problem
The current ExperimentalMobileFormattingToolbarController uses React state (setTransform) on every visualViewport scroll/resize event. Each state update triggers a full React re-render, causing visible flickering as noted in the code comments and in #1284.
Additionally, related mobile toolbar issues exist:
Proposed Solution
We implemented an alternative approach in our project that eliminates the flickering by using CSS custom properties instead of React state for positioning:
useEffect(() => {
if (!window.visualViewport) return;
const vp = window.visualViewport;
const update = () => {
const layoutHeight = document.documentElement.clientHeight;
const keyboardHeight = layoutHeight - vp.height;
wrapperRef.current?.style.setProperty(
'--mobile-keyboard-offset',
keyboardHeight > 0 ? `${keyboardHeight}px` : '0px'
);
};
vp.addEventListener('resize', update);
vp.addEventListener('scroll', update);
return () => {
vp.removeEventListener('resize', update);
vp.removeEventListener('scroll', update);
};
}, []);
Combined with CSS:
@media (max-width: 767px) {
.formatting-toolbar {
position: fixed;
bottom: var(--mobile-keyboard-offset, 0px);
left: 0;
right: 0;
padding-bottom: calc(0.375rem + env(safe-area-inset-bottom, 0));
z-index: 50;
}
}
Why this works better
| Aspect |
Current (React state) |
Proposed (CSS variable) |
| Re-renders on keyboard change |
Yes — setTransform triggers reconciliation |
No — style.setProperty is direct DOM |
| Flickering |
Visible due to render cycle delay |
None — compositor handles CSS vars |
| URL bar handling |
Uses window.innerHeight (includes URL bar) |
Uses clientHeight (excludes URL bar) |
| Safe area (notch) |
Not handled |
env(safe-area-inset-bottom) |
| Scroll events |
Not listened to |
Listened — handles URL bar collapse |
Key implementation details
- Uses
document.documentElement.clientHeight instead of window.innerHeight to avoid the URL bar inflating the keyboard height calculation on Android Chrome
- Listens to both
resize and scroll events on visualViewport — the scroll event fires when the URL bar collapses/expands
env(safe-area-inset-bottom) handles iPhone notch/home indicator
- Touch device detection gates the listener so desktop is unaffected
Willingness to contribute
We'd be happy to submit a PR with a MobileFormattingToolbarController component implementing this approach. We're using it in production with BlockNote v0.47.3 and it works well across iOS Safari and Android Chrome.
Would love feedback on whether this direction makes sense before writing the PR. Happy to coordinate with #2591 (portal floating UI to body) if needed.
Problem
The current
ExperimentalMobileFormattingToolbarControlleruses React state (setTransform) on everyvisualViewportscroll/resize event. Each state update triggers a full React re-render, causing visible flickering as noted in the code comments and in #1284.Additionally, related mobile toolbar issues exist:
Proposed Solution
We implemented an alternative approach in our project that eliminates the flickering by using CSS custom properties instead of React state for positioning:
Combined with CSS:
Why this works better
setTransformtriggers reconciliationstyle.setPropertyis direct DOMwindow.innerHeight(includes URL bar)clientHeight(excludes URL bar)env(safe-area-inset-bottom)Key implementation details
document.documentElement.clientHeightinstead ofwindow.innerHeightto avoid the URL bar inflating the keyboard height calculation on Android Chromeresizeandscrollevents onvisualViewport— the scroll event fires when the URL bar collapses/expandsenv(safe-area-inset-bottom)handles iPhone notch/home indicatorWillingness to contribute
We'd be happy to submit a PR with a
MobileFormattingToolbarControllercomponent implementing this approach. We're using it in production with BlockNote v0.47.3 and it works well across iOS Safari and Android Chrome.Would love feedback on whether this direction makes sense before writing the PR. Happy to coordinate with #2591 (portal floating UI to body) if needed.