Skip to content

fix: portal floating UI elements to document.body to prevent overflow clipping BLO-1115#2591

Open
YousefED wants to merge 8 commits intomainfrom
fix/portal-floating-ui-overflow-clipping
Open

fix: portal floating UI elements to document.body to prevent overflow clipping BLO-1115#2591
YousefED wants to merge 8 commits intomainfrom
fix/portal-floating-ui-overflow-clipping

Conversation

@YousefED
Copy link
Copy Markdown
Collaborator

@YousefED YousefED commented Mar 24, 2026

Summary

Floating UI elements (slash menu, formatting toolbar, link toolbar, side menu, file panel, table handles, emoji picker) are now portaled to a dedicated container at document.body, preventing them from being clipped by overflow: hidden ancestors.

Rationale

When BlockNote is rendered inside a container with overflow: hidden (e.g., a sidebar, modal, or scrollable panel), floating UI elements get clipped and become partially or fully invisible. Portaling to document.body is the standard fix.

Closes #2543
Closes #2544
Closes #2558
Closes #2578
Supersedes the approach from #2092

Changes

Portal infrastructure

  • BlockNoteView renders a portal container at document.body via createPortal. This container gets bn-root + color scheme + user className for theming, but NOT bn-container (layout only).
  • portalRoot exposed via BlockNoteContext so all floating elements can access it.
  • GenericPopover (the single component all floating UI elements flow through) wraps all render paths with <FloatingPortal root={portalRoot}>.

CSS architecture: bn-root vs bn-container

  • bn-root: theming class (CSS variables, font-family). Applied to both the editor container and the portal container, so floating elements inherit the correct theme.
  • bn-container: layout class (width, height). Only on the editor container.
  • Theme CSS variables in styles.css moved from .bn-container to .bn-root.
  • Removed dead .bn-root box-sizing reset from core (was never applied to any element).

