Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 16 additions & 7 deletions docs/content/docs/react/components/formatting-toolbar.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:

<Example name="ui-components/mobile-formatting-toolbar" />
- **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 `<body>` and its ancestors no longer being scrollable. Instead, all scrollable page content must be in a scrollable container that's a descendant of `<body>`.
### Scrolling document

To set this up, first lock scrolling on the document itself. This prevents the browser from scrolling `<html>`/`<body>`, 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, `<html>` and `<body>` 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

2 small things:

  • The other main trade-off is that the browser UI may no longer show/hide on scroll
  • Different browsers may have both of these issues, only 1 of them, or neither. So I would change the framing of the last sentence and say these trade-offs may occur depending on the browser used.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Would be a good addition, if you can find a good way to phrase this. "Browser UI" is very generic / not clear to me as a reader what this is


Setting it up takes two CSS rules. First, lock scrolling on the document:

```css
html,
Expand All @@ -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 `<html>`:

```css
.scroll-host {
Expand All @@ -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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Perhaps worth saying that these variables track the visual viewport

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

sounds good


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.
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"playground": true,
"docs": true,
"docs": false,
"author": "areknawo",
"tags": [
"Intermediate",
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
32 changes: 26 additions & 6 deletions examples/03-ui-components/14-mobile-formatting-toolbar/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand All @@ -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 `<html>` (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.
<div className="scroll-host">
<NavBar />
<NavBar
pinnedScrollContainer={pinnedScrollContainer}
onPinnedScrollContainerChange={setPinnedScrollContainer}
/>
<main className="app-main">
<StaticText />
{/* On mobile, the default UI automatically shows the mobile formatting
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,27 @@ function HamburgerMenu() {
);
}

export function NavBar() {
export function NavBar(props: {
pinnedScrollContainer: boolean;
onPinnedScrollContainerChange: (enabled: boolean) => void;
}) {
return (
<header className="top-nav">
<HamburgerMenu />
<span className="top-nav-title">Lorem Ipsum</span>
{/* Switches between the default "scrolling document" layout and the
"pinned scroll container" layout, to compare the toolbar in both. */}
<button
type="button"
className="layout-toggle"
aria-pressed={props.pinnedScrollContainer}
onClick={() =>
props.onPinnedScrollContainerChange(!props.pinnedScrollContainer)
}
>
Pinned scroll container
<span className="layout-toggle-track" aria-hidden="true" />
</button>
</header>
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<html>` (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;
}

Expand All @@ -17,7 +27,7 @@ body {
overflow: auto;
}

/* --- App shell (see DemoChrome) --- */
/* --- Dummy app UI (see DummyUI.tsx) --- */

.top-nav {
position: sticky;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
4 changes: 2 additions & 2 deletions playground/src/examples.gen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion playground/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading