diff --git a/examples/03-ui-components/20-portal-elements/.bnexample.json b/examples/03-ui-components/20-portal-elements/.bnexample.json
new file mode 100644
index 0000000000..40dfffd4d9
--- /dev/null
+++ b/examples/03-ui-components/20-portal-elements/.bnexample.json
@@ -0,0 +1,6 @@
+{
+ "playground": true,
+ "docs": true,
+ "author": "nperez0111",
+ "tags": ["UI Components", "Advanced"]
+}
diff --git a/examples/03-ui-components/20-portal-elements/README.md b/examples/03-ui-components/20-portal-elements/README.md
new file mode 100644
index 0000000000..c0e4a5c34a
--- /dev/null
+++ b/examples/03-ui-components/20-portal-elements/README.md
@@ -0,0 +1,20 @@
+# Configuring Portal Targets per Element
+
+By default, BlockNote's floating UI elements (formatting toolbar, slash menu, table handles, etc.) mount inside the editor's `bn-container` element. The `portalElements` prop lets you change that — globally via `default`, or per element by key.
+
+In this example we deliberately wrap the editor in a small parent with `overflow: hidden` so the global default of `bn-container` would clip the slash menu and the formatting toolbar. We escape only those two to `document.body`, while keeping `tableHandles` inside `.bn-container` so the table handles can never escape the editor's visual boundary.
+
+```tsx
+
+```
+
+**Relevant Docs:**
+
+- [UI Components](/docs/react/components)
diff --git a/examples/03-ui-components/20-portal-elements/index.html b/examples/03-ui-components/20-portal-elements/index.html
new file mode 100644
index 0000000000..a1d00cee32
--- /dev/null
+++ b/examples/03-ui-components/20-portal-elements/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Configuring Portal Targets per Element
+
+
+
+
+
+
+
diff --git a/examples/03-ui-components/20-portal-elements/main.tsx b/examples/03-ui-components/20-portal-elements/main.tsx
new file mode 100644
index 0000000000..677c7f7eed
--- /dev/null
+++ b/examples/03-ui-components/20-portal-elements/main.tsx
@@ -0,0 +1,11 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import React from "react";
+import { createRoot } from "react-dom/client";
+import App from "./src/App.jsx";
+
+const root = createRoot(document.getElementById("root")!);
+root.render(
+
+
+
+);
diff --git a/examples/03-ui-components/20-portal-elements/package.json b/examples/03-ui-components/20-portal-elements/package.json
new file mode 100644
index 0000000000..2ecd0a811d
--- /dev/null
+++ b/examples/03-ui-components/20-portal-elements/package.json
@@ -0,0 +1,30 @@
+{
+ "name": "@blocknote/example-ui-components-portal-elements",
+ "description": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "type": "module",
+ "private": true,
+ "version": "0.12.4",
+ "scripts": {
+ "start": "vite",
+ "dev": "vite",
+ "build:prod": "tsc && vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@blocknote/ariakit": "latest",
+ "@blocknote/core": "latest",
+ "@blocknote/mantine": "latest",
+ "@blocknote/react": "latest",
+ "@blocknote/shadcn": "latest",
+ "@mantine/core": "^9.0.2",
+ "@mantine/hooks": "^9.0.2",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.3",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.1",
+ "vite": "^8.0.8"
+ }
+}
\ No newline at end of file
diff --git a/examples/03-ui-components/20-portal-elements/src/App.tsx b/examples/03-ui-components/20-portal-elements/src/App.tsx
new file mode 100644
index 0000000000..0434ff819b
--- /dev/null
+++ b/examples/03-ui-components/20-portal-elements/src/App.tsx
@@ -0,0 +1,58 @@
+import "@blocknote/core/fonts/inter.css";
+import { BlockNoteView } from "@blocknote/mantine";
+import "@blocknote/mantine/style.css";
+import { useCreateBlockNote, type PortalElementsMap } from "@blocknote/react";
+
+import "./styles.css";
+
+const initialContent = [
+ {
+ type: "paragraph" as const,
+ content: "Click in this editor and press / to open the slash menu.",
+ },
+ {
+ type: "paragraph" as const,
+ content:
+ "Notice whether the menu fits inside the box or escapes it.",
+ },
+ {
+ type: "paragraph" as const,
+ },
+];
+
+function PortalDemoEditor({
+ label,
+ description,
+ portalElements,
+}: {
+ label: string;
+ description: string;
+ portalElements?: PortalElementsMap;
+}) {
+ const editor = useCreateBlockNote({ initialContent });
+ return (
+
+
{label}
+
{description}
+
+
+
+
+ );
+}
+
+export default function App() {
+ return (
+
+ );
+}
diff --git a/examples/03-ui-components/20-portal-elements/src/styles.css b/examples/03-ui-components/20-portal-elements/src/styles.css
new file mode 100644
index 0000000000..8cf28385d0
--- /dev/null
+++ b/examples/03-ui-components/20-portal-elements/src/styles.css
@@ -0,0 +1,68 @@
+.views {
+ container-name: views;
+ container-type: inline-size;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ gap: 8px;
+ padding: 8px;
+}
+
+/*
+ * Each view is intentionally shorter than the slash menu so the clipping
+ * vs escaping behaviour is visible at a glance.
+ */
+.view-wrapper {
+ display: flex;
+ flex-direction: column;
+ height: 260px;
+ width: 100%;
+}
+
+@container views (width > 1024px) {
+ .view-wrapper {
+ width: calc(50% - 4px);
+ }
+}
+
+.view-label {
+ color: #0090ff;
+ display: flex;
+ font-size: 12px;
+ font-weight: bold;
+ justify-content: space-between;
+ margin-inline: 16px;
+}
+
+.view-description {
+ color: #0090ff;
+ font-size: 12px;
+ margin: 2px 16px 0;
+}
+
+/*
+ * `position: relative` is what actually makes `overflow: hidden` clip the
+ * absolutely-positioned floating UI. Without it the popover's containing
+ * block is the viewport and the clip is bypassed.
+ */
+.view {
+ border: solid #0090ff 1px;
+ border-radius: 16px;
+ flex: 1;
+ height: 0;
+ padding: 8px;
+ position: relative;
+ overflow: hidden;
+}
+
+.view .bn-container {
+ height: 100%;
+ margin: 0;
+ max-width: none;
+ padding: 0;
+}
+
+.view .bn-editor {
+ height: 100%;
+ overflow: auto;
+}
diff --git a/examples/03-ui-components/20-portal-elements/tsconfig.json b/examples/03-ui-components/20-portal-elements/tsconfig.json
new file mode 100644
index 0000000000..dbe3e6f62d
--- /dev/null
+++ b/examples/03-ui-components/20-portal-elements/tsconfig.json
@@ -0,0 +1,36 @@
+{
+ "__comment": "AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY",
+ "compilerOptions": {
+ "target": "ESNext",
+ "useDefineForClassFields": true,
+ "lib": [
+ "DOM",
+ "DOM.Iterable",
+ "ESNext"
+ ],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": false,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "composite": true
+ },
+ "include": [
+ "."
+ ],
+ "__ADD_FOR_LOCAL_DEV_references": [
+ {
+ "path": "../../../packages/core/"
+ },
+ {
+ "path": "../../../packages/react/"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/examples/03-ui-components/20-portal-elements/vite.config.ts b/examples/03-ui-components/20-portal-elements/vite.config.ts
new file mode 100644
index 0000000000..f62ab20bc2
--- /dev/null
+++ b/examples/03-ui-components/20-portal-elements/vite.config.ts
@@ -0,0 +1,32 @@
+// AUTO-GENERATED FILE, DO NOT EDIT DIRECTLY
+import react from "@vitejs/plugin-react";
+import * as fs from "fs";
+import * as path from "path";
+import { defineConfig } from "vite";
+// import eslintPlugin from "vite-plugin-eslint";
+// https://vitejs.dev/config/
+export default defineConfig((conf) => ({
+ plugins: [react()],
+ optimizeDeps: {},
+ build: {
+ sourcemap: true,
+ },
+ resolve: {
+ alias:
+ conf.command === "build" ||
+ !fs.existsSync(path.resolve(__dirname, "../../packages/core/src"))
+ ? {}
+ : ({
+ // Comment out the lines below to load a built version of blocknote
+ // or, keep as is to load live from sources with live reload working
+ "@blocknote/core": path.resolve(
+ __dirname,
+ "../../packages/core/src/"
+ ),
+ "@blocknote/react": path.resolve(
+ __dirname,
+ "../../packages/react/src/"
+ ),
+ } as any),
+ },
+}));
diff --git a/packages/core/src/editor/BlockNoteEditor.ts b/packages/core/src/editor/BlockNoteEditor.ts
index ca6e0b4817..fbd8c9409b 100644
--- a/packages/core/src/editor/BlockNoteEditor.ts
+++ b/packages/core/src/editor/BlockNoteEditor.ts
@@ -731,15 +731,27 @@ export class BlockNoteEditor<
/**
* Mount the editor to a DOM element.
*
+ * @param element The DOM element to mount the editor's contenteditable into.
+ * @param options.portalTarget Where to mount `editor.portalElement` — the
+ * container that floating UI (toolbars, menus, etc) portals into. When
+ * omitted, defaults to `element.parentElement` (which is the editor's
+ * `bn-container` in typical React usage), or to `document.body` /
+ * the surrounding shadow root when no parent is available.
+ *
* @warning Not needed to call manually when using React, use BlockNoteView to take care of mounting
*/
- public mount = (element: HTMLElement) => {
+ public mount = (
+ element: HTMLElement,
+ options?: { portalTarget?: HTMLElement | null },
+ ) => {
const root = element.getRootNode();
- if (typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot) {
- root.appendChild(this.portalElement);
- } else {
- document.body.appendChild(this.portalElement);
- }
+ const isInShadowRoot =
+ typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot;
+ const target =
+ options?.portalTarget ??
+ element.parentElement ??
+ (isInShadowRoot ? (root as ShadowRoot) : document.body);
+ target.appendChild(this.portalElement);
this._tiptapEditor.mount({ mount: element });
};
diff --git a/packages/react/src/components/Comments/FloatingComposerController.tsx b/packages/react/src/components/Comments/FloatingComposerController.tsx
index 90faec1d20..0c41402d66 100644
--- a/packages/react/src/components/Comments/FloatingComposerController.tsx
+++ b/packages/react/src/components/Comments/FloatingComposerController.tsx
@@ -24,6 +24,12 @@ export default function FloatingComposerController<
>(props: {
floatingComposer?: FC>;
floatingUIOptions?: FloatingUIOptions;
+ /**
+ * Override the DOM node this floating element portals into. Falls back to
+ * `editor.portalElement` (which by default is mounted inside `bn-container`)
+ * when omitted.
+ */
+ portalElement?: HTMLElement | null;
}) {
const editor = useBlockNoteEditor();
@@ -82,7 +88,11 @@ export default function FloatingComposerController<
const Component = props.floatingComposer || FloatingComposer;
return (
-
+
);
diff --git a/packages/react/src/components/Comments/FloatingThreadController.tsx b/packages/react/src/components/Comments/FloatingThreadController.tsx
index c57a1c9295..20799c1dea 100644
--- a/packages/react/src/components/Comments/FloatingThreadController.tsx
+++ b/packages/react/src/components/Comments/FloatingThreadController.tsx
@@ -16,6 +16,12 @@ import { useThreads } from "./useThreads.js";
export default function FloatingThreadController(props: {
floatingThread?: FC>;
floatingUIOptions?: FloatingUIOptions;
+ /**
+ * Override the DOM node this floating element portals into. Falls back to
+ * `editor.portalElement` (which by default is mounted inside `bn-container`)
+ * when omitted.
+ */
+ portalElement?: HTMLElement | null;
}) {
const editor = useBlockNoteEditor();
@@ -78,7 +84,11 @@ export default function FloatingThreadController(props: {
const Component = props.floatingThread || Thread;
return (
-
+
{thread && }
);
diff --git a/packages/react/src/components/FilePanel/FilePanelController.tsx b/packages/react/src/components/FilePanel/FilePanelController.tsx
index 6beb94ec1e..b9da146874 100644
--- a/packages/react/src/components/FilePanel/FilePanelController.tsx
+++ b/packages/react/src/components/FilePanel/FilePanelController.tsx
@@ -12,6 +12,12 @@ import { FilePanelProps } from "./FilePanelProps.js";
export const FilePanelController = (props: {
filePanel?: FC;
floatingUIOptions?: FloatingUIOptions;
+ /**
+ * Override the DOM node this floating element portals into. Falls back to
+ * `editor.portalElement` (which by default is mounted inside `bn-container`)
+ * when omitted.
+ */
+ portalElement?: HTMLElement | null;
}) => {
const editor = useBlockNoteEditor();
@@ -54,7 +60,11 @@ export const FilePanelController = (props: {
const Component = props.filePanel || FilePanel;
return (
-
+
{blockId && }
);
diff --git a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx
index 20184e626f..a10469eab1 100644
--- a/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx
+++ b/packages/react/src/components/FormattingToolbar/FormattingToolbarController.tsx
@@ -36,6 +36,12 @@ const textAlignmentToPlacement = (
export const FormattingToolbarController = (props: {
formattingToolbar?: FC;
floatingUIOptions?: FloatingUIOptions;
+ /**
+ * Override the DOM node this floating element portals into. Falls back to
+ * `editor.portalElement` (which by default is mounted inside `bn-container`)
+ * when omitted.
+ */
+ portalElement?: HTMLElement | null;
}) => {
const editor = useBlockNoteEditor<
BlockSchema,
@@ -112,7 +118,11 @@ export const FormattingToolbarController = (props: {
const Component = props.formattingToolbar || FormattingToolbar;
return (
-
+
{show && }
);
diff --git a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx
index 9e7fe42d07..fbe789a544 100644
--- a/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx
+++ b/packages/react/src/components/LinkToolbar/LinkToolbarController.tsx
@@ -17,6 +17,12 @@ import { LinkToolbarProps } from "./LinkToolbarProps.js";
export const LinkToolbarController = (props: {
linkToolbar?: FC;
floatingUIOptions?: FloatingUIOptions;
+ /**
+ * Override the DOM node this floating element portals into. Falls back to
+ * `editor.portalElement` (which by default is mounted inside `bn-container`)
+ * when omitted.
+ */
+ portalElement?: HTMLElement | null;
}) => {
const editor = useBlockNoteEditor();
@@ -178,7 +184,11 @@ export const LinkToolbarController = (props: {
const Component = props.linkToolbar || LinkToolbar;
return (
-
+
{link && (
{
- const { blockId, children, ...floatingUIOptions } = props;
+ const { blockId, children, portalElement, ...floatingUIOptions } = props;
const editor = useBlockNoteEditor();
@@ -43,7 +44,11 @@ export const BlockPopover = (
);
return (
-
+
{blockId !== undefined && children}
);
diff --git a/packages/react/src/components/Popovers/GenericPopover.tsx b/packages/react/src/components/Popovers/GenericPopover.tsx
index 5eb08edc4d..d03534a49d 100644
--- a/packages/react/src/components/Popovers/GenericPopover.tsx
+++ b/packages/react/src/components/Popovers/GenericPopover.tsx
@@ -109,10 +109,20 @@ export const GenericPopover = (
props: FloatingUIOptions & {
reference?: GenericPopoverReference;
children: ReactNode;
+ /**
+ * Override the DOM node this popover portals into. If omitted, falls back
+ * to `editor.portalElement`.
+ */
+ portalElement?: HTMLElement | null;
},
) => {
const editor = useBlockNoteEditor();
- const portalRoot = editor?.portalElement;
+ const portalRoot =
+ props.portalElement === null
+ ? typeof document !== "undefined"
+ ? document.body
+ : undefined
+ : (props.portalElement ?? editor?.portalElement);
if (!portalRoot) {
throw new Error("Portal element not found");
}
diff --git a/packages/react/src/components/Popovers/PositionPopover.tsx b/packages/react/src/components/Popovers/PositionPopover.tsx
index 93ef837f61..f59b458900 100644
--- a/packages/react/src/components/Popovers/PositionPopover.tsx
+++ b/packages/react/src/components/Popovers/PositionPopover.tsx
@@ -10,9 +10,10 @@ export const PositionPopover = (
props: FloatingUIOptions & {
position: { from: number; to?: number } | undefined;
children: ReactNode;
+ portalElement?: HTMLElement | null;
},
) => {
- const { position, children, ...floatingUIOptions } = props;
+ const { position, children, portalElement, ...floatingUIOptions } = props;
const { from, to } = position || {};
const editor = useBlockNoteEditor();
@@ -34,7 +35,11 @@ export const PositionPopover = (
}, [editor, editorDOMElement, from, to]);
return (
-
+
{position !== undefined && children}
);
diff --git a/packages/react/src/components/SideMenu/SideMenuController.tsx b/packages/react/src/components/SideMenu/SideMenuController.tsx
index 37022b4d16..4f70e4b4c4 100644
--- a/packages/react/src/components/SideMenu/SideMenuController.tsx
+++ b/packages/react/src/components/SideMenu/SideMenuController.tsx
@@ -12,6 +12,12 @@ import { SideMenuProps } from "./SideMenuProps.js";
export const SideMenuController = (props: {
sideMenu?: FC;
floatingUIOptions?: Partial;
+ /**
+ * Override the DOM node this floating element portals into. Falls back to
+ * `editor.portalElement` (which by default is mounted inside `bn-container`)
+ * when omitted.
+ */
+ portalElement?: HTMLElement | null;
}) => {
const editor = useBlockNoteEditor();
const state = useExtensionState(SideMenuExtension, {
@@ -89,7 +95,11 @@ export const SideMenuController = (props: {
const Component = props.sideMenu || SideMenu;
return (
-
+
{block?.id && }
);
diff --git a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx
index a0fdcb61d4..1fe667635b 100644
--- a/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx
+++ b/packages/react/src/components/SuggestionMenu/GridSuggestionMenu/GridSuggestionMenuController.tsx
@@ -44,6 +44,12 @@ export function GridSuggestionMenuController<
shouldOpen?: SuggestionMenuOptions["shouldOpen"];
minQueryLength?: number;
floatingUIOptions?: FloatingUIOptions;
+ /**
+ * Override the DOM node this floating element portals into. Falls back to
+ * `editor.portalElement` (which by default is mounted inside `bn-container`)
+ * when omitted.
+ */
+ portalElement?: HTMLElement | null;
} & (ItemType extends DefaultReactGridSuggestionItem
? {
// can be undefined
@@ -178,7 +184,11 @@ export function GridSuggestionMenuController<
}
return (
-
+
{triggerCharacter && (
extends DefaultReactSuggestionItem
? {
// can be undefined
@@ -171,7 +177,11 @@ export function SuggestionMenuController<
}
return (
-
+
{triggerCharacter && (
;
tableHandle?: FC;
extendButton?: FC;
+ /**
+ * Override the DOM node this floating element portals into. Falls back to
+ * `editor.portalElement` (which by default is mounted inside `bn-container`)
+ * when omitted.
+ */
+ portalElement?: HTMLElement | null;
}) => {
const editor = useBlockNoteEditor();
@@ -312,6 +318,7 @@ export const TableHandlesController = <
<>
{state.show &&
@@ -327,6 +334,7 @@ export const TableHandlesController = <
{state.show &&
@@ -342,6 +350,7 @@ export const TableHandlesController = <
{state.show &&
@@ -357,6 +366,7 @@ export const TableHandlesController = <
{state.show &&
@@ -372,6 +382,7 @@ export const TableHandlesController = <
{state.show &&
diff --git a/packages/react/src/editor/BlockNoteDefaultUI.tsx b/packages/react/src/editor/BlockNoteDefaultUI.tsx
index 6ea66d094e..f5044e837d 100644
--- a/packages/react/src/editor/BlockNoteDefaultUI.tsx
+++ b/packages/react/src/editor/BlockNoteDefaultUI.tsx
@@ -17,6 +17,10 @@ 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 {
+ PortalElementsMap,
+ resolvePortalTarget,
+} from "./portalElements.js";
// Lazily load the comments components to avoid pulling in the comments extensions into the main bundle
const FloatingComposerController = lazy(
@@ -74,6 +78,18 @@ export type BlockNoteDefaultUIProps = {
* @see {@link https://blocknotejs.org/docs/react/components/comments}
*/
comments?: boolean;
+
+ /**
+ * Per-element portal targets for floating UI. Each key corresponds to one
+ * of the default UI elements; values can be an `HTMLElement`, a CSS
+ * selector string, or `null` (= `document.body`). The optional `default`
+ * key controls where `editor.portalElement` itself is mounted; when
+ * omitted, the editor's `bn-container` element is used.
+ *
+ * Per-element keys override `default` for that one element. Unspecified
+ * elements fall back to `default` via `editor.portalElement`.
+ */
+ portalElements?: PortalElementsMap;
};
export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) {
@@ -85,18 +101,33 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) {
);
}
+ const map = props.portalElements;
+ const formattingToolbarPortal = resolvePortalTarget(map?.formattingToolbar);
+ const linkToolbarPortal = resolvePortalTarget(map?.linkToolbar);
+ const slashMenuPortal = resolvePortalTarget(map?.slashMenu);
+ const emojiPickerPortal = resolvePortalTarget(map?.emojiPicker);
+ const sideMenuPortal = resolvePortalTarget(map?.sideMenu);
+ const filePanelPortal = resolvePortalTarget(map?.filePanel);
+ const tableHandlesPortal = resolvePortalTarget(map?.tableHandles);
+ const commentsPortal = resolvePortalTarget(map?.comments);
+
return (
<>
{editor.getExtension(FormattingToolbarExtension) &&
- props.formattingToolbar !== false && }
+ props.formattingToolbar !== false && (
+
+ )}
{editor.getExtension(LinkToolbarExtension) &&
- props.linkToolbar !== false && }
+ props.linkToolbar !== false && (
+
+ )}
{editor.getExtension(SuggestionMenu) && props.slashMenu !== false && (
!state.selection.$from.parent.type.isInGroup("tableContent")
}
+ portalElement={slashMenuPortal}
/>
)}
{editor.getExtension(SuggestionMenu) && props.emojiPicker !== false && (
@@ -104,20 +135,23 @@ export function BlockNoteDefaultUI(props: BlockNoteDefaultUIProps) {
triggerCharacter=":"
columns={10}
minQueryLength={2}
+ portalElement={emojiPickerPortal}
/>
)}
{editor.getExtension(SideMenuExtension) && props.sideMenu !== false && (
-
+
)}
{editor.getExtension(FilePanelExtension) && props.filePanel !== false && (
-
+
)}
{editor.getExtension(TableHandlesExtension) &&
- props.tableHandles !== false && }
+ props.tableHandles !== false && (
+
+ )}
{editor.getExtension(CommentsExtension) && props.comments !== false && (
-
-
+
+
)}
>
diff --git a/packages/react/src/editor/BlockNoteView.tsx b/packages/react/src/editor/BlockNoteView.tsx
index a4679af611..d6e6f85b8e 100644
--- a/packages/react/src/editor/BlockNoteView.tsx
+++ b/packages/react/src/editor/BlockNoteView.tsx
@@ -27,6 +27,7 @@ import {
BlockNoteDefaultUI,
BlockNoteDefaultUIProps,
} from "./BlockNoteDefaultUI.js";
+import { resolvePortalTarget } from "./portalElements.js";
import {
BlockNoteViewContext,
useBlockNoteViewContext,
@@ -93,6 +94,10 @@ export type BlockNoteViewProps<
ref?: Ref | undefined; // only here to get types working with the generics. Regular form doesn't work
} & BlockNoteDefaultUIProps;
+// `portalElements` is part of `BlockNoteDefaultUIProps`, but we re-export the
+// types here for convenience so consumers can import them from `@blocknote/react`.
+export type { PortalElementsMap, PortalTarget } from "./portalElements.js";
+
function BlockNoteViewComponent<
BSchema extends BlockSchema,
ISchema extends InlineContentSchema,
@@ -121,11 +126,20 @@ function BlockNoteViewComponent<
filePanel,
tableHandles,
comments,
+ portalElements,
autoFocus,
renderEditor = true,
...rest
} = props;
+ // Resolved once and handed to `editor.mount()` via context. When omitted,
+ // `mount()` falls back to `element.parentElement` (i.e. `bn-container`).
+ // Changing this prop requires remounting the editor (use a `key`).
+ const portalTarget = useMemo(
+ () => resolvePortalTarget(portalElements?.default) ?? null,
+ [portalElements?.default],
+ );
+
// Used so other components (suggestion menu) can set
// aria related props to the contenteditable div
const [contentEditableProps, setContentEditableProps] =
@@ -151,6 +165,7 @@ function BlockNoteViewComponent<
tableHandles: componentsContext ? tableHandles : false,
emojiPicker: componentsContext ? emojiPicker : false,
comments: componentsContext ? comments : false,
+ portalElements,
}),
[
comments,
@@ -162,6 +177,7 @@ function BlockNoteViewComponent<
sideMenu,
slashMenu,
tableHandles,
+ portalElements,
],
);
@@ -206,10 +222,11 @@ function BlockNoteViewComponent<
autoFocus,
contentEditableProps,
editable,
+ portalTarget,
},
defaultUIProps,
};
- }, [autoFocus, contentEditableProps, editable, defaultUIProps]);
+ }, [autoFocus, contentEditableProps, editable, defaultUIProps, portalTarget]);
return (
@@ -289,6 +306,8 @@ export const BlockNoteViewEditor = (props: { children?: ReactNode }) => {
return getContentComponent();
}, []);
+ const portalTarget = ctx.editorProps.portalTarget;
+
const mount = useCallback(
(element: HTMLElement | null) => {
// Set editable state of the actual editor.
@@ -301,12 +320,12 @@ export const BlockNoteViewEditor = (props: { children?: ReactNode }) => {
// This is a simple replacement for the state management that Tiptap does internally
editor._tiptapEditor.contentComponent = portalManager;
if (element) {
- editor.mount(element);
+ editor.mount(element, { portalTarget });
} else {
editor.unmount();
}
},
- [ctx.editorProps.editable, editor, portalManager],
+ [ctx.editorProps.editable, editor, portalManager, portalTarget],
);
return (
@@ -328,6 +347,7 @@ const ContentEditableElement = (props: {
autoFocus?: boolean;
mount: (element: HTMLElement | null) => void;
contentEditableProps?: Record;
+ portalTarget?: HTMLElement | null;
}) => {
const { autoFocus, mount, contentEditableProps } = props;
return (
diff --git a/packages/react/src/editor/BlockNoteViewContext.ts b/packages/react/src/editor/BlockNoteViewContext.ts
index 44adcefe12..2b5b7413c8 100644
--- a/packages/react/src/editor/BlockNoteViewContext.ts
+++ b/packages/react/src/editor/BlockNoteViewContext.ts
@@ -6,6 +6,13 @@ export type BlockNoteViewContextValue = {
autoFocus?: boolean;
contentEditableProps?: Record;
editable?: boolean;
+ /**
+ * Resolved portal target for `editor.portalElement` — passed to
+ * `editor.mount()`. Comes from `portalElements.default` on
+ * `BlockNoteView`. `undefined` lets `mount()` use its default
+ * (`element.parentElement`, i.e. `bn-container`).
+ */
+ portalTarget?: HTMLElement | null;
};
defaultUIProps: BlockNoteDefaultUIProps;
};
diff --git a/packages/react/src/editor/portalElements.ts b/packages/react/src/editor/portalElements.ts
new file mode 100644
index 0000000000..c3a2a48dbc
--- /dev/null
+++ b/packages/react/src/editor/portalElements.ts
@@ -0,0 +1,57 @@
+/**
+ * A portal mount target.
+ *
+ * - `HTMLElement` — used as-is.
+ * - `string` — treated as a CSS selector and resolved via `document.querySelector`.
+ * - `null` — explicit `document.body` (escape any ancestor stacking context).
+ */
+export type PortalTarget = HTMLElement | string | null;
+
+/**
+ * Per-element portal targets for BlockNote's floating UI. Keys mirror the
+ * default UI element flags on `BlockNoteView`.
+ *
+ * `default` is the fallback used for any element whose key is omitted, and is
+ * also where `editor.portalElement` itself is mounted. Elements that omit a
+ * specific entry inherit `default`; if `default` is also omitted, the editor's
+ * `bn-container` element is used.
+ */
+export type PortalElementsMap = {
+ default?: PortalTarget;
+ formattingToolbar?: PortalTarget;
+ linkToolbar?: PortalTarget;
+ slashMenu?: PortalTarget;
+ emojiPicker?: PortalTarget;
+ sideMenu?: PortalTarget;
+ filePanel?: PortalTarget;
+ tableHandles?: PortalTarget;
+ comments?: PortalTarget;
+};
+
+export type PortalElementKey = Exclude;
+
+export function resolvePortalTarget(
+ target: PortalTarget | undefined,
+): HTMLElement | undefined {
+ if (target === undefined) {
+ return undefined;
+ }
+ if (target === null) {
+ return typeof document !== "undefined" ? document.body : undefined;
+ }
+ if (typeof target === "string") {
+ if (typeof document === "undefined") {
+ return undefined;
+ }
+ const el = document.querySelector(target);
+ if (!el) {
+ // eslint-disable-next-line no-console
+ console.warn(
+ `[BlockNote] portalElements selector "${target}" did not match any element`,
+ );
+ return undefined;
+ }
+ return el as HTMLElement;
+ }
+ return target;
+}
diff --git a/playground/src/examples.gen.tsx b/playground/src/examples.gen.tsx
index 5c70e29330..b1a5a9c8d9 100644
--- a/playground/src/examples.gen.tsx
+++ b/playground/src/examples.gen.tsx
@@ -874,6 +874,26 @@
"slug": "ui-components"
},
"readme": "In this example, we filter and reorder the default Slash Menu items so that only the \"Basic blocks\" and \"Headings\" groups are shown, with \"Basic blocks\" appearing first.\n\n**Try it out:** Press the \"/\" key to open the Slash Menu and see the reordered groups!\n\n**Relevant Docs:**\n\n- [Item Grouping & Ordering](/docs/react/components/suggestion-menus)\n- [Changing Slash Menu Items](/docs/react/components/suggestion-menus)\n- [Editor Setup](/docs/getting-started/editor-setup)"
+ },
+ {
+ "projectSlug": "portal-elements",
+ "fullSlug": "ui-components/portal-elements",
+ "pathFromRoot": "examples/03-ui-components/20-portal-elements",
+ "config": {
+ "playground": true,
+ "docs": true,
+ "author": "nperez0111",
+ "tags": [
+ "UI Components",
+ "Advanced"
+ ]
+ },
+ "title": "Configuring Portal Targets per Element",
+ "group": {
+ "pathFromRoot": "examples/03-ui-components",
+ "slug": "ui-components"
+ },
+ "readme": "By default, BlockNote's floating UI elements (formatting toolbar, slash menu, table handles, etc.) mount inside the editor's `bn-container` element. The `portalElements` prop lets you change that — globally via `default`, or per element by key.\n\nIn this example we deliberately wrap the editor in a small parent with `overflow: hidden` so the global default of `bn-container` would clip the slash menu and the formatting toolbar. We escape only those two to `document.body`, while keeping `tableHandles` inside `.bn-container` so the table handles can never escape the editor's visual boundary.\n\n```tsx\n\n```\n\n**Relevant Docs:**\n\n- [UI Components](/docs/react/components)"
}
]
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index af14b567c3..cfca45f174 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2154,6 +2154,49 @@ importers:
specifier: ^8.0.8
version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)
+ examples/03-ui-components/20-portal-elements:
+ dependencies:
+ '@blocknote/ariakit':
+ specifier: latest
+ version: link:../../../packages/ariakit
+ '@blocknote/core':
+ specifier: latest
+ version: link:../../../packages/core
+ '@blocknote/mantine':
+ specifier: latest
+ version: link:../../../packages/mantine
+ '@blocknote/react':
+ specifier: latest
+ version: link:../../../packages/react
+ '@blocknote/shadcn':
+ specifier: latest
+ version: link:../../../packages/shadcn
+ '@mantine/core':
+ specifier: ^9.0.2
+ version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@mantine/hooks':
+ specifier: ^9.0.2
+ version: 9.1.1(react@19.2.5)
+ react:
+ specifier: ^19.2.3
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.2.3
+ version: 19.2.5(react@19.2.5)
+ devDependencies:
+ '@types/react':
+ specifier: ^19.2.3
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.2.3
+ version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(babel-plugin-react-compiler@1.0.0)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3))
+ vite:
+ specifier: ^8.0.8
+ version: 8.0.8(@types/node@25.6.0)(esbuild@0.27.5)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.3)
+
examples/04-theming/01-theming-dom-attributes:
dependencies:
'@blocknote/ariakit':