Skip to content

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

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

refactor: portal floating UI elements to document.body to prevent overflow clipping BLO-1115#2591
YousefED wants to merge 13 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 (menus, toolbars, emoji picker, etc.) are now portaled to document.body, preventing clipping by overflow: hidden ancestors.

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

Rationale

When BlockNote is inside a container with overflow: hidden (sidebar, modal, scrollable panel), floating UI gets clipped. Floating UI docs confirm portaling is the only reliable fix. This is also the default behavior in libraries like Base UI.

Changes

Portal infrastructure

  • BlockNoteEditor.portalElement — lazily-created div on the editor instance. Appended to document.body (or ShadowRoot) by mount(), removed by unmount().
  • BlockNoteEditor.isWithinEditor(element) — new method that checks if an element is inside the editor DOM or its portal container. Used by core extensions to correctly handle portaled elements.
  • BlockNoteView syncs theming classes (bn-root, color scheme, user className) onto the portal element via effects.
  • GenericPopover wraps all floating elements with <FloatingPortal root={portalRoot}>.

CSS: bn-root vs bn-container

  • bn-root: theming (CSS variables, fonts). Applied to both editor and portal containers.
  • bn-container: layout (width, height). Editor container only.
  • Theme CSS variables moved from .bn-container to .bn-root.

Popover portalRoot (for EmojiPicker)

  • Added portalRoot to generic Popover.Root interface.
  • Mantine: native withinPortal + portalProps.
  • shadcn: threads portalRoot via React context, uses createPortal.
  • Ariakit: threads portalRoot via React context, native portalElement prop.

Core extension fixes

  • SideMenu: uses editor.isWithinEditor() so hovering portaled floating elements doesn't dismiss the side menu.
  • UniqueID: uses isWithinEditor option to detect internal drags from the portaled drag handle, preventing block IDs from being regenerated on drag-drop.

Z-index

  • Portaled elements don't need high z-index values (no stacking context to escape). Hardcoded z-index: 10000 removed from Mantine popover and ariakit styles when portaling.
  • Non-portal fallback preserves existing z-index behavior.
  • --bn-ui-base-z-index on .bn-root remains available for user customization.

EmojiPicker simplification

  • Removed manual createPortal — passes portalRoot to Popover.Root instead.
  • Scoped em-emoji-picker styles to .bn-root, removed stale z-index: 11000.

Impact

  • Breaking (CSS): Theme-related properties (fonts, colors, CSS variables) should target .bn-root instead of .bn-container. .bn-container now only wraps the editor itself, while .bn-root wraps both the editor and its floating UI (menus, toolbars). Layout related properties (width, height, margin) should still target .bn-container.

Testing

  • Manual testing with editor inside overflow: hidden containers.
  • TypeScript checks pass for all packages.

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

… 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 Ready Ready Preview Apr 8, 2026 11:18am
blocknote-website Ready Ready Preview Apr 8, 2026 11:18am

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
  • ✅ Review completed - (🔄 Check again to review again)
📝 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
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.
Description check ✅ Passed The PR description comprehensively covers all required sections with substantial detail on rationale, changes, impact, testing, and checklist.
Title check ✅ Passed The title 'refactor: portal floating UI elements to document.body to prevent overflow clipping BLO-1115' accurately describes the main architectural change across the entire changeset: moving floating UI (menus, toolbars, pickers) to a document.body portal to prevent overflow clipping.

✏️ 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
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: c93b468

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.

@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

@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

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

YousefED commented Apr 7, 2026

Updated PR to fix:

  • shadowroot support
  • theming support
  • remove as many hardcoded zIndex values as possible, afaik they're not needed anymore

Tested with:

  • shadowroot demo
  • theming/theming-css-variables-code demo

* elements (menus, toolbars) to escape overflow:hidden ancestors.
* Set by BlockNoteView; undefined in headless mode.
*/
public portalElement: HTMLElement | undefined;
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@nperez0111 not sure about setting this on the editor. We need it for the UniqueID and SideMenu extensions though. wdyt?

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, I would not put this on the editor, much less public. Need to find a workaround here

padding: 0;
}

