Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
03e86dd
Added demo
matthewlipski Aug 3, 2026
e45f5a1
Replaced demo toolbar with actual toolbar
matthewlipski Aug 6, 2026
d9902bf
Properly fixed dropdowns
matthewlipski Aug 7, 2026
af12646
- Fixed link button popover close dismissing virtual keyboard
matthewlipski Aug 7, 2026
1d4fbe3
Added transition to toolbar
matthewlipski Aug 12, 2026
07d9ff7
Replaced hook with pure CSS
matthewlipski Aug 13, 2026
faa72b5
Added docs
matthewlipski Aug 13, 2026
d070f14
Removed redundant `direction` prop
matthewlipski Aug 13, 2026
ece1f83
Merged main
matthewlipski Aug 13, 2026
e23b2f3
Refactored `useVirtualViewportRect`
matthewlipski Aug 14, 2026
57b68bf
Fixed some SSR issues
matthewlipski Aug 17, 2026
dfb89ac
Removed `portalRoot` prop
matthewlipski Aug 17, 2026
5a5fcd1
Merge branch 'main' into mobile-toolbar-demo
matthewlipski Aug 17, 2026
4b920a1
Implemented PR feedback
matthewlipski Aug 17, 2026
52933c2
Removed unused component
matthewlipski Aug 17, 2026
8a03207
Small fix
matthewlipski Aug 17, 2026
f779a4b
Increased mobile formatting toolbar tap target size
matthewlipski Aug 17, 2026
19428d4
- Fixed `getActiveStyles` with empty selections
matthewlipski Aug 20, 2026
a174fef
Refactored `FormattingToolbarController` to handle whether the deskto…
matthewlipski Aug 20, 2026
51aa828
Made mobile toolbar only appear when attached editor instance is focused
matthewlipski Aug 20, 2026
9c748cc
Fixed toolbar select desktop regression
matthewlipski Aug 20, 2026
3a95797
Increased required threshold to detect orientation change
matthewlipski Aug 20, 2026
69c6a71
Fixed formatting toolbar not opening when using touch-capable device …
matthewlipski Aug 20, 2026
a42e93c
Made mobile formatting toolbar center-aligned
matthewlipski Aug 20, 2026
1d0ef95
Small fix
matthewlipski Aug 20, 2026
e441113
docs: name the mobile toolbar layouts + add layout toggle to the exam…
YousefED Aug 20, 2026
09692f4
Merge remote-tracking branch 'origin/mobile-toolbar-demo' into mobile…
matthewlipski Aug 20, 2026
d4079d4
Made `.bn-scroll-host` class name apply all necessary styles automati…
matthewlipski Aug 20, 2026
b05f7ab
Added second editor to example
matthewlipski Aug 20, 2026
cef137d
Cached `isTouchDevice` result
matthewlipski Aug 20, 2026
be8838e
Cleaned up portal element context
matthewlipski Aug 21, 2026
51b89d0
Moved injected styles to stylesheet
matthewlipski Aug 21, 2026
83c2a00
Added `hide` middleware to `GenericPopover`
matthewlipski Aug 21, 2026
22c7645
Minor fixes
matthewlipski Aug 25, 2026
9a9f218
Fixed popovers
matthewlipski Aug 25, 2026
384ffd2
Implemented PR feedback
matthewlipski Aug 25, 2026
5696c41
Small fix
matthewlipski Aug 26, 2026
b54dcf5
Fixed Android gap
matthewlipski Aug 27, 2026
2656d73
Small fixes
matthewlipski Aug 28, 2026
0f80e42
Small docs change
matthewlipski Aug 28, 2026
852849f
Merge branch 'main' into mobile-toolbar-demo
YousefED Aug 28, 2026
0ec8997
Updated docs
matthewlipski Aug 31, 2026
b43ca12
Made formatting toolbar smooth in docs & playground
matthewlipski Aug 31, 2026
9feb488
Added warning when `interactive-widget` is not set
matthewlipski Sep 1, 2026
e0e3e90
Added `PortalContext`
matthewlipski Sep 2, 2026
3773025
Refactored `PortalContext` to `PortalTarget`
matthewlipski Sep 3, 2026
9b2b740
Refactored `PortalContext` to `PortalTarget`
matthewlipski Sep 3, 2026
3c9c0b6
Fixed build
matthewlipski Sep 4, 2026
72fce8f
Updated naming
matthewlipski Sep 4, 2026
0fdf553
Fixed build
matthewlipski Sep 4, 2026
543523d
Fixed naming omissions
matthewlipski Sep 4, 2026
f69f5c9
Always pass `editorPortalElement` to `portalRoot`
matthewlipski Sep 4, 2026
85b9029
refactor(react): one vocabulary and simpler plumbing for portal elements
YousefED Sep 4, 2026
473642d
refactor(ui): keep the portalElement prop's plain name in the UI libr…
YousefED Sep 4, 2026
4ca626d
Added tests
matthewlipski Sep 4, 2026
9e7dfa5
refactor(core)!: drop the portal option from editor.mount
YousefED Sep 5, 2026
bbb5ddf
small fixes
YousefED Sep 5, 2026
ceb93bd
refactor: require portalRoot and never fall back to the body
YousefED Sep 5, 2026
3eb01f7
Merge branch 'portal-root-always-pass' into portals-cleanup-v2
YousefED Sep 5, 2026
c63532a
fix: pass the required portal element in the SettingsSelect examples
YousefED Sep 5, 2026
ea7aef9
Merge branch 'portals-context' into portals-cleanup-v2
YousefED Sep 5, 2026
17eca72
test(mantine): pin the render profile of BlockNoteView around portals
YousefED Sep 5, 2026
e3dde99
Merge branch 'main' into mobile-toolbar-demo
YousefED Sep 5, 2026
355ddc4
Merge branch 'mobile-toolbar-demo' into portals-cleanup-v2
YousefED Sep 5, 2026
7c50b00
fix(mantine,ariakit): style portalled menus by their own class
YousefED Sep 5, 2026
4150a35
feat(react): render a floating component's menus inside its wrapper
YousefED Sep 5, 2026
4dbe297
fix(mantine): drop the toolbar focus trap
YousefED Sep 5, 2026
4d3a67c
test(mantine): let commits settle before measuring the render profile
YousefED Sep 5, 2026
9cfade6
fix(react): portal floating UI next to the editor element by default
YousefED Sep 5, 2026
ef72729
docs(react): drop the null portal target from the doc comments
YousefED Sep 6, 2026
3351b2e
chore(docs): allow only the private 172.16/12 range for dev origins
YousefED Sep 6, 2026
27f48e8
Merge branch 'mobile-toolbar-demo' into portals-cleanup-v2
YousefED Sep 6, 2026
05d2e3b
feat(react): warn about the viewport meta tag from the view, in devel…
YousefED Sep 6, 2026
aaef894
docs(examples): show the mobile toolbar example in the playground
YousefED Sep 6, 2026
bd0537a
fix(react): a nested BlockNoteView resolves its own portal element
YousefED Sep 6, 2026
36e4a47
docs(react): state the preventFocusOnOpen contract
YousefED Sep 6, 2026
d94a980
test: link toolbar create and edit flows per skin
YousefED Sep 6, 2026
261d5f6
Merge branch 'mobile-toolbar-demo' into portals-cleanup-v2
YousefED Sep 7, 2026
3265ebc
Merge branch 'mobile-toolbar-demo' into portals-cleanup-v2
matthewlipski Sep 18, 2026
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
8 changes: 4 additions & 4 deletions docs/content/docs/react/components/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,20 @@ BlockNote includes a number of UI Components (like menus and toolbars) that can

