diff --git a/docs/content/docs/react/components/formatting-toolbar.mdx b/docs/content/docs/react/components/formatting-toolbar.mdx
index 962035ba57..8c9bc0a04d 100644
--- a/docs/content/docs/react/components/formatting-toolbar.mdx
+++ b/docs/content/docs/react/components/formatting-toolbar.mdx
@@ -38,3 +38,40 @@ The first element in the default Formatting Toolbar is the Block Type Select, an
Here, we use the `FormattingToolbar` component but keep the default buttons (we don't pass any children). Instead, we pass our customized Block Type Select items using the `blockTypeSelectItems` prop.
+
+## 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!
+
+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.
+
+
+
+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 ``.
+
+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:
+
+```css
+html,
+body {
+ margin: 0;
+ overflow: hidden;
+}
+```
+
+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:
+
+```css
+.scroll-host {
+ position: fixed;
+ top: var(--bn-vv-top, 0px);
+ left: var(--bn-vv-left, 0px);
+ width: var(--bn-vv-width, 100vw);
+ height: var(--bn-vv-height, 100dvh);
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior: contain;
+}
+```
+
+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.
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/README.md b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/README.md
deleted file mode 100644
index 02eaf7673f..0000000000
--- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/README.md
+++ /dev/null
@@ -1,10 +0,0 @@
-# Experimental Mobile Formatting Toolbar
-
-This example shows how to use the experimental mobile formatting toolbar, which uses [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.
-
-Controller is currently marked **experimental** due to the flickering issue with positioning (caused by delays of the Visual Viewport API)
-
-**Relevant Docs:**
-
-- [Changing the Formatting Toolbar](/docs/react/components/formatting-toolbar)
-- [Editor Setup](/docs/getting-started/editor-setup)
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx
deleted file mode 100644
index 47d59e453c..0000000000
--- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/App.tsx
+++ /dev/null
@@ -1,39 +0,0 @@
-import "@blocknote/core/fonts/inter.css";
-import {
- ExperimentalMobileFormattingToolbarController,
- useCreateBlockNote,
-} from "@blocknote/react";
-import { BlockNoteView } from "@blocknote/mantine";
-import "@blocknote/mantine/style.css";
-
-import "./style.css";
-
-export default function App() {
- // Creates a new editor instance.
- const editor = useCreateBlockNote({
- initialContent: [
- {
- type: "paragraph",
- content: "Welcome to this demo!",
- },
- {
- type: "paragraph",
- content:
- "Check out the experimental mobile formatting toolbar by selecting some text (best experienced on a mobile device).",
- },
- ],
- });
-
- // Renders the editor instance using a React component.
- return (
- // Disables the default formatting toolbar and re-adds it without the
- // `FormattingToolbarController` component. You may have seen
- // `FormattingToolbarController` used in other examples, but we omit it here
- // as we want to control the position and visibility ourselves. BlockNote
- // also uses the `FormattingToolbarController` when displaying the
- // Formatting Toolbar by default.
-
-
-
- );
-}
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css b/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css
deleted file mode 100644
index 98e93611cd..0000000000
--- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/style.css
+++ /dev/null
@@ -1,9 +0,0 @@
-.bn-container {
- display: flex;
- flex-direction: column-reverse;
- gap: 8px;
-}
-
-.bn-formatting-toolbar {
- margin-inline: auto;
-}
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/.bnexample.json b/examples/03-ui-components/14-mobile-formatting-toolbar/.bnexample.json
similarity index 100%
rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/.bnexample.json
rename to examples/03-ui-components/14-mobile-formatting-toolbar/.bnexample.json
diff --git a/examples/03-ui-components/14-mobile-formatting-toolbar/README.md b/examples/03-ui-components/14-mobile-formatting-toolbar/README.md
new file mode 100644
index 0000000000..8a68305c00
--- /dev/null
+++ b/examples/03-ui-components/14-mobile-formatting-toolbar/README.md
@@ -0,0 +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.
+
+**Relevant Docs:**
+
+- [Changing the Formatting Toolbar](/docs/react/components/formatting-toolbar)
+- [Editor Setup](/docs/getting-started/editor-setup)
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/index.html b/examples/03-ui-components/14-mobile-formatting-toolbar/index.html
similarity index 85%
rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/index.html
rename to examples/03-ui-components/14-mobile-formatting-toolbar/index.html
index 69b3583594..edd82eaea0 100644
--- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/index.html
+++ b/examples/03-ui-components/14-mobile-formatting-toolbar/index.html
@@ -2,7 +2,7 @@
- Experimental Mobile Formatting Toolbar
+ Mobile Formatting Toolbar
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/main.tsx b/examples/03-ui-components/14-mobile-formatting-toolbar/main.tsx
similarity index 100%
rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/main.tsx
rename to examples/03-ui-components/14-mobile-formatting-toolbar/main.tsx
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/package.json b/examples/03-ui-components/14-mobile-formatting-toolbar/package.json
similarity index 89%
rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/package.json
rename to examples/03-ui-components/14-mobile-formatting-toolbar/package.json
index c0843c027a..79453826e2 100644
--- a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/package.json
+++ b/examples/03-ui-components/14-mobile-formatting-toolbar/package.json
@@ -1,5 +1,5 @@
{
- "name": "@blocknote/example-ui-components-experimental-mobile-formatting-toolbar",
+ "name": "@blocknote/example-ui-components-mobile-formatting-toolbar",
"description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
"type": "module",
"private": true,
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
new file mode 100644
index 0000000000..04cfba0f9a
--- /dev/null
+++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/App.tsx
@@ -0,0 +1,44 @@
+import "@blocknote/core/fonts/inter.css";
+import { useCreateBlockNote } from "@blocknote/react";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+
+import "./style.css";
+import { StaticText, NavBar } from "./DummyUI";
+
+// Enough content that the editor actually overflows, so scrolling is testable.
+const initialContent = [
+ { type: "paragraph" as const, content: "Welcome to this demo!" },
+ {
+ 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.",
+ },
+ ...Array.from({ length: 20 }, (_, i) => ({
+ type: "paragraph" as const,
+ content:
+ `Filler paragraph ${i + 1}. Select some text here and bring up the ` +
+ "keyboard to see the toolbar sit above it.",
+ })),
+];
+
+export default function App() {
+ const editor = useCreateBlockNote({ initialContent });
+
+ 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
+ toolbar above the keyboard - no extra setup needed. */}
+
+
+
+
+ );
+}
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
new file mode 100644
index 0000000000..a778e4f26f
--- /dev/null
+++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/DummyUI.tsx
@@ -0,0 +1,78 @@
+import { useState } from "react";
+
+function HamburgerMenu() {
+ const [open, setOpen] = useState(false);
+
+ return (
+
+ );
+}
+
+export function NavBar() {
+ return (
+
+ );
+}
+
+/** A block of static page text, to sit around the editor. */
+export function StaticText() {
+ return (
+
+ Lorem Ipsum
+
+ Elit ipsum qui deserunt deserunt. Qui labore eu esse veniam excepteur.
+ Aute ipsum qui dolore in ipsum commodo adipisicing velit. Qui
+ consectetur et cupidatat consectetur sunt anim excepteur reprehenderit
+ sunt quis magna aliqua laborum. Lorem irure est ipsum ea nisi incididunt
+ culpa qui consequat eiusmod deserunt ipsum nostrud velit laboris.
+
+
+ Culpa quis id ipsum enim proident dolore non. Ad occaecat nostrud
+ eiusmod pariatur occaecat nisi voluptate nulla. Nisi quis ut esse ex
+ reprehenderit Lorem tempor ex tempor id sit officia. Commodo sunt sint
+ aliqua quis reprehenderit. Occaecat id ad dolor officia qui sunt dolor.
+ Consectetur magna excepteur in minim pariatur qui elit in sit consequat
+ aliquip voluptate laboris. Reprehenderit et eu dolor ex cupidatat aliqua
+ in elit anim eiusmod et adipisicing. Cupidatat fugiat fugiat amet duis.
+
+
+ Voluptate quis dolor ipsum commodo fugiat sit tempor tempor non aliqua
+ qui. Veniam consectetur mollit consequat exercitation sit ad. Lorem amet
+ deserunt qui sint et. Sint aute cillum aliqua pariatur cillum id.
+ Consectetur proident Lorem qui laborum id in sit. Aute aute irure nisi
+ est veniam Lorem. Anim labore irure ut sit mollit velit et duis veniam
+ ipsum aliquip.
+
+
+ Occaecat dolore excepteur qui proident laborum. Dolor deserunt cillum
+ veniam nulla minim eu in est aute nulla anim incididunt ea. Anim aliquip
+ aute duis aliqua eu pariatur est dolor magna Lorem dolore do sunt
+ aliquip est. Laborum pariatur fugiat do reprehenderit tempor cupidatat
+ proident ipsum ad dolor laboris.
+
+
+ );
+}
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
new file mode 100644
index 0000000000..7268410feb
--- /dev/null
+++ b/examples/03-ui-components/14-mobile-formatting-toolbar/src/style.css
@@ -0,0 +1,126 @@
+html,
+body {
+ margin: 0;
+ overflow: hidden;
+}
+
+/* Fixed-height, internally scrollable editor — a nested scroll container inside
+ the page's `.scroll-host`, to check nested scrolling works. */
+.bn-container {
+ height: 300px;
+ border: 1px solid #e0e0e0;
+ border-radius: 8px;
+}
+
+.bn-editor {
+ height: 100%;
+ overflow: auto;
+}
+
+/* --- App shell (see DemoChrome) --- */
+
+.top-nav {
+ position: sticky;
+ top: 0;
+ z-index: 20;
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ height: 48px;
+ padding: 0 12px;
+ background: #1a1a1a;
+ color: #fff;
+}
+
+.top-nav-title {
+ font: 600 15px/1 sans-serif;
+}
+
+.hamburger {
+ position: relative;
+}
+
+.hamburger-button {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ width: 22px;
+ height: 16px;
+ padding: 0;
+ background: none;
+ border: none;
+ cursor: pointer;
+}
+
+.hamburger-button span {
+ display: block;
+ height: 2px;
+ border-radius: 1px;
+ background: #fff;
+}
+
+.hamburger-menu {
+ position: absolute;
+ top: calc(100% + 8px);
+ left: 0;
+ display: flex;
+ flex-direction: column;
+ min-width: 180px;
+ padding: 8px;
+ background: #fff;
+ color: #111;
+ border-radius: 8px;
+ box-shadow: 0 6px 20px rgb(0 0 0 / 0.15);
+}
+
+.hamburger-menu a {
+ padding: 8px 10px;
+ color: inherit;
+ text-decoration: none;
+ border-radius: 6px;
+}
+
+.hamburger-menu a:hover {
+ background: #f0f0f0;
+}
+
+.app-main {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-width: 720px;
+ margin: 0 auto;
+ padding: 16px;
+}
+
+.prose h2 {
+ margin: 0 0 8px;
+ font: 600 18px/1.2 sans-serif;
+}
+
+.prose p {
+ margin: 0 0 8px;
+ font: 14px/1.6 sans-serif;
+ 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 {
+ position: fixed;
+ top: var(--bn-vv-top, 0px);
+ left: var(--bn-vv-left, 0px);
+ width: var(--bn-vv-width, 100vw);
+ height: var(--bn-vv-height, 100dvh);
+ overflow-y: auto;
+ -webkit-overflow-scrolling: touch;
+ /* Stop overscroll at the boundary from chaining to the document. Without
+ this, dragging past the bottom on iOS rubber-bands the whole page, which
+ shifts the visual viewport (repinning the host mid-bounce → jitter) and
+ surfaces a second, document-level scrollbar. */
+ overscroll-behavior: contain;
+}
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite-env.d.ts b/examples/03-ui-components/14-mobile-formatting-toolbar/src/vite-env.d.ts
similarity index 100%
rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite-env.d.ts
rename to examples/03-ui-components/14-mobile-formatting-toolbar/src/vite-env.d.ts
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/tsconfig.json b/examples/03-ui-components/14-mobile-formatting-toolbar/tsconfig.json
similarity index 100%
rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/tsconfig.json
rename to examples/03-ui-components/14-mobile-formatting-toolbar/tsconfig.json
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/vite-env.d.ts b/examples/03-ui-components/14-mobile-formatting-toolbar/vite-env.d.ts
similarity index 100%
rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/src/vite-env.d.ts
rename to examples/03-ui-components/14-mobile-formatting-toolbar/vite-env.d.ts
diff --git a/examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts b/examples/03-ui-components/14-mobile-formatting-toolbar/vite.config.ts
similarity index 100%
rename from examples/03-ui-components/14-experimental-mobile-formatting-toolbar/vite.config.ts
rename to examples/03-ui-components/14-mobile-formatting-toolbar/vite.config.ts
diff --git a/packages/ariakit/src/menu/Menu.tsx b/packages/ariakit/src/menu/Menu.tsx
index c2a401204a..d2a5b7542b 100644
--- a/packages/ariakit/src/menu/Menu.tsx
+++ b/packages/ariakit/src/menu/Menu.tsx
@@ -11,13 +11,18 @@ import {
import { assertEmpty, mergeCSSClasses } from "@blocknote/core";
import { ComponentProps } from "@blocknote/react";
-import { forwardRef } from "react";
+import { createContext, forwardRef, useContext } from "react";
+
+const PortalRootContext = createContext(
+ undefined,
+);
export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => {
const {
children,
onOpenChange,
position,
+ portalRoot,
sub: _sub, // unused
...rest
} = props;
@@ -30,7 +35,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => {
setOpen={onOpenChange}
virtualFocus={true}
>
- {children}
+
+ {children}
+
);
};
@@ -48,10 +55,13 @@ export const MenuDropdown = forwardRef<
assertEmpty(rest);
+ const portalRoot = useContext(PortalRootContext);
+
return (
{children}
diff --git a/packages/ariakit/src/toolbar/ToolbarSelect.tsx b/packages/ariakit/src/toolbar/ToolbarSelect.tsx
index f596cbbae6..bcff489fd3 100644
--- a/packages/ariakit/src/toolbar/ToolbarSelect.tsx
+++ b/packages/ariakit/src/toolbar/ToolbarSelect.tsx
@@ -16,7 +16,7 @@ export const ToolbarSelect = forwardRef<
HTMLDivElement,
ComponentProps["FormattingToolbar"]["Select"]
>((props, ref) => {
- const { className, items, isDisabled, ...rest } = props;
+ const { className, items, isDisabled, portalRoot, ...rest } = props;
assertEmpty(rest);
@@ -27,7 +27,11 @@ export const ToolbarSelect = forwardRef<
};
return (
-
+
{items.map((option) => (
/^((?!chrome|android).)*safari/i.test(navigator.userAgent);
+
+export const isTouchDevice = () =>
+ typeof navigator !== "undefined" && navigator.maxTouchPoints > 0;
diff --git a/packages/mantine/src/blocknoteStyles.css b/packages/mantine/src/blocknoteStyles.css
index accb33f62a..53d4d7603c 100644
--- a/packages/mantine/src/blocknoteStyles.css
+++ b/packages/mantine/src/blocknoteStyles.css
@@ -155,10 +155,6 @@
overflow: auto;
}
-.bn-mantine .mantine-Button-root[aria-controls*="dropdown"] {
- min-width: fit-content;
-}
-
/* Toolbar styling */
.bn-mantine .bn-toolbar {
background-color: var(--bn-colors-menu-background);
@@ -183,6 +179,7 @@
border: none;
border-radius: var(--bn-border-radius-small);
color: var(--bn-colors-menu-text);
+ flex-shrink: 0;
}
.bn-toolbar .mantine-Button-root:hover,
diff --git a/packages/mantine/src/menu/Menu.tsx b/packages/mantine/src/menu/Menu.tsx
index c81ed870d7..9d3d84b4ce 100644
--- a/packages/mantine/src/menu/Menu.tsx
+++ b/packages/mantine/src/menu/Menu.tsx
@@ -16,16 +16,20 @@ const SubMenuContext = createContext<
>(undefined);
export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => {
- const { children, onOpenChange, position, sub, ...rest } = props;
+ const { children, onOpenChange, position, portalRoot, sub, ...rest } = props;
assertEmpty(rest);
+ // When explicitly positioned to a `top` placement (e.g. the mobile toolbar's
+ // color menu, opening above the keyboard) don't let `flip` send it back down.
+ const flip = !position?.startsWith("top");
+
if (sub) {
return (
@@ -36,11 +40,18 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => {
return (
{children}
diff --git a/packages/mantine/src/toolbar/ToolbarButton.tsx b/packages/mantine/src/toolbar/ToolbarButton.tsx
index 179b08b03c..e49b48e52d 100644
--- a/packages/mantine/src/toolbar/ToolbarButton.tsx
+++ b/packages/mantine/src/toolbar/ToolbarButton.tsx
@@ -6,7 +6,7 @@ import {
Tooltip as MantineTooltip,
} from "@mantine/core";
-import { assertEmpty, isSafari } from "@blocknote/core";
+import { assertEmpty, isSafari, isTouchDevice } from "@blocknote/core";
import { ComponentProps } from "@blocknote/react";
import { forwardRef, useState } from "react";
@@ -57,9 +57,15 @@ export const ToolbarButton = forwardRef(
{
+ onPointerDown={(e) => {
+ // Prevents focus shift on mo
+ if (isTouchDevice()) {
+ e.preventDefault();
+ return;
+ }
+
+ // Needed as Safari doesn't focus button elements on mouse down
+ // unlike other browsers.
if (isSafari()) {
(e.currentTarget as HTMLButtonElement).focus();
}
@@ -93,10 +99,14 @@ export const ToolbarButton = forwardRef(
// Needed as Safari doesn't focus button elements on mouse down
// unlike other browsers.
onMouseDown={(e) => {
- if (isSafari()) {
+ if (isSafari() && !isTouchDevice()) {
(e.currentTarget as HTMLButtonElement).focus();
}
}}
+ onPointerDown={(event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ }}
onClick={(event) => {
// We manually hide the tooltip onclick, because the click event
// might open a popover which would then show both the tooltip and the popover
diff --git a/packages/mantine/src/toolbar/ToolbarSelect.tsx b/packages/mantine/src/toolbar/ToolbarSelect.tsx
index 21cee2a1fd..efdacc2be3 100644
--- a/packages/mantine/src/toolbar/ToolbarSelect.tsx
+++ b/packages/mantine/src/toolbar/ToolbarSelect.tsx
@@ -4,7 +4,7 @@ import {
Menu as MantineMenu,
} from "@mantine/core";
-import { assertEmpty, isSafari } from "@blocknote/core";
+import { assertEmpty, isSafari, isTouchDevice } from "@blocknote/core";
import { ComponentProps } from "@blocknote/react";
import { forwardRef } from "react";
import { HiChevronDown } from "react-icons/hi";
@@ -14,7 +14,7 @@ export const ToolbarSelect = forwardRef<
HTMLDivElement,
ComponentProps["FormattingToolbar"]["Select"]
>((props, ref) => {
- const { className, items, isDisabled, ...rest } = props;
+ const { className, items, isDisabled, portalRoot, ...rest } = props;
assertEmpty(rest);
@@ -26,18 +26,38 @@ export const ToolbarSelect = forwardRef<
return (
{
+ onPointerDown={(e) => {
+ // Prevents focus shift on mo
+ if (isTouchDevice()) {
+ e.preventDefault();
+ return;
+ }
+
+ // Needed as Safari doesn't focus button elements on mouse down
+ // unlike other browsers.
if (isSafari()) {
(e.currentTarget as HTMLButtonElement).focus();
}
diff --git a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx
index d0e98c5c8f..686d8787c1 100644
--- a/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx
+++ b/packages/react/src/components/FormattingToolbar/DefaultButtons/ColorStyleButton.tsx
@@ -4,9 +4,10 @@ import {
InlineContentSchema,
StyleSchema,
} from "@blocknote/core";
-import { useCallback } from "react";
+import { useCallback, useContext } from "react";
import { useComponentsContext } from "../../../editor/ComponentsContext.js";
+import { MobileFormattingToolbarPortalContext } from "../MobileFormattingToolbarPortalContext.js";
import { useBlockNoteEditor } from "../../../hooks/useBlockNoteEditor.js";
import { useEditorState } from "../../../hooks/useEditorState.js";
import { useDictionary } from "../../../i18n/dictionary.js";
@@ -43,6 +44,7 @@ function checkColorInSchema(
export const ColorStyleButton = () => {
const Components = useComponentsContext()!;
const dict = useDictionary();
+ const portalRoot = useContext(MobileFormattingToolbarPortalContext);
const editor = useBlockNoteEditor<
BlockSchema,
InlineContentSchema,
@@ -136,7 +138,7 @@ export const ColorStyleButton = () => {
}
return (
-
+
,
@@ -45,6 +46,7 @@ export const CreateLinkButton = () => {
const editorDOMElement = useEditorDOMElement();
const Components = useComponentsContext()!;
const dict = useDictionary();
+ const portalRoot = useContext(MobileFormattingToolbarPortalContext);
const formattingToolbar = useExtension(FormattingToolbarExtension);
// eslint-disable-next-line @typescript-eslint/unbound-method -- showSelection is a plain object method, not a class method
@@ -56,6 +58,17 @@ export const CreateLinkButton = () => {
return () => showSelection(false, "createLinkButton");
}, [showPopover, showSelection]);
+ // Return focus to editor on close.
+ const setPopoverOpen = useCallback(
+ (open: boolean) => {
+ if (!open) {
+ editor.focus();
+ }
+ setShowPopover(open);
+ },
+ [editor],
+ );
+
const state = useEditorState({
editor,
selector: ({ editor }) => {
@@ -114,7 +127,8 @@ export const CreateLinkButton = () => {
return (
{/* TODO: hide tooltip on click */}
@@ -128,7 +142,7 @@ export const CreateLinkButton = () => {
dict.generic.ctrl_shortcut,
)}
icon={}
- onClick={() => setShowPopover((open) => !open)}
+ onClick={() => setPopoverOpen(!showPopover)}
/>
{
const dict = useDictionary();
const Components = useComponentsContext()!;
+ const portalRoot = useContext(MobileFormattingToolbarPortalContext);
const editor = useBlockNoteEditor<
BlockSchema,
@@ -88,6 +96,7 @@ export const FileCaptionButton = () => {
{
const dict = useDictionary();
const Components = useComponentsContext()!;
+ const portalRoot = useContext(MobileFormattingToolbarPortalContext);
const editor = useBlockNoteEditor<
BlockSchema,
@@ -88,6 +96,7 @@ export const FileRenameButton = () => {
{
const dict = useDictionary();
const Components = useComponentsContext()!;
+ const portalRoot = useContext(MobileFormattingToolbarPortalContext);
const editor = useBlockNoteEditor<
BlockSchema,
@@ -56,7 +59,7 @@ export const FileReplaceButton = () => {
}
return (
-
+
{
const Components = useComponentsContext()!;
+ // Set inside the mobile formatting toolbar, so the dropdown portals out of the
+ // toolbar's scroll container instead of being clipped by it.
+ const portalRoot = useContext(MobileFormattingToolbarPortalContext);
const editor = useBlockNoteEditor<
BlockSchema,
@@ -212,6 +216,7 @@ export const BlockTypeSelect = (props: { items?: BlockTypeSelectItem[] }) => {
);
};
diff --git a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx
deleted file mode 100644
index a729bb4433..0000000000
--- a/packages/react/src/components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.tsx
+++ /dev/null
@@ -1,167 +0,0 @@
-import { BlockSchema, InlineContentSchema, StyleSchema } from "@blocknote/core";
-import { FormattingToolbarExtension } from "@blocknote/core/extensions";
-import { FC, useRef, useEffect } from "react";
-
-import { useBlockNoteEditor } from "../../hooks/useBlockNoteEditor.js";
-import { useExtensionState } from "../../hooks/useExtension.js";
-import { FormattingToolbar } from "./FormattingToolbar.js";
-import { FormattingToolbarProps } from "./FormattingToolbarProps.js";
-
-/**
- * Flicker-free mobile formatting toolbar controller.
- *
- * Uses a CSS custom property (`--bn-mobile-keyboard-offset`) instead of React
- * state to position the toolbar above the virtual keyboard. This avoids the
- * re-render storm that caused visible flickering in the previous implementation.
- *
- * Two-tier keyboard detection:
- * 1. **VirtualKeyboard API** (Chrome / Edge 94+, Samsung Internet) — provides
- * exact keyboard geometry before the animation starts.
- * 2. **Visual Viewport API fallback** (Safari iOS 13+, Firefox Android 68+) —
- * computes keyboard height from the difference between layout and visual
- * viewport, with focus-based prediction for instant initial positioning.
- */
-export const ExperimentalMobileFormattingToolbarController = (props: {
- formattingToolbar?: FC;
-}) => {
- const divRef = useRef(null);
- const editor = useBlockNoteEditor<
- BlockSchema,
- InlineContentSchema,
- StyleSchema
- >();
-
- const show = useExtensionState(FormattingToolbarExtension, {
- editor,
- });
-
- useEffect(() => {
- const el = divRef.current;
- if (!el) {
- return;
- }
-
- const setOffset = (px: number) => {
- el.style.setProperty(
- "--bn-mobile-keyboard-offset",
- px > 0 ? `${px}px` : "0px",
- );
- };
-
- let scrollTimer: ReturnType;
-
- const scrollSelectionIntoView = () => {
- const sel = window.getSelection();
- if (!sel || sel.rangeCount === 0) {
- return;
- }
- const rect = sel.getRangeAt(0).getBoundingClientRect();
- const vp = window.visualViewport;
- if (!vp) {
- return;
- }
- const toolbarHeight = el.getBoundingClientRect().height || 44;
- const visibleBottom = vp.offsetTop + vp.height - toolbarHeight;
- if (rect.bottom > visibleBottom) {
- window.scrollBy({
- top: rect.bottom - visibleBottom + 16,
- behavior: "smooth",
- });
- } else if (rect.top < vp.offsetTop) {
- window.scrollBy({
- top: rect.top - vp.offsetTop - 16,
- behavior: "smooth",
- });
- }
- };
-
- // Tier 1: VirtualKeyboard API (Chrome/Edge 94+) — exact geometry, no delay
- const vk = (navigator as any).virtualKeyboard;
- if (vk) {
- vk.overlaysContent = true;
- const onGeometryChange = () => {
- setOffset(vk.boundingRect.height);
- clearTimeout(scrollTimer);
- scrollTimer = setTimeout(scrollSelectionIntoView, 100);
- };
- vk.addEventListener("geometrychange", onGeometryChange);
- const onSelectionChange = () => scrollSelectionIntoView();
- document.addEventListener("selectionchange", onSelectionChange);
- return () => {
- vk.removeEventListener("geometrychange", onGeometryChange);
- document.removeEventListener("selectionchange", onSelectionChange);
- clearTimeout(scrollTimer);
- };
- }
-
- // Tier 2: Visual Viewport API fallback (Safari iOS, Firefox Android)
- const vp = window.visualViewport;
- if (!vp) {
- return;
- }
-
- let lastKnownKeyboardHeight = 0;
-
- const update = () => {
- const layoutHeight = document.documentElement.clientHeight;
- const keyboardHeight = layoutHeight - vp.height - vp.offsetTop;
- if (keyboardHeight > 50) {
- lastKnownKeyboardHeight = keyboardHeight;
- }
- setOffset(keyboardHeight);
- clearTimeout(scrollTimer);
- scrollTimer = setTimeout(scrollSelectionIntoView, 100);
- };
-
- const onFocusIn = (e: FocusEvent) => {
- const target = e.target as HTMLElement;
- if (
- target.isContentEditable ||
- target.tagName === "INPUT" ||
- target.tagName === "TEXTAREA"
- ) {
- if (lastKnownKeyboardHeight > 0) {
- setOffset(lastKnownKeyboardHeight);
- }
- }
- };
-
- const onFocusOut = () => {
- setOffset(0);
- };
-
- const onSelectionChange = () => scrollSelectionIntoView();
-
- vp.addEventListener("resize", update);
- vp.addEventListener("scroll", update);
- document.addEventListener("focusin", onFocusIn);
- document.addEventListener("focusout", onFocusOut);
- document.addEventListener("selectionchange", onSelectionChange);
- return () => {
- vp.removeEventListener("resize", update);
- vp.removeEventListener("scroll", update);
- document.removeEventListener("focusin", onFocusIn);
- document.removeEventListener("focusout", onFocusOut);
- document.removeEventListener("selectionchange", onSelectionChange);
- clearTimeout(scrollTimer);
- };
- }, []);
-
- if (!show && divRef.current) {
- return (
-
- );
- }
-
- const Component = props.formattingToolbar || FormattingToolbar;
-
- return (
-
-
-
- );
-};
diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx
new file mode 100644
index 0000000000..03984294ec
--- /dev/null
+++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbar.tsx
@@ -0,0 +1,28 @@
+import { ReactNode } from "react";
+
+import { useComponentsContext } from "../../editor/ComponentsContext.js";
+import { getFormattingToolbarItems } from "./FormattingToolbar.js";
+import { FormattingToolbarProps } from "./FormattingToolbarProps.js";
+
+/**
+ * A formatting toolbar tailored for mobile — where it sits just above the
+ * on-screen keyboard (see `MobileFormattingToolbarController`).
+ *
+ * For now it renders the same items as the regular `FormattingToolbar` — their
+ * dropdowns/popovers open above the keyboard automatically via floating-ui's
+ * `flip` middleware. Over time this can diverge from the desktop toolbar with
+ * mobile-specific items/behavior.
+ */
+export const MobileFormattingToolbar = (
+ props: FormattingToolbarProps & { children?: ReactNode },
+) => {
+ const Components = useComponentsContext()!;
+
+ return (
+
+ {props.children || getFormattingToolbarItems(props.blockTypeSelectItems)}
+
+ );
+};
diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx
new file mode 100644
index 0000000000..a57f7583ab
--- /dev/null
+++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarController.tsx
@@ -0,0 +1,58 @@
+import { FC, useState } from "react";
+
+import { MobileFormattingToolbarPortalContext } from "./MobileFormattingToolbarPortalContext.js";
+import { FormattingToolbarProps } from "./FormattingToolbarProps.js";
+import { FormattingToolbar } from "./FormattingToolbar.js";
+import {
+ isVirtualKeyboardOpen,
+ useVisualViewportRect,
+} from "./useVisualViewportRect.js";
+
+/**
+ * Mobile formatting toolbar controller.
+ *
+ * Pins the formatting toolbar to the bottom of the visual viewport — just above
+ * the on-screen keyboard — positioning itself purely from the `--bn-vv-*` CSS
+ * variables published by {@link useVisualViewportRect} (see
+ * `.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.
+ *
+ * The toolbar itself scrolls horizontally (`overflow-x: auto`), which clips any
+ * inline dropdown on mobile. So the outer `.bn-mobile-formatting-toolbar`
+ * wrapper — outside that scroll container — is published via
+ * {@link MobileFormattingToolbarPortalContext}, and buttons portal
+ * their menus/popovers into it (see e.g. `ColorStyleButton`).
+ *
+ * Shown while the virtual keyboard is open.
+ */
+export const MobileFormattingToolbarController = (props: {
+ formattingToolbar?: FC;
+}) => {
+ const viewport = useVisualViewportRect();
+ // The non-scrolling wrapper, published so buttons can portal their dropdowns
+ // out of the horizontally scrolling toolbar. A callback ref into state so the
+ // context updates once the element mounts.
+ const [toolbarElement, setToolbarElement] = useState(
+ null,
+ );
+
+ if (!isVirtualKeyboardOpen(viewport)) {
+ return null;
+ }
+
+ const Component = props.formattingToolbar || FormattingToolbar;
+
+ return (
+
+
+
+
+
+ );
+};
diff --git a/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarPortalContext.ts b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarPortalContext.ts
new file mode 100644
index 0000000000..d06966ec7f
--- /dev/null
+++ b/packages/react/src/components/FormattingToolbar/MobileFormattingToolbarPortalContext.ts
@@ -0,0 +1,17 @@
+import { createContext } from "react";
+
+/**
+ * Holds the mobile formatting toolbar controller's root element — the
+ * non-scrolling wrapper that sits *outside* the toolbar's horizontally
+ * scrolling container.
+ *
+ * Formatting toolbar buttons read this and portal their menus/popovers into it,
+ * so the dropdowns escape the toolbar's `overflow-x: auto` clip (which mobile
+ * WebKit/Blink apply to DOM descendants regardless of their containing block)
+ * while staying inside BlockNote's themed DOM subtree.
+ *
+ * `null` when not inside the mobile toolbar (e.g. the desktop toolbar), in which
+ * case buttons render their dropdowns inline as usual.
+ */
+export const MobileFormattingToolbarPortalContext =
+ createContext(null);
diff --git a/packages/react/src/components/FormattingToolbar/useVisualViewportRect.ts b/packages/react/src/components/FormattingToolbar/useVisualViewportRect.ts
new file mode 100644
index 0000000000..9909e04e2d
--- /dev/null
+++ b/packages/react/src/components/FormattingToolbar/useVisualViewportRect.ts
@@ -0,0 +1,88 @@
+import { useEffect, useState } from "react";
+
+export type VisualViewportRect = {
+ top: number;
+ left: number;
+ width: number;
+ height: number;
+ scale: number;
+};
+
+function readVisualViewport(): VisualViewportRect {
+ const vp = window.visualViewport;
+ return {
+ top: vp?.offsetTop ?? 0,
+ left: vp?.offsetLeft ?? 0,
+ width: vp?.width ?? window.innerWidth,
+ height: vp?.height ?? window.innerHeight,
+ scale: vp?.scale ?? 1,
+ };
+}
+
+/**
+ * Tracks the visual viewport rectangle + pinch-zoom scale, publishing it as CSS
+ * custom properties on the root (`--bn-vv-top/left/width/height/scale`) so the
+ * mobile toolbar (and the app's scroll container) can position themselves off
+ * the viewport without a React re-render, and returning it as an object for JS.
+ *
+ * Does not lock document scroll. For the smoother "non-scrolling document"
+ * behavior, the host app opts in with CSS (see
+ * {@link MobileFormattingToolbarController}). This is what that controller
+ * relies on for positioning and keyboard detection.
+ */
+export function useVisualViewportRect(): VisualViewportRect {
+ const [rect, setRect] = useState(readVisualViewport);
+
+ useEffect(() => {
+ const html = document.documentElement;
+
+ const vp = window.visualViewport;
+ const update = () => {
+ const next = readVisualViewport();
+ setRect(next);
+ html.style.setProperty("--bn-vv-top", `${next.top}px`);
+ html.style.setProperty("--bn-vv-left", `${next.left}px`);
+ html.style.setProperty("--bn-vv-width", `${next.width}px`);
+ html.style.setProperty("--bn-vv-height", `${next.height}px`);
+ html.style.setProperty("--bn-vv-scale", `${next.scale}`);
+ };
+ update();
+
+ // Fire on keyboard open/close, zoom/pan, and (unless the document is locked
+ // via CSS) content scroll.
+ vp?.addEventListener("resize", update);
+ vp?.addEventListener("scroll", update);
+ window.addEventListener("resize", update);
+
+ return () => {
+ html.style.removeProperty("--bn-vv-top");
+ html.style.removeProperty("--bn-vv-left");
+ html.style.removeProperty("--bn-vv-width");
+ html.style.removeProperty("--bn-vv-height");
+ html.style.removeProperty("--bn-vv-scale");
+ vp?.removeEventListener("resize", update);
+ vp?.removeEventListener("scroll", update);
+ window.removeEventListener("resize", update);
+ };
+ }, []);
+
+ return rect;
+}
+
+// The tallest layout-equivalent viewport height seen so far — our stand-in for
+// "keyboard closed". Module scope so it survives re-renders; it only ever grows,
+// so refreshing it from a render pass is safe.
+let maxLayoutViewportHeight = 0;
+
+/**
+ * Whether the on-screen keyboard is open, from a visual-viewport snapshot. We
+ * compare `height * scale` — the zoom-invariant layout-equivalent height, so
+ * pinch-zoom (which also shrinks `height`) doesn't count — against the tallest
+ * value seen, treating a drop of more than 150px as open: comfortably above
+ * URL-bar show/hide (~60-100px) and below any real keyboard (~250px+).
+ */
+export function isVirtualKeyboardOpen(viewport: VisualViewportRect): boolean {
+ const layoutHeight = viewport.height * viewport.scale;
+ maxLayoutViewportHeight = Math.max(maxLayoutViewportHeight, layoutHeight);
+ return maxLayoutViewportHeight - layoutHeight > 150;
+}
diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx
index 75d618dc71..d5a668ace4 100644
--- a/packages/react/src/editor/BlockNoteDefaultUI.tsx
+++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx
@@ -11,6 +11,7 @@ import { lazy, Suspense } from "react";
import { FilePanelController } from "../components/FilePanel/FilePanelController.js";
import { FormattingToolbarController } from "../components/FormattingToolbar/FormattingToolbarController.js";
+import { MobileFormattingToolbarController } from "../components/FormattingToolbar/MobileFormattingToolbarController.js";
import { LinkToolbarController } from "../components/LinkToolbar/LinkToolbarController.js";
import { SideMenuController } from "../components/SideMenu/SideMenuController.js";
import { AttributionTooltipController } from "../components/AttributionTooltip/AttributionTooltipController.js";
@@ -18,6 +19,7 @@ import { GridSuggestionMenuController } from "../components/SuggestionMenu/GridS
import { SuggestionMenuController } from "../components/SuggestionMenu/SuggestionMenuController.js";
import { TableHandlesController } from "../components/TableHandles/TableHandlesController.js";
import { useBlockNoteEditor } from "../hooks/useBlockNoteEditor.js";
+import { useIsMobile } from "../hooks/useIsMobile.js";
import { PortalElementsMap, resolvePortalTarget } from "./portalElements.js";
// Lazily load the comments components to avoid pulling in the comments extensions into the main bundle
@@ -98,6 +100,7 @@ export type BlockNoteDefaultUIProps = {
export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) {
const editor = useBlockNoteEditor();
+ const isMobile = useIsMobile();
if (!editor) {
throw new Error(
@@ -119,11 +122,14 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) {
return (
<>
{editor.getExtension(FormattingToolbarExtension) &&
- props.formattingToolbar !== false && (
+ props.formattingToolbar !== false &&
+ (isMobile ? (
+
+ ) : (
- )}
+ ))}
{editor.getExtension(LinkToolbarExtension) &&
props.linkToolbar !== false && (
diff --git a/packages/react/src/editor/ComponentsContext.tsx b/packages/react/src/editor/ComponentsContext.tsx
index 35d8a1ee3c..5d71bc58dc 100644
--- a/packages/react/src/editor/ComponentsContext.tsx
+++ b/packages/react/src/editor/ComponentsContext.tsx
@@ -47,6 +47,7 @@ type ToolbarSelectType = {
isDisabled?: boolean;
}[];
isDisabled?: boolean;
+ portalRoot?: HTMLElement | null;
};
type MenuButtonType = {
@@ -333,6 +334,7 @@ export type ComponentProps = {
| "bottom"
| "left"
| `${"top" | "right" | "bottom" | "left"}-${"start" | "end"}`;
+ portalRoot?: HTMLElement | null;
children?: ReactNode;
};
Divider: {
diff --git a/packages/react/src/editor/styles.css b/packages/react/src/editor/styles.css
index 507f2cd46f..b7c9be2044 100644
--- a/packages/react/src/editor/styles.css
+++ b/packages/react/src/editor/styles.css
@@ -509,19 +509,50 @@ SideMenuController offsets its position to keep it centered on the line. */
gap: 4px;
}
-/* Mobile formatting toolbar positioning */
+/* Mobile formatting toolbar positioning. Pinned to the bottom of the visual
+ viewport from the `--bn-vv-*` variables published by `useVisualViewportRect`
+ (used by MobileFormattingToolbarController): `translateY(-100%)` puts the
+ toolbar's bottom edge on the viewport bottom without measuring its height, and
+ `scale(1 / --bn-vv-scale)` around that anchored corner cancels pinch-zoom so
+ it keeps its on-screen size. */
.bn-mobile-formatting-toolbar {
display: flex;
position: fixed;
- bottom: var(--bn-mobile-keyboard-offset, 0px);
+ top: 0;
left: 0;
right: 0;
z-index: calc(var(--bn-ui-base-z-index) + 40);
- transition: bottom 0.15s ease-out;
+ transform: translate(
+ var(--bn-vv-left, 0px),
+ calc(var(--bn-vv-top, 0px) + var(--bn-vv-height, 0px))
+ )
+ translateY(-100%) scale(calc(1 / var(--bn-vv-scale, 1)));
+ transform-origin: left bottom;
+ will-change: transform;
+ transition: transform 0.2s cubic-bezier(0.5, 1, 0.89, 1);
+ padding-bottom: env(safe-area-inset-bottom, 0);
+ /* No `overflow` here: dropdowns flip to render above the toolbar and must not
+ be clipped. Note `overflow-x` would force `overflow-y: auto` too,
+ which is exactly what clips them. Horizontal scroll lives on the inner
+ `.bn-mobile-formatting-toolbar-scroll` element instead. */
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bn-mobile-formatting-toolbar {
+ transition: none;
+ }
+}
+
+/* Inner horizontal scroller for the toolbar buttons. Its overflow does not clip
+ the dropdowns: their containing block is the positioned outer toolbar, so they
+ escape this element entirely. */
+.bn-mobile-formatting-toolbar-scroll {
+ display: flex;
+ flex: 1;
+ min-width: 0;
+ overflow-x: auto;
touch-action: pan-x;
-webkit-overflow-scrolling: touch;
- overflow-x: auto;
- padding-bottom: env(safe-area-inset-bottom, 0);
}
/* Emoji Picker styling */
diff --git a/packages/react/src/hooks/useIsMobile.ts b/packages/react/src/hooks/useIsMobile.ts
new file mode 100644
index 0000000000..a38fcae5a0
--- /dev/null
+++ b/packages/react/src/hooks/useIsMobile.ts
@@ -0,0 +1,21 @@
+import { isTouchDevice } from "@blocknote/core";
+import { useEffect, useState } from "react";
+
+/**
+ * Whether the editor is being used on a mobile (touch) device. Used to decide
+ * between the desktop and mobile formatting toolbar controllers.
+ *
+ * The check runs after mount rather than during render so it's SSR-safe: the
+ * server (and the first client render) assume desktop, then switch to mobile on
+ * the client if it's a touch device - avoiding a hydration mismatch. Touch
+ * capability doesn't change during a session, so a one-off check is enough.
+ */
+export const useIsMobile = () => {
+ const [isMobile, setIsMobile] = useState(false);
+
+ useEffect(() => {
+ setIsMobile(isTouchDevice());
+ }, []);
+
+ return isMobile;
+};
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index 2de5361e99..a16667149d 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -35,8 +35,11 @@ export * from "./components/FormattingToolbar/DefaultButtons/TableCellMergeButto
export * from "./components/FormattingToolbar/DefaultButtons/TextAlignButton.js";
export * from "./components/FormattingToolbar/DefaultSelects/BlockTypeSelect.js";
export * from "./components/FormattingToolbar/FormattingToolbar.js";
+export * from "./components/FormattingToolbar/MobileFormattingToolbar.js";
export * from "./components/FormattingToolbar/FormattingToolbarController.js";
-export * from "./components/FormattingToolbar/ExperimentalMobileFormattingToolbarController.js";
+export * from "./components/FormattingToolbar/MobileFormattingToolbarController.js";
+export * from "./components/FormattingToolbar/MobileFormattingToolbarPortalContext.js";
+export * from "./components/FormattingToolbar/useVisualViewportRect.js";
export * from "./components/FormattingToolbar/FormattingToolbarProps.js";
export * from "./components/LinkToolbar/DefaultButtons/DeleteLinkButton.js";
@@ -128,6 +131,7 @@ export * from "./hooks/useEditorDomElement.js";
export * from "./hooks/useEditorSelectionBoundingBox.js";
export * from "./hooks/useEditorSelectionChange.js";
export * from "./hooks/useFocusWithin.js";
+export * from "./hooks/useIsMobile.js";
export * from "./hooks/useOnUploadEnd.js";
export * from "./hooks/useOnUploadStart.js";
export * from "./hooks/usePrefersColorScheme.js";
diff --git a/packages/shadcn/src/menu/Menu.tsx b/packages/shadcn/src/menu/Menu.tsx
index 1e5eb6ea54..b61158f688 100644
--- a/packages/shadcn/src/menu/Menu.tsx
+++ b/packages/shadcn/src/menu/Menu.tsx
@@ -2,15 +2,20 @@ import { assertEmpty } from "@blocknote/core";
import { ComponentProps, useBlockNoteEditor } from "@blocknote/react";
import { ChevronRight } from "lucide-react";
import { forwardRef, ReactElement } from "react";
-
+import { createContext, useContext } from "react";
import { cn } from "../lib/utils.js";
import { useShadCNComponentsContext } from "../ShadCNComponentsContext.js";
+const PortalRootContext = createContext(
+ undefined,
+);
+
export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => {
const {
children,
onOpenChange,
position: _position, // Unused
+ portalRoot,
sub,
...rest
} = props;
@@ -24,7 +29,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => {
- {children}
+
+ {children}
+
);
} else {
@@ -33,7 +40,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => {
modal={false}
onOpenChange={onOpenChange}
>
- {children}
+
+ {children}
+
);
}
@@ -72,6 +81,7 @@ export const MenuDropdown = forwardRef<
assertEmpty(rest);
const ShadCNComponents = useShadCNComponentsContext()!;
+ const portalRoot = useContext(PortalRootContext);
// Portal into the editor's portal element (which carries the color-scheme
// class) so the menu inherits light/dark mode instead of the document body's.
@@ -82,7 +92,7 @@ export const MenuDropdown = forwardRef<
return (
{children}
@@ -92,7 +102,7 @@ export const MenuDropdown = forwardRef<
return (
{children}
diff --git a/packages/shadcn/src/toolbar/Toolbar.tsx b/packages/shadcn/src/toolbar/Toolbar.tsx
index 6ac937ee7c..d12b4f4670 100644
--- a/packages/shadcn/src/toolbar/Toolbar.tsx
+++ b/packages/shadcn/src/toolbar/Toolbar.tsx
@@ -126,7 +126,7 @@ export const ToolbarSelect = forwardRef<
HTMLDivElement,
ComponentProps["FormattingToolbar"]["Select"]
>((props, ref) => {
- const { className, items, isDisabled, ...rest } = props;
+ const { className, items, isDisabled, portalRoot, ...rest } = props;
assertEmpty(rest);
@@ -163,7 +163,7 @@ export const ToolbarSelect = forwardRef<