/*
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this was unused, lingering code that I removed in this PR

const internalPortalRef = useCallback(
(node: HTMLDivElement | null) => {
editor.portalElement = node ?? undefined;
setPortalRoot(node);
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

@nperez0111 this will cause an extra render unfortunately, but afaik it's needed because the blockNoteContext depends on it.

An alternative would be to not use a ref but just document.createElement ahead-of-time - but that seems less "idiomatic react". wdyt?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I changed this to the document.createElement approach, because that also made it easier to support shadow dom

actions,
edited,
emojiPickerOpen, // Unused
emojiPickerOpen,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

this was missing in shadcn and ariakit implementations

actions,
children,
edited,
emojiPickerOpen, // Unused
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

fyi, this was not implemented in ariakit / shadcn, so decided to fix

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.

Let's chat

* elements (menus, toolbars) to escape overflow:hidden ancestors.
* Set by BlockNoteView; undefined in headless mode.
*/
public portalElement: HTMLElement | undefined;
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, I would not put this on the editor, much less public. Need to find a workaround here

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 portalRootRef; the current wiring turns it into a dead prop.

Line 100 overrides any portalRootRef that comes in through props. Since this wrapper’s prop type is derived from BlockNoteViewRaw, Mantine consumers can still pass portalRootRef, but it never reaches the raw view anymore.

🩹 Suggested fix
-  const { className, theme, ...rest } = props;
+  const { className, theme, portalRootRef, ...rest } = props;
-  const applyThemeVariables = useCallback(
-    (node: HTMLDivElement | null) => {
+  const applyThemeVariables = useCallback(
+    (node: HTMLElement | null) => {
       if (!node) {
         return;
       }
-  const portalRef = useCallback(
-    (node: HTMLDivElement | null) => {
+  const portalRef = useCallback(
+    (node: HTMLElement | null) => {
       if (!node) {
         return;
       }
       node.setAttribute("data-mantine-color-scheme", finalTheme);
       applyThemeVariables(node);
     },
     [applyThemeVariables, finalTheme],
   );
+
+  const mergedPortalRootRef = useCallback(
+    (node: HTMLElement | null) => {
+      portalRef(node);
+      if (typeof portalRootRef === "function") {
+        portalRootRef(node);
+      } else if (portalRootRef) {
+        portalRootRef.current = node;
+      }
+    },
+    [portalRef, portalRootRef],
+  );
-        portalRootRef={portalRef}
+        portalRootRef={mergedPortalRootRef}

Also applies to: 79-100

🤖 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, BlockNoteView currently
overwrites any incoming portalRootRef from props, dropping consumer refs; change
the wiring so you merge the incoming portalRootRef prop with the component's
internal ref instead of overriding it. In practice: when destructuring props
(const { className, theme, portalRootRef, ...rest } = props) retain
portalRootRef and use a merged ref utility (e.g., useMergedRef/useRef-merge) to
combine the external portalRootRef with the internal portal root ref, then pass
that merged ref into BlockNoteViewRaw (and any internal portalRootRef usages) so
consumers' refs are preserved.
♻️ Duplicate comments (2)
packages/react/src/editor/BlockNoteView.tsx (2)

187-195: ⚠️ Potential issue | 🟠 Major

Avoid overwriting className on externally supplied portalRoot.

For external roots, this effect replaces className wholesale, which can clobber user-managed classes. That also conflicts with the prop docs saying caller handles theming.

Suggested fix
 useEffect(() => {
   editor.portalElement = internalPortalRoot;
-  internalPortalRoot.className = mergeCSSClasses(
-    "bn-root",
-    editorColorScheme,
-    className || "",
-  );
-  internalPortalRoot.setAttribute("data-color-scheme", editorColorScheme);
-}, [internalPortalRoot, editorColorScheme, className]);
+  if (!portalRoot) {
+    internalPortalRoot.className = mergeCSSClasses(
+      "bn-root",
+      editorColorScheme,
+      className || "",
+    );
+    internalPortalRoot.setAttribute("data-color-scheme", editorColorScheme);
+  }
+}, [internalPortalRoot, editorColorScheme, className, portalRoot]);
🤖 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 187 - 195, The
effect in useEffect overwrites the external portal root's className, clobbering
caller-managed classes; update the logic in the useEffect that sets
editor.portalElement and manipulates internalPortalRoot so it preserves any
existing classes on internalPortalRoot (and on the external portal passed in)
instead of replacing them—e.g., read the element's current className/classList
and merge it with mergeCSSClasses(editorColorScheme, className, existingClasses)
or use classList.add to append the theme and prop classes; keep references to
editor.portalElement, internalPortalRoot, mergeCSSClasses, editorColorScheme,
and className when implementing the non-destructive class update.

172-175: ⚠️ Potential issue | 🟠 Major

Guard document.createElement in render for SSR paths.

Creating the fallback portal element during render (document.createElement) will throw when document is unavailable (server render). Please make this client-guarded and handle an undefined internal root until mounted.

#!/bin/bash
# Verify SSR-sensitive usage in BlockNoteView
rg -n 'document\.createElement|document\.body' packages/react/src/editor/BlockNoteView.tsx -C2
rg -n '"use client"' packages/react/src/editor/BlockNoteView.tsx
🤖 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 172 - 175, The
useMemo that creates internalPortalRoot (portalRoot ??
document.createElement("div")) runs during render and will throw in SSR; change
to defer creation to the client by initializing internalPortalRoot as undefined
(or portalRoot) in state and only creating/appending the fallback div inside a
useEffect when running on the client (i.e., when BlockNoteView mounts), cleaning
it up on unmount; update any consumers to handle an undefined internalPortalRoot
until the effect runs.
🤖 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 197-211: internalRef currently treats a null element as falsy and
falls through to appending internalPortalRoot to document.body; change the logic
so that when internalRef is called with element === null (unmount) you
remove/detach internalPortalRoot (call internalPortalRoot.remove()) instead of
appending, and only perform the append when element is non-null and portalRoot
is falsy; update the callback for internalRef (and use the existing variables
portalRoot, internalPortalRoot and the root variable) to early-return for
portalRoot, remove on null element, otherwise compute root (ShadowRoot vs
document.body) and append the internalPortalRoot.

---

Outside diff comments:
In `@packages/mantine/src/BlockNoteView.tsx`:
- Line 41: BlockNoteView currently overwrites any incoming portalRootRef from
props, dropping consumer refs; change the wiring so you merge the incoming
portalRootRef prop with the component's internal ref instead of overriding it.
In practice: when destructuring props (const { className, theme, portalRootRef,
...rest } = props) retain portalRootRef and use a merged ref utility (e.g.,
useMergedRef/useRef-merge) to combine the external portalRootRef with the
internal portal root ref, then pass that merged ref into BlockNoteViewRaw (and
any internal portalRootRef usages) so consumers' refs are preserved.

---

Duplicate comments:
In `@packages/react/src/editor/BlockNoteView.tsx`:
- Around line 187-195: The effect in useEffect overwrites the external portal
root's className, clobbering caller-managed classes; update the logic in the
useEffect that sets editor.portalElement and manipulates internalPortalRoot so
it preserves any existing classes on internalPortalRoot (and on the external
portal passed in) instead of replacing them—e.g., read the element's current
className/classList and merge it with mergeCSSClasses(editorColorScheme,
className, existingClasses) or use classList.add to append the theme and prop
classes; keep references to editor.portalElement, internalPortalRoot,
mergeCSSClasses, editorColorScheme, and className when implementing the
non-destructive class update.
- Around line 172-175: The useMemo that creates internalPortalRoot (portalRoot
?? document.createElement("div")) runs during render and will throw in SSR;
change to defer creation to the client by initializing internalPortalRoot as
undefined (or portalRoot) in state and only creating/appending the fallback div
inside a useEffect when running on the client (i.e., when BlockNoteView mounts),
cleaning it up on unmount; update any consumers to handle an undefined
internalPortalRoot until the effect runs.
🪄 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: efb3b186-3b7b-4f49-8922-4942f9845301

📥 Commits

Reviewing files that changed from the base of the PR and between 5756910 and 73d3e51.

📒 Files selected for processing (10)
  • docs/content/docs/react/styling-theming/overriding-css.mdx
  • packages/ariakit/src/style.css
  • packages/core/src/editor/managers/ExtensionManager/extensions.ts
  • packages/core/src/extensions/SideMenu/SideMenu.ts
  • packages/mantine/src/BlockNoteView.tsx
  • packages/mantine/src/popover/Popover.tsx
  • packages/react/src/components/Popovers/GenericPopover.tsx
  • packages/react/src/editor/BlockNoteContext.ts
  • packages/react/src/editor/BlockNoteView.tsx
  • packages/shadcn/src/popover/popover.tsx
💤 Files with no reviewable changes (1)
  • packages/ariakit/src/style.css
✅ Files skipped from review due to trivial changes (2)
  • packages/core/src/extensions/SideMenu/SideMenu.ts
  • docs/content/docs/react/styling-theming/overriding-css.mdx
🚧 Files skipped from review as they are similar to previous changes (3)
  • packages/core/src/editor/managers/ExtensionManager/extensions.ts
  • packages/react/src/editor/BlockNoteContext.ts
  • packages/mantine/src/popover/Popover.tsx

…itor class and remove redundant portal props from BlockNoteView.
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.

cool that did simplify things

@YousefED YousefED changed the title fix: portal floating UI elements to document.body to prevent overflow clipping BLO-1115 refactor: portal floating UI elements to document.body to prevent overflow clipping BLO-1115 Apr 8, 2026
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