## Configuring Portal Targets

By default, all floating UI elements (toolbars, menus, table handles, etc.) portal into the editor's `bn-container` so they stay scoped to the editor. If your layout needs them to escapee.g. an `overflow: hidden` ancestor that would clip large dropdowns, or a host modal with its own stacking context pass a `portalElements` prop to `BlockNoteView`:
By default, the floating components (formatting toolbar, side menu, slash menu, table handles, etc.) portal into the element that wraps the editor: the editor's `bn-container`, or, when you render `BlockNoteViewEditor` yourself, the element you render it into. So they stay scoped to the editor and clip with it. The menus and popovers a floating component opens render inside that component's wrapper, so they move and hide with it. If your layout needs the floating components to escape, e.g. an `overflow: hidden` ancestor that would clip large dropdowns, or a host modal with its own stacking context, pass a `portalElements` prop to `BlockNoteView`:

```tsx
<BlockNoteView
editor={editor}
portalElements={{
// Global default for any element not listed below.
default: document.body,
// Per-element overrides. Values can be HTMLElement, a CSS selector, or null (= document.body).
// Per-element overrides. Values can be an HTMLElement or a CSS selector.
tableHandles: ".bn-container",
}}
/>
```

Keys mirror the default UI flags (`formattingToolbar`, `linkToolbar`, `slashMenu`, `emojiPicker`, `sideMenu`, `filePanel`, `tableHandles`, `comments`). Manually-mounted Controllers also accept a `portalElement` prop that takes precedence over the map. See the [Portal Targets example](/examples/ui-components/portal-elements).
Keys mirror the default UI flags (`formattingToolbar`, `linkToolbar`, `slashMenu`, `emojiPicker`, `sideMenu`, `filePanel`, `tableHandles`, `comments`). Manually-mounted Controllers also accept a `portalElement` prop that takes precedence over the map. All keys, including `default`, update reactively. See the [Portal Targets example](/examples/ui-components/portal-elements).

Note: changing `portalElements.default` after mount requires remounting the editor (`editor.mount()` consults it once); per-element keys update reactively.
When a target sits outside the editor's DOM (like `document.body`), BlockNote automatically renders a themed wrapper element inside it, so floating UI keeps the editor's styling and theming wherever it's portalled.
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
useBlockNoteEditor,
useComponentsContext,
useDictionary,
useMobileToolbarPortal,
usePortalElement,
useSelectedBlocks,
} from "@blocknote/react";
import { useCallback, useEffect, useState } from "react";
Expand All @@ -24,7 +24,7 @@ export const FileReplaceButton = () => {
const dict = useDictionary();
const Components = useComponentsContext()!;
// Portal necessary to properly show popover on mobile.
const mobileToolbarPortal = useMobileToolbarPortal();
const editorPortalElement = usePortalElement();

const editor = useBlockNoteEditor<
BlockSchema,
Expand Down Expand Up @@ -68,7 +68,7 @@ export const FileReplaceButton = () => {
open={isOpen}
onOpenChange={setIsOpen}
position={"bottom"}
portalRoot={mobileToolbarPortal ?? undefined}
portalElement={editorPortalElement}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
>
<Components.Generic.Popover.Trigger>
<Components.FormattingToolbar.Button
Expand Down
4 changes: 2 additions & 2 deletions examples/03-ui-components/20-portal-elements/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Configuring Portal Targets

By default, BlockNote's floating UI elements (formatting toolbar, slash menu, table handles, etc.) mount inside the editor's `bn-container`. The `portalElements` prop on `BlockNoteView` lets you change thatglobally via `default`, or per element by key.
By default, BlockNote's floating components (formatting toolbar, slash menu, table handles, etc.) mount next to the editor, inside its `bn-container` (or inside whatever you render `BlockNoteViewEditor` into). The `portalElements` prop on `BlockNoteView` lets you change that: globally via `default`, or per component by key. The menus and popovers a floating component opens follow it wherever it mounts.

This example renders two editors side-by-side, both wrapped in a small `overflow: hidden` container. The left editor uses the default the slash menu is clipped by the editor's bounds. The right editor passes `portalElements={{ default: document.body }}` so floating UI escapes the wrapper and renders fully.
This example renders two editors side-by-side, both wrapped in a small `overflow: hidden` container. The left editor uses the default, so the slash menu is clipped by the editor's bounds. The right editor passes `portalElements={{ default: document.body }}` so the floating components escape the wrapper and render fully.

```tsx
<BlockNoteView editor={editor} portalElements={{ default: document.body }} />
Expand Down
11 changes: 10 additions & 1 deletion examples/07-collaboration/05-comments/src/SettingsSelect.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ComponentProps, useComponentsContext } from "@blocknote/react";
import {
ComponentProps,
useComponentsContext,
usePortalElement,
} from "@blocknote/react";

// This component is used to display a selection dropdown with a label. By using
// the useComponentsContext hook, we can create it out of existing components
Expand All @@ -9,6 +13,10 @@ export const SettingsSelect = (props: {
items: ComponentProps["FormattingToolbar"]["Select"]["items"];
}) => {
const Components = useComponentsContext()!;
// The select's dropdown portals into the editor's portal element, which keeps
// it themed and clear of any overflow clipping. The prop is required, so it
// can't be left out by accident.
const portalElement = usePortalElement();

return (
<div className={"settings-select"}>
Expand All @@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
<Components.Generic.Toolbar.Select
className={"bn-select"}
items={props.items}
portalElement={portalElement}
/>
</Components.Generic.Toolbar.Root>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ComponentProps, useComponentsContext } from "@blocknote/react";
import {
ComponentProps,
useComponentsContext,
usePortalElement,
} from "@blocknote/react";

// This component is used to display a selection dropdown with a label. By using
// the useComponentsContext hook, we can create it out of existing components
Expand All @@ -9,6 +13,10 @@ export const SettingsSelect = (props: {
items: ComponentProps["FormattingToolbar"]["Select"]["items"];
}) => {
const Components = useComponentsContext()!;
// The select's dropdown portals into the editor's portal element, which keeps
// it themed and clear of any overflow clipping. The prop is required, so it
// can't be left out by accident.
const portalElement = usePortalElement();

return (
<div className={"settings-select"}>
Expand All @@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
<Components.Generic.Toolbar.Select
className={"bn-select"}
items={props.items}
portalElement={portalElement}
/>
</Components.Generic.Toolbar.Root>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ComponentProps, useComponentsContext } from "@blocknote/react";
import {
ComponentProps,
useComponentsContext,
usePortalElement,
} from "@blocknote/react";

// This component is used to display a selection dropdown with a label. By using
// the useComponentsContext hook, we can create it out of existing components
Expand All @@ -9,6 +13,10 @@ export const SettingsSelect = (props: {
items: ComponentProps["FormattingToolbar"]["Select"]["items"];
}) => {
const Components = useComponentsContext()!;
// The select's dropdown portals into the editor's portal element, which keeps
// it themed and clear of any overflow clipping. The prop is required, so it
// can't be left out by accident.
const portalElement = usePortalElement();

return (
<div className={"settings-select"}>
Expand All @@ -17,6 +25,7 @@ export const SettingsSelect = (props: {
<Components.Generic.Toolbar.Select
className={"bn-select"}
items={props.items}
portalElement={portalElement}
/>
</Components.Generic.Toolbar.Root>
</div>
Expand Down
24 changes: 14 additions & 10 deletions packages/ariakit/src/menu/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,19 @@ import { assertEmpty, mergeCSSClasses } from "@blocknote/core";
import { ComponentProps } from "@blocknote/react";
import { createContext, forwardRef, useContext } from "react";

// Threads the `portalRoot` override from `Menu` (the provider) down to
// `MenuDropdown`, where ariakit's `portalElement` prop actually lives.
const PortalRootContext = createContext<HTMLElement | null | undefined>(
undefined,
);
// Hands the `portalElement` prop from `Menu` (the root) down to
// `MenuDropdown`, where Ariakit takes it.
const MenuPortalElementContext = createContext<HTMLElement | null>(null);

export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => {
const {
children,
onOpenChange,
position,
portalRoot,
portalElement,
// ariakit's `virtualFocus` keeps DOM focus on the editor (roving via
// `aria-activedescendant`), so there is no focus to suppress here.
preventFocusOnOpen: _preventFocusOnOpen,
sub: _sub, // unused
...rest
} = props;
Expand All @@ -37,9 +38,9 @@ export const Menu = (props: ComponentProps["Generic"]["Menu"]["Root"]) => {
setOpen={onOpenChange}
virtualFocus={true}
>
<PortalRootContext.Provider value={portalRoot}>
<MenuPortalElementContext.Provider value={portalElement}>
{children}
</PortalRootContext.Provider>
</MenuPortalElementContext.Provider>
</AriakitMenuProvider>
);
};
Expand All @@ -57,13 +58,16 @@ export const MenuDropdown = forwardRef<

assertEmpty(rest);

const portalRoot = useContext(PortalRootContext);
const portalElement = useContext(MenuPortalElementContext);

return (
<AriakitMenu
unmountOnHide={true}
className={mergeCSSClasses("bn-ak-menu", className || "")}
portalElement={portalRoot ?? undefined}
// Ariakit falls back to a body-appended div for a missing element, so
// don't portal at all until there is one (editor not mounted yet).
portal={portalElement !== null}
portalElement={portalElement}
ref={ref}
>
{children}
Expand Down
27 changes: 19 additions & 8 deletions packages/ariakit/src/popover/Popover.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import { assertEmpty, mergeCSSClasses } from "@blocknote/core";
import { ComponentProps } from "@blocknote/react";
import { createContext, forwardRef, useContext } from "react";

const PortalRootContext = createContext<HTMLElement | null | undefined>(
undefined,
);
// Hands the `portalElement` prop from `Popover` (the root) down to
// `PopoverContent`, where Ariakit takes it.
const PopoverPortalElementContext = createContext<HTMLElement | null>(null);

export const PopoverTrigger = forwardRef<
HTMLButtonElement,
Expand All @@ -31,7 +31,7 @@ export const PopoverContent = forwardRef<

assertEmpty(rest);

const portalRoot = useContext(PortalRootContext);
const portalElement = useContext(PopoverPortalElementContext);

return (
<AriakitPopover
Expand All @@ -40,7 +40,10 @@ export const PopoverContent = forwardRef<
className || "",
variant === "panel-popover" ? "bn-ak-panel-popover" : "",
)}
portalElement={portalRoot ?? undefined}
// Ariakit falls back to a body-appended div for a missing element, so
// don't portal at all until there is one (editor not mounted yet).
portal={portalElement !== null}
portalElement={portalElement}
ref={ref}
>
{children}
Expand All @@ -51,7 +54,15 @@ export const PopoverContent = forwardRef<
export const Popover = (
props: ComponentProps["Generic"]["Popover"]["Root"],
) => {
const { children, open, onOpenChange, position, portalRoot, ...rest } = props;
const {
children,
open,
onOpenChange,
position,
portalElement,
preventFocusOnOpen: _preventFocusOnOpen, // unused; see Menu.tsx
Comment thread
coderabbitai[bot] marked this conversation as resolved.
...rest
} = props;

assertEmpty(rest);

Expand All @@ -61,9 +72,9 @@ export const Popover = (
setOpen={onOpenChange}
placement={position}
>
<PortalRootContext.Provider value={portalRoot}>
<PopoverPortalElementContext.Provider value={portalElement}>
{children}
</PortalRootContext.Provider>
</PopoverPortalElementContext.Provider>
</AriakitPopoverProvider>
);
};
4 changes: 3 additions & 1 deletion packages/ariakit/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
inset 0 1px 1px 1px var(--shadow);
}

.bn-toolbar .bn-ak-popover {
/* Form popovers (link, caption, rename) stack their fields; lists keep the
tighter spacing. */
.bn-ariakit .bn-form-popover {
gap: 0.5rem;
}

Expand Down
14 changes: 12 additions & 2 deletions packages/ariakit/src/toolbar/ToolbarSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,14 @@ export const ToolbarSelect = forwardRef<
HTMLDivElement,
ComponentProps["FormattingToolbar"]["Select"]
>((props, ref) => {
const { className, items, isDisabled, portalRoot, ...rest } = props;
const {
className,
items,
isDisabled,
portalElement,
preventFocusOnOpen: _preventFocusOnOpen, // unused; see Menu.tsx
...rest
} = props;

assertEmpty(rest);

Expand All @@ -40,7 +47,10 @@ export const ToolbarSelect = forwardRef<
className={mergeCSSClasses("bn-ak-popover", className || "")}
ref={ref}
gutter={4}
portalElement={portalRoot ?? undefined}
// Ariakit falls back to a body-appended div for a missing element,
// so don't portal at all until there is one (editor not mounted yet).
portal={portalElement !== null}
portalElement={portalElement}
>
{items.map((option) => (
<AriakitSelectItem
Expand Down
Loading
Loading