Popover portalRoot prop (for EmojiPicker)

  • Added portalRoot to the generic Popover.Root component interface.
  • Mantine: uses native withinPortal + portalProps.
  • shadcn: threads portalRoot via React context from Root to Content, uses createPortal (avoids modifying user's shadcn primitives).
  • Ariakit: threads portalRoot via React context, uses native portalElement prop.

Z-index handling

  • When portaling to portalRoot, hardcoded z-index: 10000 is dropped (unnecessary at document.body level).
  • When NOT portaling (e.g., portalRoot is undefined from other call sites), the original z-index: 10000 is preserved.
  • Users can configure --bn-ui-base-z-index on .bn-root for GenericPopover-based elements.

EmojiPicker simplification

  • Removed manual createPortal — now passes portalRoot to Popover.Root and lets each UI library handle portaling.

Minor fixes

  • shadcn/ariakit Comment: emojiPickerOpen prop is now used (was previously ignored) to keep action buttons visible while emoji picker is open, matching mantine.
  • SideMenu: uses .closest(".bn-root") instead of editor wrapper containment check, so hovering portaled floating elements doesn't dismiss the side menu.
  • Emoji picker: scoped em-emoji-picker styles to .bn-root, removed stale z-index: 11000.

Impact

  • Breaking (CSS): Users customizing themes via .bn-container[data-color-scheme] selectors need to update to .bn-root[data-color-scheme]. .bn-container still exists but is for layout only.
  • All floating UI elements now escape overflow: hidden ancestors.
  • No changes to the public JS/React API.

Testing

  • Manual testing with editor inside overflow: hidden containers.
  • TypeScript checks pass for all packages (react, mantine, shadcn, ariakit).

Checklist

  • Code follows the project's coding standards.
  • Unit tests covering the new feature have been added.
  • All existing tests pass.
  • The documentation has been updated to reflect the new feature

Additional Notes

  • The multi-editor example was updated to pass explicit theme props to verify theming works correctly on the portal container.
  • The SideMenu .closest(".bn-root") check is broader than the original editor-wrapper containment check, but mouseOverEditor bounding-box guard mitigates multi-editor edge cases.

Summary by CodeRabbit

  • New Features

    • Added explicit light/dark theme support for editors
    • Implemented improved portal rendering for floating UI elements (popovers, menus, emoji picker) with better z-index handling
  • Bug Fixes

    • Fixed emoji picker to display actions when the picker is open
  • Refactor

    • Updated theming CSS scope structure for better element targeting

… clipping

Floating UI elements (menus, toolbars, emoji picker) are now portaled to a
dedicated container at document.body, preventing them from being clipped by
overflow:hidden ancestors.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 24, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
blocknote Error Error Apr 7, 2026 5:22pm
blocknote-website Error Error Apr 7, 2026 5:22pm

Request Review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 24, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR implements a portal system for floating UI elements to escape parent overflow constraints. CSS selectors migrate from .bn-container to .bn-root, DOM structure gains a bn-root wrapper class, and popover/menu components gain portalRoot prop support to render floating content outside the editor's DOM hierarchy.

Changes

Cohort / File(s) Summary
Documentation & Examples
docs/content/docs/react/styling-theming/themes.mdx, examples/04-theming/02-changing-font/src/styles.css, examples/04-theming/03-theming-css/src/styles.css, examples/04-theming/04-theming-css-variables/src/styles.css
CSS selector updates from .bn-container[data-*] to .bn-root[data-*] for theme variable scoping and styling rules.
Static HTML Render Examples
examples/02-backend/04-rendering-static-documents/src/App.tsx, examples/05-interoperability/09-blocks-to-html-static-render/src/App.tsx, examples/05-interoperability/10-static-html-render/src/App.tsx
Wrapper <div> elements updated to include bn-root CSS class alongside existing bn-container and theme attributes.
Multi-Editor Example
examples/01-basic/12-multi-editor/src/App.tsx
Editor component extended to accept required theme prop ("dark" | "light"), passed to BlockNoteView; both editor instances supplied with distinct theme values.
Core Portal & Context System
packages/core/src/editor/BlockNoteEditor.ts, packages/react/src/editor/BlockNoteContext.ts, packages/react/src/editor/ComponentsContext.tsx
New portalElement property, isWithinEditor() method added to editor; portalRoot field added to context types; portalRoot prop added to Popover.Root component props.
BlockNoteView Portal Implementation
packages/react/src/editor/BlockNoteView.tsx
Portal container created via createPortal() into document.body with bn-root and theming classes; portalRoot state synced with editor and context; wrapper hierarchy restructured with bn-root class.
Generic Popover Portal Support
packages/react/src/components/Popovers/GenericPopover.tsx
Portal root read from context and used to wrap popover in FloatingPortal; z-index fallback added for missing --bn-ui-base-z-index variable.
Framework-Specific Popovers
packages/ariakit/src/popover/Popover.tsx, packages/mantine/src/popover/Popover.tsx, packages/shadcn/src/popover/popover.tsx
portalRoot prop support added; conditional portal rendering/positioning logic integrated; z-index handling adjusted when portaled.
Emoji Picker & Comment Components
packages/react/src/components/Comments/EmojiPicker.tsx, packages/ariakit/src/comments/Comment.tsx, packages/shadcn/src/comments/Comment.tsx
useBlockNoteEditor() dependency removed from EmojiPicker; emojiPickerOpen now affects action visibility; portal support integrated via context.
Editor Styling & Utilities
packages/core/src/editor/editor.css, packages/core/src/extensions/SideMenu/SideMenu.ts, packages/core/src/extensions/tiptap-extensions/UniqueID/UniqueID.ts, packages/core/src/editor/managers/ExtensionManager/extensions.ts
.bn-root border-box rule removed; side menu hover detection replaced with isWithinEditor() method; UniqueID extension updated to use isWithinEditor for drag detection.
React & Mantine Styling
packages/react/src/editor/styles.css, packages/mantine/src/BlockNoteView.tsx, playground/src/style.css
Primary theme selectors migrated to .bn-root; emoji picker rule scoped to .bn-root; z-index variable base set in playground; Mantine theme variables applied to portal element.

Sequence Diagram

sequenceDiagram
    participant User
    participant Editor as BlockNoteView
    participant Portal as Portal Container
    participant Floating as FloatingUI<br/>(Popover/Menu)
    participant DOM as Document.body

    User->>Editor: Interact (type `/`, select text, etc.)
    Editor->>Editor: Initialize portalRoot element
    Editor->>Portal: createPortal() with bn-root class
    Portal->>DOM: Mount separate portal container
    
    User->>Editor: Trigger floating UI (menu, toolbar)
    Editor->>Floating: Request floating content
    Floating->>Portal: Query portalRoot from context
    Portal-->>Floating: Return portalRoot element
    
    Floating->>Floating: Calculate position<br/>(escapes overflow constraints)
    Floating->>DOM: Render in portal container<br/>(outside editor subtree)
    
    Note over Portal,DOM: Floating UI escapes parent overflow<br/>and renders above editor hierarchy
    
    Floating-->>User: Display menu/toolbar unclipped
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested reviewers

  • nperez0111

Poem

🐰 Portal paths now freed from clipping chains,
Floating menus dance beyond their veins,
Root-scoped realms guide shadows right,
No overflow shall dim their light!
BlockNote floats with newfound grace,

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely describes the main fix: portaling floating UI elements to document.body to prevent overflow clipping, with the relevant issue number (BLO-1115).
Description check ✅ Passed The PR description comprehensively covers Summary, Rationale, Changes, Impact, Testing, and Checklist sections matching the template structure with detailed explanations of portal infrastructure, CSS architecture, and implementation details.
Linked Issues check ✅ Passed The PR successfully addresses all linked issue requirements: slash menu (#2543), drag-handle menu (#2544), FormattingToolbar (#2558), and emoji picker (#2578) are now portaled to document.body, escaping overflow:hidden ancestors and remaining fully visible.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing portal infrastructure for floating UI elements and refactoring CSS architecture from .bn-container to .bn-root for theming, with minor fixes to emojiPickerOpen and SideMenu hover logic that support the portal feature.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/portal-floating-ui-overflow-clipping

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 24, 2026

Open in StackBlitz

@blocknote/ariakit

npm i https://pkg.pr.new/@blocknote/ariakit@2591

@blocknote/code-block

npm i https://pkg.pr.new/@blocknote/code-block@2591

@blocknote/core

npm i https://pkg.pr.new/@blocknote/core@2591

@blocknote/mantine

npm i https://pkg.pr.new/@blocknote/mantine@2591

@blocknote/react

npm i https://pkg.pr.new/@blocknote/react@2591

@blocknote/server-util

npm i https://pkg.pr.new/@blocknote/server-util@2591

@blocknote/shadcn

npm i https://pkg.pr.new/@blocknote/shadcn@2591

@blocknote/xl-ai

npm i https://pkg.pr.new/@blocknote/xl-ai@2591

@blocknote/xl-docx-exporter

npm i https://pkg.pr.new/@blocknote/xl-docx-exporter@2591

@blocknote/xl-email-exporter

npm i https://pkg.pr.new/@blocknote/xl-email-exporter@2591

@blocknote/xl-multi-column

npm i https://pkg.pr.new/@blocknote/xl-multi-column@2591

@blocknote/xl-odt-exporter

npm i https://pkg.pr.new/@blocknote/xl-odt-exporter@2591

@blocknote/xl-pdf-exporter

npm i https://pkg.pr.new/@blocknote/xl-pdf-exporter@2591

commit: 6550e46

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/core/src/extensions/SideMenu/SideMenu.ts (1)

615-626: ⚠️ Potential issue | 🟡 Minor

Scope the .bn-root exemption to the current editor instance.

closest(".bn-root") now treats any BlockNote root as local UI. On multi-editor pages, a portaled toolbar from editor B hovering over editor A can keep editor A's side menu active because the target still has a .bn-root ancestor. This check needs an instance-scoped marker for the current editor/root pair, not a global class match.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/core/src/extensions/SideMenu/SideMenu.ts` around lines 615 - 626,
The current check uses (event.target as HTMLElement).closest(".bn-root") which
matches any editor root on the page; change it to an instance-scoped check so
only the current editor/root keeps the side menu active. Use the SideMenu
instance's root reference (e.g. this.rootElement or this.editorRoot) and replace
the global closest(".bn-root") logic with an instance-scoped test such as using
this.rootElement.contains(event.target as Node) or matching a unique root
identifier/data-attribute (e.g. data-bn-root-id === this.rootId) on ancestor
lookup; update the closestBNRoot usage and the conditional that references
cursorWithinEditor so it only treats elements within this specific editor root
as local UI.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/mantine/src/popover/Popover.tsx`:
- Line 26: Popover.tsx currently clears zIndex when portalRoot exists which lets
Mantine's default z-index win; change the zIndex logic to use the CSS variable
fallback pattern from GenericPopover.tsx by assigning zIndex to the CSS variable
--bn-ui-base-z-index with a 10000 fallback when portalRoot is truthy (and keep
10000 when not portaled) so portaled popovers respect the app's base z-index
system; update the zIndex prop usage in the Popover component (the zIndex prop
set at the repository diff line) accordingly to use the
var(--bn-ui-base-z-index, 10000) approach.

In `@packages/react/src/editor/BlockNoteView.tsx`:
- Around line 216-227: The portaled root created by createPortal currently only
forwards className and data-color-scheme, causing loss of important attributes
from the editor container; update the BlockNoteView portaling logic (the element
created where setPortalRoot is used) to derive and forward explicit props from
...rest (e.g., dir, any data-* attributes used for theming like data-theming-*,
data-mantine-color-scheme, and inline style/CSS variable overrides) instead of
relying on className alone, and avoid forwarding layout-only classes by either
whitelisting these attributes or picking them out from rest (preserve
editorColorScheme and className via mergeCSSClasses, but also copy dir, style,
and any data-* theming attributes into the portaled div so floating UI retains
same theme/context as the editor).

---

Outside diff comments:
In `@packages/core/src/extensions/SideMenu/SideMenu.ts`:
- Around line 615-626: The current check uses (event.target as
HTMLElement).closest(".bn-root") which matches any editor root on the page;
change it to an instance-scoped check so only the current editor/root keeps the
side menu active. Use the SideMenu instance's root reference (e.g.
this.rootElement or this.editorRoot) and replace the global closest(".bn-root")
logic with an instance-scoped test such as using
this.rootElement.contains(event.target as Node) or matching a unique root
identifier/data-attribute (e.g. data-bn-root-id === this.rootId) on ancestor
lookup; update the closestBNRoot usage and the conditional that references
cursorWithinEditor so it only treats elements within this specific editor root
as local UI.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c2308343-2a71-42d8-9eee-c0b1fdf5b028

📥 Commits

Reviewing files that changed from the base of the PR and between af3737a and 50ebffb.

📒 Files selected for processing (21)
  • docs/content/docs/react/styling-theming/themes.mdx
  • examples/01-basic/12-multi-editor/src/App.tsx
  • examples/02-backend/04-rendering-static-documents/src/App.tsx
  • examples/04-theming/02-changing-font/src/styles.css
  • examples/04-theming/03-theming-css/src/styles.css
  • examples/04-theming/04-theming-css-variables/src/styles.css
  • examples/05-interoperability/09-blocks-to-html-static-render/src/App.tsx
  • packages/ariakit/src/comments/Comment.tsx
  • packages/ariakit/src/popover/Popover.tsx
  • packages/core/src/editor/editor.css
  • packages/core/src/extensions/SideMenu/SideMenu.ts
  • packages/mantine/src/popover/Popover.tsx
  • packages/react/src/components/Comments/EmojiPicker.tsx
  • packages/react/src/components/Popovers/GenericPopover.tsx
  • packages/react/src/editor/BlockNoteContext.ts
  • packages/react/src/editor/BlockNoteView.tsx
  • packages/react/src/editor/ComponentsContext.tsx
  • packages/react/src/editor/styles.css
  • packages/shadcn/src/comments/Comment.tsx
  • packages/shadcn/src/popover/popover.tsx
  • playground/src/style.css
💤 Files with no reviewable changes (1)
  • packages/core/src/editor/editor.css

onChange={onOpenChange}
position={position}
zIndex={10000}
zIndex={portalRoot ? undefined : 10000}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify z-index handling for Mantine popovers and bn root variable wiring.

# 1) Confirm changed Mantine popover behavior in this file.
rg -nP -C3 'withinPortal=|portalProps=|zIndex=' packages/mantine/src/popover/Popover.tsx

# 2) Check whether bn-ui-base-z-index is consumed anywhere in styling/code.
rg -nP -C2 'bn-ui-base-z-index|--bn-ui-base-z-index|z-index' packages/react packages/mantine

# 3) Inspect portal root creation context for explicit stacking styles.
rg -nP -C8 'createPortal\\(|className=|data-color-scheme|bn-root' packages/react/src/editor/BlockNoteView.tsx

Repository: TypeCellOS/BlockNote

Length of output: 2993


🏁 Script executed:

# Find the portal root creation and its styling context
rg -nA15 -B5 'createPortal|portalRoot.*=' packages/react/src/editor/BlockNoteView.tsx | head -100

Repository: TypeCellOS/BlockNote

Length of output: 2391


🏁 Script executed:

# Check if there's z-index styling on the portal root or bn-root specifically
rg -nP 'bn-root|portal.*root' packages/react/src/editor/styles.css packages/react/src/editor/BlockNoteView.tsx -A3 -B3

Repository: TypeCellOS/BlockNote

Length of output: 4550


🏁 Script executed:

# Check Mantine Popover implementation or types to see how zIndex behaves
fd -t f -e tsx -e ts packages/mantine | xargs grep -l "zIndex" | head -5

Repository: TypeCellOS/BlockNote

Length of output: 433


🏁 Script executed:

# Find Mantine Popover files
fd -t f --full-path 'packages/mantine' -e tsx -e ts | head -20

Repository: TypeCellOS/BlockNote

Length of output: 856


🏁 Script executed:

# Check if there's any handling of zIndex prop for Popover in Mantine code
rg -n 'zIndex|z-index' packages/mantine/src/popover/ -B3 -A3

Repository: TypeCellOS/BlockNote

Length of output: 538


🏁 Script executed:

# Verify what Mantine's default z-index behavior is for portaled popovers
rg -n 'const.*Popover|export.*Popover' packages/mantine/src/popover/ -A20 | head -80

Repository: TypeCellOS/BlockNote

Length of output: 3408


Portaled popovers lose stacking control with Mantine's default z-index.

At line 26, zIndex is set to undefined when portalRoot exists. The portal root (created in packages/react/src/editor/BlockNoteView.tsx:217-226) has no explicit z-index styling and inherits --bn-ui-base-z-index: 0 from the .bn-root CSS rule. This causes Mantine to apply its default z-index (300 via CSS variables), which can fall behind fixed/sticky app chrome without explicit stacking control.

The fix aligns with the established pattern used in GenericPopover.tsx and other UI components, which leverage the --bn-ui-base-z-index CSS variable for consistent z-index management:

Proposed fix
-      zIndex={portalRoot ? undefined : 10000}
+      zIndex={portalRoot ? "var(--bn-ui-base-z-index, 10000)" : 10000}

This ensures portaled popovers respect the base z-index system while maintaining fallback to 10000 when the variable is not defined.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
zIndex={portalRoot ? undefined : 10000}
zIndex={portalRoot ? "var(--bn-ui-base-z-index, 10000)" : 10000}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/mantine/src/popover/Popover.tsx` at line 26, Popover.tsx currently
clears zIndex when portalRoot exists which lets Mantine's default z-index win;
change the zIndex logic to use the CSS variable fallback pattern from
GenericPopover.tsx by assigning zIndex to the CSS variable --bn-ui-base-z-index
with a 10000 fallback when portalRoot is truthy (and keep 10000 when not
portaled) so portaled popovers respect the app's base z-index system; update the
zIndex prop usage in the Popover component (the zIndex prop set at the
repository diff line) accordingly to use the var(--bn-ui-base-z-index, 10000)
approach.

Comment on lines +216 to +227
{createPortal(
<div
ref={setPortalRoot}
className={mergeCSSClasses(
"bn-root",
editorColorScheme,
className,
)}
data-color-scheme={editorColorScheme}
/>,
document.body,
)}
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 24, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

The portal root needs the same theme/context attrs as the editor root.

Only className and data-color-scheme are mirrored here. The editor container at Lines 207-213 still owns ...rest, so portaled UI loses things like dir, custom data-*, and inline CSS variable overrides. That means the new .bn-root[data-theming-*] demos and Mantine's data-mantine-color-scheme passthrough in packages/mantine/src/BlockNoteView.tsx:82-107 will not fully apply to floating UI. className alone is also not a safe proxy here, because layout classes can leak onto the body-level portal container. Please derive explicit portal-root props from ...rest instead of relying on className alone.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/src/editor/BlockNoteView.tsx` around lines 216 - 227, The
portaled root created by createPortal currently only forwards className and
data-color-scheme, causing loss of important attributes from the editor
container; update the BlockNoteView portaling logic (the element created where
setPortalRoot is used) to derive and forward explicit props from ...rest (e.g.,
dir, any data-* attributes used for theming like data-theming-*,
data-mantine-color-scheme, and inline style/CSS variable overrides) instead of
relying on className alone, and avoid forwarding layout-only classes by either
whitelisting these attributes or picking them out from rest (preserve
editorColorScheme and className via mergeCSSClasses, but also copy dir, style,
and any data-* theming attributes into the portaled div so floating UI retains
same theme/context as the editor).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yep, I think this is a fundamental problem in this approach, it being portaled outside of the editor's control does have some serious drawbacks and makes styling more complicated. I think we should re-visit this approach

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@nperez0111 Agreed — portaling to document.body creates a structural disconnect that's hard to paper over. A few alternative directions worth considering when revisiting:

  1. position: fixed on the floating elements themselves — instead of portaling the entire UI subtree, apply position: fixed (and a CSS transform: none reset on ancestors if needed) to individual menus/toolbars. They still live inside the React/DOM tree so theming, dir, data-* attrs, and context all flow naturally, and they escape overflow: hidden automatically. The main caveat is that position: fixed is relative to the viewport, not the scroll container, which @floating-ui already handles well via its strategy: "fixed" option.

  2. Selective attribute mirroring with a whitelist — if the portal approach is kept, rather than copying className wholesale (which leaks layout classes), explicitly mirror only theming-relevant attributes (data-color-scheme, data-mantine-color-scheme, dir, style for CSS variable overrides, and specific data-* keys known to carry theme tokens). This is fragile long-term as integrations grow but could be a near-term patch.

  3. Portal to a sibling element outside the clipping ancestor — instead of document.body, mount the portal container as a sibling above the overflow: hidden ancestor in the DOM. The editor could walk closestAncestor that has overflow: hidden and insert a sibling there. More invasive but stays closer to the editor in the DOM tree.

Option 1 is probably the least invasive and most robust since @floating-ui already supports it natively — would be worth prototyping before committing to the portal container architecture.

ʕ •ᴥ•ʔ

@YousefED YousefED requested a review from nperez0111 March 30, 2026 08:48
@YousefED YousefED changed the title fix: portal floating UI elements to document.body to prevent overflow clipping fix: portal floating UI elements to document.body to prevent overflow clipping BLO-1115 Mar 30, 2026
Copy link
Copy Markdown
Contributor

@nperez0111 nperez0111 left a comment

Choose a reason for hiding this comment

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

Definitely not comfortable with this approach. I agree it is worth solving, but as a lib I don't think we have this luxury

Comment on lines +156 to +167
const [portalRoot, setPortalRoot] = useState<HTMLDivElement | null>(null);

// Register the portal element on the editor so core extensions (SideMenu,
// UniqueID) can identify portaled elements as belonging to this editor.
// (through editor.isWithinEditor)
useEffect(() => {
editor.portalElement = portalRoot ?? undefined;
return () => {
editor.portalElement = undefined;
};
}, [portalRoot, editor]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nope, we got to find another way around this. This is a mess.

className={mergeCSSClasses("bn-root", editorColorScheme, className)}
data-color-scheme={editorColorScheme}
/>,
document.body,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I'd prefer this to not be hard-coded to just add elements into the document.body, we should allow an option to have a selector/callback to pick where to place this. We are a library not an app

Comment on lines +216 to +227
{createPortal(
<div
ref={setPortalRoot}
className={mergeCSSClasses(
"bn-root",
editorColorScheme,
className,
)}
data-color-scheme={editorColorScheme}
/>,
document.body,
)}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Yep, I think this is a fundamental problem in this approach, it being portaled outside of the editor's control does have some serious drawbacks and makes styling more complicated. I think we should re-visit this approach

@YousefED
Copy link
Copy Markdown
Collaborator Author

Definitely not comfortable with this approach. I agree it is worth solving, but as a lib I don't think we have this luxury

Fair, ideally you wouldn't add sth to document.root as a library. Do you think it needs a user-facing API, or would you take a completely different approach?

Note that @ https://floating-ui.com/docs/misc#clipping, they mention a portal is the only reliable solution to fix the clipping problem. It's also similar to what other libraries like base-ui etc do by default, if I'm not mistaken

@nperez0111
Copy link
Copy Markdown
Contributor

Fair, ideally you wouldn't add sth to document.root as a library. Do you think it needs a user-facing API, or would you take a completely different approach?

Unsure exactly how I'd go about it, I didn't review the existing issues to know where the current limitations are, to know the right problem to solve.

Note that @ floating-ui.com/docs/misc#clipping, they mention a portal is the only reliable solution to fix the clipping problem. It's also similar to what other libraries like base-ui etc do by default, if I'm not mistaken

Yea, I think we should give the fixed positioning strategy a try, which they also mention:

Using the 'fixed' strategy will “break” the floating element out of its parent clipping context in the majority of cases. This allows you to keep the floating element’s DOM context in tact. This is not 100% guaranteed though — portalling is the only 100% reliable method.

I think base-ui can get away with it because it is unstyled, whereas I think we will run into some synchronization of styles issues between something within the portal and something within the editor. I'd rather not have to make that distinction if we can get away with it. We would be fighting against the Cascade in CSS with this approach and IME, you can win the battle, but not the war with this.

@YousefED
Copy link
Copy Markdown
Collaborator Author

fyi, this is what claude gives when asking for downsides of "fixed" (didn't validate all if this):

The Misc page doesn't go into the downsides directly — let me grab the computePosition docs where strategy is documented more fully.The Floating UI docs don't give a dedicated "downsides" list, but here's what they state and what follows from it:

Explicitly from the docs:

  • It's not 100% guaranteed to break out of clipping — portalling is the only fully reliable method.
  • 'absolute' requires the browser to do the least work when updating position, implying fixed carries more layout/repaint cost.

What that means in practice (CSS-level consequences):

position: fixed is broken by any ancestor that creates a new containing block, which includes:

  • transform (any value other than none) — the most common gotcha
  • filter (any value other than none)
  • perspective
  • will-change: transform / will-change: filter
  • contain: layout, contain: paint, or contain: strict
  • backdrop-filter (in some browsers)

So if your floating element lives inside — or gets portalled into — a subtree with any of those CSS properties on an ancestor, fixed behaves like absolute relative to that ancestor, not the viewport. This is a very common surprise in component libraries and animation-heavy UIs.

Also relevant for BlockNote specifically: if you have a scrollable editor container, fixed elements stay viewport-anchored while the reference element scrolls, which means autoUpdate must run on scroll events to keep coordinates in sync — more overhead than absolute which naturally follows the scroll.


let's review later 👍

@YousefED
Copy link
Copy Markdown
Collaborator Author

YousefED commented Apr 7, 2026

We've decided to continue with this, after confirming that:

  • we need to support overflow-constraint layouts (for example, when the editor is in a sidebar)
  • strategy: fixed results in janky positioning on scroll

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/mantine/src/BlockNoteView.tsx (1)

41-41: ⚠️ Potential issue | 🟠 Major

Compose the incoming portalRef instead of overwriting it.

The rest object (destructured at line 41) can contain a consumer-supplied portalRef, but it gets replaced unconditionally at line 100. This pattern differs from packages/react/src/editor/BlockNoteView.tsx (lines 163-174), which explicitly destructures portalRef and merges it with the internal callback using mergeRefs. Destructure portalRef from props, merge it with the Mantine-specific callback, and pass the merged ref instead.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/mantine/src/BlockNoteView.tsx` at line 41, The code overwrites a
consumer-supplied portalRef because props are destructured into rest (const {
className, theme, ...rest } = props) and then the component assigns its own
portal ref unconditionally; instead, destructure portalRef from props (const {
className, theme, portalRef, ...rest } = props), import/use mergeRefs, compose
the consumer portalRef with the component's internal Mantine portal ref callback
via mergeRefs(consumerPortalRef, internalPortalRefCallback) and pass that merged
ref where the component currently assigns its own portal ref so the consumer ref
is preserved and called as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/react/src/editor/BlockNoteView.tsx`:
- Around line 235-242: The createPortal call in BlockNoteView currently
dereferences document.body unconditionally (using mergedPortalRef and
mergeCSSClasses), which breaks SSR; wrap the portal rendering in a conditional
that checks typeof document !== "undefined" (and optionally that document.body
exists) and only call createPortal when true, otherwise render null or the
component's existing nullable portal state so the component doesn't access
document during server rendering.

---

Outside diff comments:
In `@packages/mantine/src/BlockNoteView.tsx`:
- Line 41: The code overwrites a consumer-supplied portalRef because props are
destructured into rest (const { className, theme, ...rest } = props) and then
the component assigns its own portal ref unconditionally; instead, destructure
portalRef from props (const { className, theme, portalRef, ...rest } = props),
import/use mergeRefs, compose the consumer portalRef with the component's
internal Mantine portal ref callback via mergeRefs(consumerPortalRef,
internalPortalRefCallback) and pass that merged ref where the component
currently assigns its own portal ref so the consumer ref is preserved and called
as well.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 53880307-20d0-4b11-b649-892b9ee5da4e

📥 Commits

Reviewing files that changed from the base of the PR and between 1358d89 and 5756910.

📒 Files selected for processing (2)
  • packages/mantine/src/BlockNoteView.tsx
  • packages/react/src/editor/BlockNoteView.tsx

Comment on lines +235 to +242
{createPortal(
<div
ref={mergedPortalRef}
className={mergeCSSClasses("bn-root", editorColorScheme, className)}
data-color-scheme={editorColorScheme}
/>,
document.body,
)}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find and read the BlockNoteView.tsx file
fd -t f "BlockNoteView.tsx" --path "*/react/src/editor*"

Repository: TypeCellOS/BlockNote

Length of output: 297


🏁 Script executed:

# Get the file and check its size
wc -l packages/react/src/editor/BlockNoteView.tsx

Repository: TypeCellOS/BlockNote

Length of output: 111


🏁 Script executed:

# Read lines around 235-242 to see the context
sed -n '220,260p' packages/react/src/editor/BlockNoteView.tsx

Repository: TypeCellOS/BlockNote

Length of output: 1218


🏁 Script executed:

# Also check if there are any existing document checks or SSR guards in the file
rg "typeof document|document\?" packages/react/src/editor/BlockNoteView.tsx

Repository: TypeCellOS/BlockNote

Length of output: 46


🏁 Script executed:

# Check if createPortal is imported and understand its behavior
head -50 packages/react/src/editor/BlockNoteView.tsx | grep -E "import|createPortal"

Repository: TypeCellOS/BlockNote

Length of output: 641


🏁 Script executed:

# Read the full component function to understand context
sed -n '1,260p' packages/react/src/editor/BlockNoteView.tsx | head -250

Repository: TypeCellOS/BlockNote

Length of output: 7209


🏁 Script executed:

# Search for useEffect, useState, or other hooks that might indicate hydration awareness
rg "useEffect|useState|useLayoutEffect|useRef\(" packages/react/src/editor/BlockNoteView.tsx -A 2

Repository: TypeCellOS/BlockNote

Length of output: 380


🏁 Script executed:

# Check if this component or repo explicitly supports SSR
rg -i "ssr|server|hydrate" packages/react/src/editor/BlockNoteView.tsx

Repository: TypeCellOS/BlockNote

Length of output: 46


🏁 Script executed:

# Check package.json or README for SSR mention
find . -maxdepth 3 -name "package.json" -o -name "README.md" | head -5

Repository: TypeCellOS/BlockNote

Length of output: 148


🏁 Script executed:

# Search for patterns of document.body usage elsewhere in react/src
rg "document\.body" packages/react/src --count

Repository: TypeCellOS/BlockNote

Length of output: 257


🌐 Web query:

react createPortal null container SSR server-side rendering

💡 Result:

createPortal(children, container) requires container to be a real DOM Element. If you pass null (or undefined), React will throw “Target container is not a DOM element.”—including during SSR if your code tries to look up the container on the server. [1][2]

SSR-safe pattern

Don’t call createPortal until you’re on the client and you’ve resolved the container:

import { createPortal } from "react-dom";
import { useEffect, useState } from "react";

function ModalPortal({ children }: { children: React.ReactNode }) {
  const [container, setContainer] = useState<Element | null>(null);

  useEffect(() => {
    setContainer(document.getElementById("modal-root")); // or document.body
  }, []);

  if (!container) return null;          // SSR + first client render
  return createPortal(children, container);
}

This avoids SSR crashes (document doesn’t exist on the server) and avoids passing null as the portal target. [1][2]

If you’re seeing hydration/mismatch issues

Many UI libs recommend rendering portals only on the client for SSR setups to avoid mismatches. [3][4]

Sources: React createPortal docs [1], common Next.js/SSR portal error + fix discussion [2], Chakra UI SSR note for portals [3], Mantine note that createPortal isn’t supported during SSR [4].


🏁 Script executed:

# Check the other files using document.body to see if they have guards
rg "document\.body" packages/react/src -B 2 -A 2

Repository: TypeCellOS/BlockNote

Length of output: 2164


🏁 Script executed:

# Check if there's any hydration or SSR documentation in the repo
rg -i "hydrate|ssr|server" packages/react --type md

Repository: TypeCellOS/BlockNote

Length of output: 46


🏁 Script executed:

# Check the main package.json and README for SSR support claims
head -50 package.json

Repository: TypeCellOS/BlockNote

Length of output: 1770


🏁 Script executed:

# Check for "browser" or "react" field in package.json
rg '"browser"|"react"|"main"' package.json

Repository: TypeCellOS/BlockNote

Length of output: 46


🏁 Script executed:

# Check if there's Next.js or SSR examples
find ./examples -name "*.tsx" -o -name "*.ts" | head -10

Repository: TypeCellOS/BlockNote

Length of output: 530


🏁 Script executed:

# Check the react package.json
cat packages/react/package.json | grep -E '"main"|"module"|"browser"|"exports"' -A 2

Repository: TypeCellOS/BlockNote

Length of output: 296


🏁 Script executed:

# Check for "use client" directives which would indicate client-only
rg '"use client"' packages/react/src/editor/BlockNoteView.tsx

Repository: TypeCellOS/BlockNote

Length of output: 46


Guard document.body access during SSR. The code unconditionally dereferences document.body in the createPortal call (lines 235–242), which throws immediately in server environments because document is undefined. Only create the portal after the DOM is available. Apply a typeof document !== "undefined" check and conditionally render, since the component already handles nullable portal state:

Suggested fix
+  const portalHost =
+    typeof document !== "undefined" ? document.body : null;
+
   return (
     <BlockNoteContext.Provider value={blockNoteContext}>
       <BlockNoteViewContext.Provider value={blockNoteViewContextValue}>
         <ElementRenderer ref={setElementRenderer} />
         <BlockNoteViewContainer
@@
         >
           {children}
         </BlockNoteViewContainer>
-        {createPortal(
-          <div
-            ref={mergedPortalRef}
-            className={mergeCSSClasses("bn-root", editorColorScheme, className)}
-            data-color-scheme={editorColorScheme}
-          />,
-          document.body,
-        )}
+        {portalHost &&
+          createPortal(
+            <div
+              ref={mergedPortalRef}
+              className={mergeCSSClasses("bn-root", editorColorScheme, className)}
+              data-color-scheme={editorColorScheme}
+            />,
+            portalHost,
+          )}
       </BlockNoteViewContext.Provider>
     </BlockNoteContext.Provider>
   );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{createPortal(
<div
ref={mergedPortalRef}
className={mergeCSSClasses("bn-root", editorColorScheme, className)}
data-color-scheme={editorColorScheme}
/>,
document.body,
)}
const portalHost =
typeof document !== "undefined" ? document.body : null;
return (
<BlockNoteContext.Provider value={blockNoteContext}>
<BlockNoteViewContext.Provider value={blockNoteViewContextValue}>
<ElementRenderer ref={setElementRenderer} />
<BlockNoteViewContainer
// ... props
>
{children}
</BlockNoteViewContainer>
{portalHost &&
createPortal(
<div
ref={mergedPortalRef}
className={mergeCSSClasses("bn-root", editorColorScheme, className)}
data-color-scheme={editorColorScheme}
/>,
portalHost,
)}
</BlockNoteViewContext.Provider>
</BlockNoteContext.Provider>
);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react/src/editor/BlockNoteView.tsx` around lines 235 - 242, The
createPortal call in BlockNoteView currently dereferences document.body
unconditionally (using mergedPortalRef and mergeCSSClasses), which breaks SSR;
wrap the portal rendering in a conditional that checks typeof document !==
"undefined" (and optionally that document.body exists) and only call
createPortal when true, otherwise render null or the component's existing
nullable portal state so the component doesn't access document during server
rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants