Skip to content

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

Open
YousefED wants to merge 1 commit intomainfrom
fix/portal-floating-ui-overflow-clipping
Open

fix: portal floating UI elements to document.body to prevent overflow clipping#2591
YousefED wants to merge 1 commit intomainfrom
fix/portal-floating-ui-overflow-clipping

Conversation

@YousefED
Copy link
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 portalRoot support for custom portal containers, enabling floating UI elements to render outside ancestor overflow constraints.
    • Added theme prop to editor instances for explicit light/dark mode control.
  • Bug Fixes

    • Fixed emoji picker action visibility when picker is open.
  • Documentation

    • Updated theming CSS selector guidance to use root-level scoping instead of container-based selectors.

… 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 <[email protected]>
@vercel
Copy link

vercel bot commented Mar 24, 2026

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

Project Deployment Actions Updated (UTC)
blocknote Ready Ready Preview Mar 24, 2026 6:02pm
blocknote-website Ready Ready Preview Mar 24, 2026 6:02pm

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Mar 24, 2026

📝 Walkthrough

Walkthrough

This PR introduces a portal root infrastructure to render floating UI elements (menus, toolbars, popovers) outside the editor container, preventing clipping when the editor has overflow constraints. Concurrently, CSS selectors are shifted from .bn-container to .bn-root for theme scoping and styling.

Changes

Cohort / File(s) Summary
Documentation & Example CSS Selector Updates
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
Updated CSS selectors from .bn-container to .bn-root for theme variable scoping and theming demo styling.
Example DOM Class Updates
examples/02-backend/04-rendering-static-documents/src/App.tsx, examples/05-interoperability/09-blocks-to-html-static-render/src/App.tsx
Added bn-root CSS class alongside existing bn-container on wrapper divs.
Multi-Editor Example
examples/01-basic/12-multi-editor/src/App.tsx
Extended Editor component props to require theme: "dark" | "light" and pass it through to BlockNoteView; updated both editor instances with explicit theme values.
Portal Root Context Infrastructure
packages/react/src/editor/BlockNoteContext.ts, packages/react/src/editor/BlockNoteView.tsx, packages/react/src/editor/ComponentsContext.tsx
Added portalRoot optional property to BlockNoteContextValue; created portal root div rendered to document.body in BlockNoteView and exposed via context; extended ComponentProps.Generic.Popover.Root type with optional portalRoot prop.
Core CSS Refactoring
packages/core/src/editor/editor.css
Removed .bn-root box-sizing CSS rule and its descendant selectors.
React Component Styling
packages/react/src/editor/styles.css
Shifted theme scoping from .bn-container to .bn-root; updated emoji picker selector to scoped .bn-root em-emoji-picker and removed hardcoded z-index; changed BlockNoteViewContainer wrapper class to include both bn-root and bn-container.
Ariakit Popover Portal Support
packages/ariakit/src/popover/Popover.tsx
Added PortalRootContext to propagate portalRoot from Popover root to PopoverContent; updated PopoverContent to forward portalElement={portalRoot ?? undefined} to AriakitPopover.
Mantine Popover Portal Support
packages/mantine/src/popover/Popover.tsx
Extended Popover to accept portalRoot prop; conditionally set withinPortal, portalProps, and zIndex based on portalRoot presence.
Shadcn Popover Portal Support
packages/shadcn/src/popover/popover.tsx
Added PortalRootContext and portalRoot prop handling; PopoverContent now conditionally renders via createPortal(content, portalRoot) when portalRoot is non-null; removed z-[10000] class when portaled.
Generic Popover Portal Integration
packages/react/src/components/Popovers/GenericPopover.tsx
Updated to read portalRoot from useBlockNoteContext() and render popover DOM in FloatingPortal using that root; added fallback for --bn-ui-base-z-index CSS variable.
EmojiPicker Portal Update
packages/react/src/components/Comments/EmojiPicker.tsx
Removed manual createPortal() logic and useBlockNoteEditor() dependency; updated to pass portalRoot={blockNoteContext?.portalRoot} to Components.Generic.Popover.Root.
Side Menu Hover Logic
packages/core/src/extensions/SideMenu/SideMenu.ts
Replaced editorWrapper parentElement containment check with .bn-root closest ancestor lookup for menu visibility during cursor movement.
Comment Component Emoji Picker Visibility
packages/ariakit/src/comments/Comment.tsx, packages/shadcn/src/comments/Comment.tsx
Updated doShowActions calculation to include emojiPickerOpen as a condition for rendering action UI.
Playground Styling
playground/src/style.css
Added .bn-root CSS rule defining --bn-ui-base-z-index: 100.

Sequence Diagram

sequenceDiagram
    participant User
    participant Editor as BlockNoteView
    participant Context as BlockNoteContext
    participant Popover as PopoverComponent
    participant Portal as FloatingPortal
    participant DOM as document.body

    User->>Editor: Render editor
    Editor->>DOM: createPortal(portalRootDiv)
    Editor->>Context: Provide portalRoot in context
    
    User->>Editor: Trigger floating UI (select text)
    Editor->>Popover: Render Popover with context
    Popover->>Context: Read portalRoot
    Popover->>Portal: Render in FloatingPortal(portalRoot)
    Portal->>DOM: Render floating UI outside editor
    
    User->>Editor: Scroll/resize editor
    Portal->>DOM: Floating UI updates via Floating UI
    DOM->>User: Toolbar/menu visible & not clipped
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • nperez0111

Poem

🐰 A portal to the sky, we did create,
So menus dance where none can clip or gate,
The root ascends, the container takes its place,
Floating UI finds its proper space!
A happy rabbit, portal-bound 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.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 title accurately describes the main change: portaling floating UI elements to document.body to prevent overflow clipping.
Description check ✅ Passed The description comprehensively covers all required sections: Summary, Rationale, Changes, Impact, Testing, and Checklist are all well-documented.
Linked Issues check ✅ Passed All four linked issues (#2543, #2544, #2558, #2578) are directly addressed by the portal infrastructure and floating UI changes implemented in this PR.
Out of Scope Changes check ✅ Passed All changes directly support the portaling objective: CSS updates for bn-root/bn-container separation, portal infrastructure, Popover integrations, z-index handling, and EmojiPicker simplification are all in scope.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 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

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: 50ebffb

Copy link

@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

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

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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant