From 8eb53e3beec817e3784b7e66ce3a2967190f38d2 Mon Sep 17 00:00:00 2001 From: yousefed Date: Wed, 19 Aug 2026 12:48:09 +0200 Subject: [PATCH 1/2] docs: name the mobile toolbar layouts, add layout toggle to example Introduce "scrolling document" (default) vs "pinned scroll container" (opt-in) as the names for the two page layouts the mobile formatting toolbar supports, and restructure the docs section around them. - Docs: simple-first rewrite of the Mobile Formatting Toolbar section (default layout, then the opt-in layout with its two CSS rules). - Example: stop embedding it in the docs (`docs: false`) - its page-level CSS (html/body overflow, full-viewport fixed scroll host, `.prose` rules) leaks into the docs page since examples render inline. Link to the standalone playground example instead. - Example: add a nav-bar switch that toggles the pinned scroll container layout via a class on , so both layouts can be compared. - Playground: `.mantine-AppShell-root` width 100vw -> 100%, which caused a horizontal scrollbar on any example taller than the viewport. - Align README, JSDoc and example comments with the new naming; regenerate examples.gen.tsx. --- .../react/components/formatting-toolbar.mdx | 23 ++++-- .../.bnexample.json | 2 +- .../14-mobile-formatting-toolbar/README.md | 4 +- .../14-mobile-formatting-toolbar/src/App.tsx | 32 ++++++-- .../src/DummyUI.tsx | 18 ++++- .../src/style.css | 73 +++++++++++++++++-- .../MobileFormattingToolbarController.tsx | 12 +-- .../FormattingToolbar/useVirtualKeyboard.ts | 4 +- playground/src/examples.gen.tsx | 4 +- playground/src/style.css | 4 +- 10 files changed, 141 insertions(+), 35 deletions(-) diff --git a/docs/content/docs/react/components/formatting-toolbar.mdx b/docs/content/docs/react/components/formatting-toolbar.mdx index 8c9bc0a04d..96a8956e15 100644 --- a/docs/content/docs/react/components/formatting-toolbar.mdx +++ b/docs/content/docs/react/components/formatting-toolbar.mdx @@ -41,15 +41,22 @@ Here, we use the `FormattingToolbar` component but keep the default buttons (we ## Mobile Formatting Toolbar -On mobile, BlockNote's default UI automatically shows a dedicated formatting toolbar pinned just above the on-screen keyboard - no setup needed. It renders the same items as the regular Formatting Toolbar, but stays anchored to the keyboard so it's always reachable while editing on a touch device. Try it in any of the previous examples to see it in action! +On touch devices, BlockNote's default UI replaces the floating Formatting Toolbar with a mobile Formatting Toolbar that sits just above the on-screen keyboard. It shows the same items as the regular Formatting Toolbar and is enabled by default - there's nothing to set up. Open any of the examples above on a phone to see it. -Due to browser limitations, scrolling the page can cause the mobile Formatting Toolbar to appear laggy or jittery. BlockNote offers a workaround for these limitations, which you can see below. +The mobile Formatting Toolbar works with two page layouts. Which one you get is decided purely by your app's CSS: - +- **Scrolling document** (the default): the page scrolls as usual and BlockNote repositions the toolbar as you scroll. +- **Pinned scroll container**: the document itself doesn't scroll; a container pinned to the visual viewport scrolls instead, and the toolbar never has to move. -Here, the lag/jitter is eliminated, at the cost of `` and its ancestors no longer being scrollable. Instead, all scrollable page content must be in a scrollable container that's a descendant of ``. +### Scrolling document -To set this up, first lock scrolling on the document itself. This prevents the browser from scrolling ``/``, which is what causes the toolbar to jitter: +This is what you get without any changes to your app. The toolbar follows the visible area above the keyboard as the page scrolls. Mobile browsers only report visual viewport changes after the fact, so the toolbar can lag or jitter slightly while the page is scrolling. If that matters for your app, switch to a pinned scroll container. + +### Pinned scroll container + +In this layout, `` and `` are locked and all page content lives inside a single scroll container that BlockNote keeps aligned with the visual viewport. Since the document never scrolls, the toolbar can stay at a truly fixed position and the lag/jitter disappears. The trade-off is that browser gestures which rely on document scrolling, like pull-to-refresh, no longer work. + +Setting it up takes two CSS rules. First, lock scrolling on the document: ```css html, @@ -59,7 +66,7 @@ body { } ``` -Then, make your scroll container (`.scroll-host` in the demo) the element that actually scrolls. It's pinned to the visual viewport using the `--bn-vv-*` CSS variables that BlockNote publishes on the root element (`--bn-vv-top`, `--bn-vv-left`, `--bn-vv-width`, and `--bn-vv-height`), so it always lines up with the visible area above the keyboard: +Then make your scroll container (`.scroll-host` in the example below) the element that actually scrolls, and pin it to the visual viewport using the `--bn-vv-*` CSS variables that BlockNote publishes on ``: ```css .scroll-host { @@ -74,4 +81,6 @@ Then, make your scroll container (`.scroll-host` in the demo) the element that a } ``` -BlockNote keeps the `--bn-vv-*` variables up to date as the keyboard opens/closes and the user zooms or pans, so both the toolbar and your scroll container stay aligned with the visual viewport without any JavaScript on your end. +BlockNote keeps `--bn-vv-top`, `--bn-vv-left`, `--bn-vv-width`, and `--bn-vv-height` (plus `--bn-vv-scale`, the pinch-zoom factor) up to date as the keyboard opens and closes and as the user pans or zooms, so the scroll container always lines up with the visible area above the keyboard without any JavaScript on your end. + +Because this layout changes how the whole page scrolls, the example can't be embedded here - open the [standalone example](https://playground.blocknotejs.org/ui-components/mobile-formatting-toolbar?hideMenu=true) on a phone instead. It puts a navigation bar, some static text, and the editor inside a `.scroll-host` styled as above, and the switch in the navigation bar toggles the pinned scroll container layout on and off so you can compare it with the default scrolling document. Select some text and scroll in each layout to see the difference. diff --git a/examples/03-ui-components/14-mobile-formatting-toolbar/.bnexample.json b/examples/03-ui-components/14-mobile-formatting-toolbar/.bnexample.json index 16f9aea065..2d14483537 100644 --- a/examples/03-ui-components/14-mobile-formatting-toolbar/.bnexample.json +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/.bnexample.json @@ -1,6 +1,6 @@ { "playground": true, - "docs": true, + "docs": false, "author": "areknawo", "tags": [ "Intermediate", diff --git a/examples/03-ui-components/14-mobile-formatting-toolbar/README.md b/examples/03-ui-components/14-mobile-formatting-toolbar/README.md index 8a68305c00..a3ecb3c504 100644 --- a/examples/03-ui-components/14-mobile-formatting-toolbar/README.md +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/README.md @@ -1,8 +1,8 @@ # Mobile Formatting Toolbar -On mobile, BlockNote's default UI automatically shows a formatting toolbar pinned above the virtual keyboard - no setup needed. This example demos the opt-in, CSS-only "non-scrolling document" setup (locking `html`/`body` scroll and sizing a `.scroll-host` to the visual viewport), which keeps the toolbar smoothly pinned while scrolling. +On touch devices, BlockNote's default UI shows a Formatting Toolbar above the on-screen keyboard - no setup needed. This example demos the opt-in, CSS-only **pinned scroll container** layout: `html`/`body` scrolling is locked and a `.scroll-host` pinned to the visual viewport scrolls instead, so the toolbar stays perfectly in place while scrolling. Use the switch in the nav bar to toggle it off and compare with the default scrolling document layout. **Relevant Docs:** -- [Changing the Formatting Toolbar](/docs/react/components/formatting-toolbar) +- [Mobile Formatting Toolbar](/docs/react/components/formatting-toolbar#mobile-formatting-toolbar) - [Editor Setup](/docs/getting-started/editor-setup) diff --git a/examples/03-ui-components/14-mobile-formatting-toolbar/src/App.tsx b/examples/03-ui-components/14-mobile-formatting-toolbar/src/App.tsx index 04cfba0f9a..c454b87f9d 100644 --- a/examples/03-ui-components/14-mobile-formatting-toolbar/src/App.tsx +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/App.tsx @@ -2,6 +2,7 @@ import "@blocknote/core/fonts/inter.css"; import { useCreateBlockNote } from "@blocknote/react"; import { BlockNoteView } from "@blocknote/mantine"; import "@blocknote/mantine/style.css"; +import { useEffect, useState } from "react"; import "./style.css"; import { StaticText, NavBar } from "./DummyUI"; @@ -12,8 +13,9 @@ const initialContent = [ { type: "paragraph" as const, content: - "Select some text to bring up the toolbar, then scroll — it stays " + - "pinned above the keyboard because the document itself doesn't scroll.", + "Select some text to bring up the toolbar, then scroll. With the pinned " + + "scroll container layout on, it stays put because the document itself " + + "doesn't scroll. Toggle it off in the nav bar to compare.", }, ...Array.from({ length: 20 }, (_, i) => ({ type: "paragraph" as const, @@ -26,12 +28,30 @@ const initialContent = [ export default function App() { const editor = useCreateBlockNote({ initialContent }); + // Whether the "pinned scroll container" layout is on. It's pure CSS, toggled + // by adding a class to `` (see `style.css`): `html`/`body` scrolling is + // locked and `.scroll-host` (pinned to the visual viewport) is what actually + // scrolls, so the mobile formatting toolbar can stay at a truly fixed + // position instead of following the page as it scrolls. A real app would + // just apply those styles unconditionally - the toggle is only here so you + // can compare both layouts. + const [pinnedScrollContainer, setPinnedScrollContainer] = useState(true); + useEffect(() => { + document.documentElement.classList.toggle( + "pinned-scroll-container", + pinnedScrollContainer, + ); + return () => { + document.documentElement.classList.remove("pinned-scroll-container"); + }; + }, [pinnedScrollContainer]); + return ( - // To make the formatting toolbar scrolling smoother, we lock the `document.body` scrolling - // using CSS so we can use `position: fixed` on the toolbar. Therefore, we need to use a - // descendant element for scrolling.
- +
{/* On mobile, the default UI automatically shows the mobile formatting diff --git a/examples/03-ui-components/14-mobile-formatting-toolbar/src/DummyUI.tsx b/examples/03-ui-components/14-mobile-formatting-toolbar/src/DummyUI.tsx index a778e4f26f..855238d919 100644 --- a/examples/03-ui-components/14-mobile-formatting-toolbar/src/DummyUI.tsx +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/DummyUI.tsx @@ -28,11 +28,27 @@ function HamburgerMenu() { ); } -export function NavBar() { +export function NavBar(props: { + pinnedScrollContainer: boolean; + onPinnedScrollContainerChange: (enabled: boolean) => void; +}) { return (
Lorem Ipsum + {/* Switches between the default "scrolling document" layout and the + "pinned scroll container" layout, to compare the toolbar in both. */} +
); } diff --git a/examples/03-ui-components/14-mobile-formatting-toolbar/src/style.css b/examples/03-ui-components/14-mobile-formatting-toolbar/src/style.css index 4f210a9ea7..76cfc9d8b4 100644 --- a/examples/03-ui-components/14-mobile-formatting-toolbar/src/style.css +++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/style.css @@ -1,6 +1,16 @@ html, body { margin: 0; +} + +/* The pinned scroll container layout is toggled by the switch in the nav bar, + which adds `.pinned-scroll-container` to `` (see App.tsx). In a real + app you'd apply these rules unconditionally, without the class. */ + +/* Pinned scroll container layout, step 1: lock scrolling on the document + itself. All page content scrolls inside `.scroll-host` below instead. */ +html.pinned-scroll-container, +html.pinned-scroll-container body { overflow: hidden; } @@ -17,7 +27,7 @@ body { overflow: auto; } -/* --- App shell (see DemoChrome) --- */ +/* --- Dummy app UI (see DummyUI.tsx) --- */ .top-nav { position: sticky; @@ -36,6 +46,50 @@ body { font: 600 15px/1 sans-serif; } +/* Switch for the pinned scroll container layout, pushed to the right edge. */ +.layout-toggle { + display: flex; + align-items: center; + gap: 8px; + height: 44px; + margin-left: auto; + padding: 0 4px 0 10px; + background: none; + border: none; + color: inherit; + font: 13px/1 sans-serif; + cursor: pointer; +} + +.layout-toggle-track { + position: relative; + width: 36px; + height: 20px; + border-radius: 10px; + background: #555; + transition: background 0.15s; +} + +.layout-toggle[aria-pressed="true"] .layout-toggle-track { + background: #4caf50; +} + +.layout-toggle-track::after { + content: ""; + position: absolute; + top: 2px; + left: 2px; + width: 16px; + height: 16px; + border-radius: 50%; + background: #fff; + transition: transform 0.15s; +} + +.layout-toggle[aria-pressed="true"] .layout-toggle-track::after { + transform: translateX(16px); +} + .hamburger { position: relative; } @@ -111,13 +165,16 @@ body { color: #333; } -/* A top-level wrapper div is the scroll container (the document itself doesn't - scroll — `html`/`body` are locked with `overflow: hidden` above), pinned to - the visual viewport rectangle via the `--bn-vv-*` variables the mobile - toolbar controller publishes, so it sits directly above the keyboard on iOS — - where the layout viewport doesn't resize and can be left with a nonzero - `offsetTop`. */ -.scroll-host { +/* Pinned scroll container layout, step 2: a top-level wrapper div is the scroll + container (the document itself doesn't scroll — `html`/`body` are locked with + `overflow: hidden` above), pinned to the visual viewport rectangle via the + `--bn-vv-*` variables the mobile toolbar controller publishes, so it sits + directly above the keyboard on iOS — where the layout viewport doesn't resize + and can be left with a nonzero `offsetTop`. + + With the layout toggled off, `.scroll-host` is a plain wrapper and the + document scrolls as usual (the default "scrolling document" layout). */ +html.pinned-scroll-container .scroll-host { position: fixed; top: var(--bn-vv-top, 0px); left: var(--bn-vv-left, 0px); diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx index 7f631f970a..cc9438c4d8 100644 --- a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx +++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx @@ -14,11 +14,13 @@ import { useVirtualKeyboard } from "./useVirtualKeyboard.js"; * `.bn-mobile-formatting-toolbar` in the styles), so it needs no re-render to * follow the viewport. * - * By default it does not lock document scroll. For the smoother - * "non-scrolling document" behavior (the toolbar staying pinned during scroll - * with no per-frame work), the host app opts in via CSS: locking document - * scroll (`overflow: hidden` on `html`/`body`) and sizing its scroll container - * to the visual viewport via the same `--bn-vv-*` variables. + * Works with both page layouts described in the docs. In the default + * "scrolling document" layout the toolbar follows the visual viewport as the + * page scrolls. For the smoother "pinned scroll container" layout (the toolbar + * staying pinned during scroll with no per-frame work), the host app opts in + * via CSS: locking document scroll (`overflow: hidden` on `html`/`body`) and + * pinning its scroll container to the visual viewport via the same `--bn-vv-*` + * variables. * * The toolbar itself scrolls horizontally (`overflow-x: auto`), which clips any * inline dropdown on mobile. So the outer `.bn-mobile-formatting-toolbar` diff --git a/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts b/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts index 389345e11b..f00341e10a 100644 --- a/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts +++ b/packages/react/src/components/FormattingToolbar/useVirtualKeyboard.ts @@ -48,8 +48,8 @@ function isVirtualKeyboardOpen(): boolean { * opens/closes, not on every viewport change (zoom/pan/scroll) — those keep the * CSS properties up to date without a re-render. * - * Does not lock document scroll. For the smoother "non-scrolling document" - * behavior, the host app opts in with CSS (see + * Does not lock document scroll. For the smoother "pinned scroll container" + * layout, the host app opts in with CSS (see * {@link MobileFormattingToolbarController}). This is what that controller * relies on for positioning and keyboard detection. */ diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx index a55264c1b1..f5a54d35b6 100644 --- a/playground/src/examples.gen.tsx +++ b/playground/src/examples.gen.tsx @@ -739,7 +739,7 @@ export const examples = { pathFromRoot: "examples/03-ui-components/14-mobile-formatting-toolbar", config: { playground: true, - docs: true, + docs: false, author: "areknawo", tags: [ "Intermediate", @@ -754,7 +754,7 @@ export const examples = { slug: "ui-components", }, readme: - "This example shows how to use the mobile formatting toolbar, which uses the [Visual Viewport API](https://developer.mozilla.org/en-US/docs/Web/API/Visual_Viewport_API) to position the toolbar right above the virtual keyboard on mobile devices.\n\n**Relevant Docs:**\n\n- [Changing the Formatting Toolbar](/docs/react/components/formatting-toolbar)\n- [Editor Setup](/docs/getting-started/editor-setup)", + "On touch devices, BlockNote's default UI shows a Formatting Toolbar above the on-screen keyboard - no setup needed. This example demos the opt-in, CSS-only **pinned scroll container** layout: `html`/`body` scrolling is locked and a `.scroll-host` pinned to the visual viewport scrolls instead, so the toolbar stays perfectly in place while scrolling. Use the switch in the nav bar to toggle it off and compare with the default scrolling document layout.\n\n**Relevant Docs:**\n\n- [Mobile Formatting Toolbar](/docs/react/components/formatting-toolbar#mobile-formatting-toolbar)\n- [Editor Setup](/docs/getting-started/editor-setup)", }, { projectSlug: "advanced-tables", diff --git a/playground/src/style.css b/playground/src/style.css index 7ce5324f7c..54caec295c 100644 --- a/playground/src/style.css +++ b/playground/src/style.css @@ -49,7 +49,9 @@ body { .mantine-AppShell-root { height: 100vh; - width: 100vw; + /* Not `100vw`: that includes the vertical scrollbar's width, so any example + taller than the viewport would overflow horizontally. */ + width: 100%; } .mantine-AppShell-navbar { From 870f4c69916f7077eb5951419ccb160d1af3329e Mon Sep 17 00:00:00 2001 From: Matthew Lipski Date: Thu, 20 Aug 2026 10:46:03 +0200 Subject: [PATCH 2/2] Implemented PR feedback --- docs/content/docs/react/components/formatting-toolbar.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/content/docs/react/components/formatting-toolbar.mdx b/docs/content/docs/react/components/formatting-toolbar.mdx index 96a8956e15..b487959546 100644 --- a/docs/content/docs/react/components/formatting-toolbar.mdx +++ b/docs/content/docs/react/components/formatting-toolbar.mdx @@ -54,7 +54,7 @@ This is what you get without any changes to your app. The toolbar follows the vi ### Pinned scroll container -In this layout, `` and `` are locked and all page content lives inside a single scroll container that BlockNote keeps aligned with the visual viewport. Since the document never scrolls, the toolbar can stay at a truly fixed position and the lag/jitter disappears. The trade-off is that browser gestures which rely on document scrolling, like pull-to-refresh, no longer work. +In this layout, `` and `` are locked and all page content lives inside a single scroll container that BlockNote keeps aligned with the visual viewport. Since the document never scrolls, the toolbar can stay at a truly fixed position and the lag/jitter disappears. Since the document no longer scrolls, this comes with some potential trade-offs. Browser gestures that rely on document scrolling, like pull-to-refresh, may stop working and browser UI elements like the address bar, which normally hides and reappears as you scroll, may stay fixed. Note that these trade-offs are browser-dependent - some will not have either, while others will have both. Setting it up takes two CSS rules. First, lock scrolling on the document: @@ -81,6 +81,6 @@ Then make your scroll container (`.scroll-host` in the example below) the elemen } ``` -BlockNote keeps `--bn-vv-top`, `--bn-vv-left`, `--bn-vv-width`, and `--bn-vv-height` (plus `--bn-vv-scale`, the pinch-zoom factor) up to date as the keyboard opens and closes and as the user pans or zooms, so the scroll container always lines up with the visible area above the keyboard without any JavaScript on your end. +These variables track the [visual viewport](https://developer.mozilla.org/en-US/docs/Web/API/VisualViewport) - the part of the page actually visible above the keyboard. BlockNote keeps `--bn-vv-top`, `--bn-vv-left`, `--bn-vv-width`, and `--bn-vv-height` (plus `--bn-vv-scale`, the pinch-zoom factor) up to date as the keyboard opens and closes and as the user pans or zooms, so the scroll container always lines up with the visible area above the keyboard without any JavaScript on your end. Because this layout changes how the whole page scrolls, the example can't be embedded here - open the [standalone example](https://playground.blocknotejs.org/ui-components/mobile-formatting-toolbar?hideMenu=true) on a phone instead. It puts a navigation bar, some static text, and the editor inside a `.scroll-host` styled as above, and the switch in the navigation bar toggles the pinned scroll container layout on and off so you can compare it with the default scrolling document. Select some text and scroll in each layout to see the difference.