fix: portal floating UI elements to document.body to prevent overflow clipping BLO-1115#2591
fix: portal floating UI elements to document.body to prevent overflow clipping BLO-1115#2591
Conversation
… 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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughThis PR implements a portal system for floating UI elements to escape parent overflow constraints. CSS selectors migrate from Changes
Sequence DiagramsequenceDiagram
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
@blocknote/ariakit
@blocknote/code-block
@blocknote/core
@blocknote/mantine
@blocknote/react
@blocknote/server-util
@blocknote/shadcn
@blocknote/xl-ai
@blocknote/xl-docx-exporter
@blocknote/xl-email-exporter
@blocknote/xl-multi-column
@blocknote/xl-odt-exporter
@blocknote/xl-pdf-exporter
commit: |
There was a problem hiding this comment.
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 | 🟡 MinorScope the
.bn-rootexemption 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-rootancestor. 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
📒 Files selected for processing (21)
docs/content/docs/react/styling-theming/themes.mdxexamples/01-basic/12-multi-editor/src/App.tsxexamples/02-backend/04-rendering-static-documents/src/App.tsxexamples/04-theming/02-changing-font/src/styles.cssexamples/04-theming/03-theming-css/src/styles.cssexamples/04-theming/04-theming-css-variables/src/styles.cssexamples/05-interoperability/09-blocks-to-html-static-render/src/App.tsxpackages/ariakit/src/comments/Comment.tsxpackages/ariakit/src/popover/Popover.tsxpackages/core/src/editor/editor.csspackages/core/src/extensions/SideMenu/SideMenu.tspackages/mantine/src/popover/Popover.tsxpackages/react/src/components/Comments/EmojiPicker.tsxpackages/react/src/components/Popovers/GenericPopover.tsxpackages/react/src/editor/BlockNoteContext.tspackages/react/src/editor/BlockNoteView.tsxpackages/react/src/editor/ComponentsContext.tsxpackages/react/src/editor/styles.csspackages/shadcn/src/comments/Comment.tsxpackages/shadcn/src/popover/popover.tsxplayground/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} |
There was a problem hiding this comment.
🧩 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.tsxRepository: 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 -100Repository: 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 -B3Repository: 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 -5Repository: TypeCellOS/BlockNote
Length of output: 433
🏁 Script executed:
# Find Mantine Popover files
fd -t f --full-path 'packages/mantine' -e tsx -e ts | head -20Repository: 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 -A3Repository: 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 -80Repository: 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.
| 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.
| {createPortal( | ||
| <div | ||
| ref={setPortalRoot} | ||
| className={mergeCSSClasses( | ||
| "bn-root", | ||
| editorColorScheme, | ||
| className, | ||
| )} | ||
| data-color-scheme={editorColorScheme} | ||
| />, | ||
| document.body, | ||
| )} |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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
There was a problem hiding this comment.
@nperez0111 Agreed — portaling to document.body creates a structural disconnect that's hard to paper over. A few alternative directions worth considering when revisiting:
-
position: fixedon the floating elements themselves — instead of portaling the entire UI subtree, applyposition: fixed(and a CSStransform: nonereset 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 escapeoverflow: hiddenautomatically. The main caveat is thatposition: fixedis relative to the viewport, not the scroll container, which@floating-uialready handles well via itsstrategy: "fixed"option. -
Selective attribute mirroring with a whitelist — if the portal approach is kept, rather than copying
classNamewholesale (which leaks layout classes), explicitly mirror only theming-relevant attributes (data-color-scheme,data-mantine-color-scheme,dir,stylefor CSS variable overrides, and specificdata-*keys known to carry theme tokens). This is fragile long-term as integrations grow but could be a near-term patch. -
Portal to a sibling element outside the clipping ancestor — instead of
document.body, mount the portal container as a sibling above theoverflow: hiddenancestor in the DOM. The editor could walkclosestAncestorthat hasoverflow: hiddenand 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.
ʕ •ᴥ•ʔ
nperez0111
left a comment
There was a problem hiding this comment.
Definitely not comfortable with this approach. I agree it is worth solving, but as a lib I don't think we have this luxury
| 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]); | ||
|
|
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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
| {createPortal( | ||
| <div | ||
| ref={setPortalRoot} | ||
| className={mergeCSSClasses( | ||
| "bn-root", | ||
| editorColorScheme, | ||
| className, | ||
| )} | ||
| data-color-scheme={editorColorScheme} | ||
| />, | ||
| document.body, | ||
| )} |
There was a problem hiding this comment.
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
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 |
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.
Yea, I think we should give the fixed positioning strategy a try, which they also mention:
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 |
|
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 Explicitly from the docs:
What that means in practice (CSS-level consequences):
So if your floating element lives inside — or gets portalled into — a subtree with any of those CSS properties on an ancestor, Also relevant for BlockNote specifically: if you have a scrollable editor container, let's review later 👍 |
…l element access and customization
|
We've decided to continue with this, after confirming that:
|
There was a problem hiding this comment.
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 | 🟠 MajorCompose the incoming
portalRefinstead of overwriting it.The
restobject (destructured at line 41) can contain a consumer-suppliedportalRef, but it gets replaced unconditionally at line 100. This pattern differs frompackages/react/src/editor/BlockNoteView.tsx(lines 163-174), which explicitly destructuresportalRefand merges it with the internal callback usingmergeRefs. DestructureportalReffrom 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
📒 Files selected for processing (2)
packages/mantine/src/BlockNoteView.tsxpackages/react/src/editor/BlockNoteView.tsx
| {createPortal( | ||
| <div | ||
| ref={mergedPortalRef} | ||
| className={mergeCSSClasses("bn-root", editorColorScheme, className)} | ||
| data-color-scheme={editorColorScheme} | ||
| />, | ||
| document.body, | ||
| )} |
There was a problem hiding this comment.
🧩 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.tsxRepository: 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.tsxRepository: 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.tsxRepository: 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 -250Repository: 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 2Repository: 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.tsxRepository: 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 -5Repository: 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 --countRepository: 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 2Repository: 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 mdRepository: TypeCellOS/BlockNote
Length of output: 46
🏁 Script executed:
# Check the main package.json and README for SSR support claims
head -50 package.jsonRepository: TypeCellOS/BlockNote
Length of output: 1770
🏁 Script executed:
# Check for "browser" or "react" field in package.json
rg '"browser"|"react"|"main"' package.jsonRepository: 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 -10Repository: 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 2Repository: 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.tsxRepository: 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.
| {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.
…i-overflow-clipping
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…i-overflow-clipping
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 byoverflow: hiddenancestors.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 todocument.bodyis the standard fix.Closes #2543
Closes #2544
Closes #2558
Closes #2578
Supersedes the approach from #2092
Changes
Portal infrastructure
BlockNoteViewrenders a portal container atdocument.bodyviacreatePortal. This container getsbn-root+ color scheme + user className for theming, but NOTbn-container(layout only).portalRootexposed viaBlockNoteContextso 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-rootvsbn-containerbn-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.styles.cssmoved from.bn-containerto.bn-root..bn-rootbox-sizing reset from core (was never applied to any element).Popover
portalRootprop (for EmojiPicker)portalRootto the genericPopover.Rootcomponent interface.withinPortal+portalProps.portalRootvia React context from Root to Content, usescreatePortal(avoids modifying user's shadcn primitives).portalRootvia React context, uses nativeportalElementprop.Z-index handling
portalRoot, hardcodedz-index: 10000is dropped (unnecessary atdocument.bodylevel).portalRootis undefined from other call sites), the originalz-index: 10000is preserved.--bn-ui-base-z-indexon.bn-rootfor GenericPopover-based elements.EmojiPicker simplification
createPortal— now passesportalRoottoPopover.Rootand lets each UI library handle portaling.Minor fixes
emojiPickerOpenprop is now used (was previously ignored) to keep action buttons visible while emoji picker is open, matching mantine..closest(".bn-root")instead of editor wrapper containment check, so hovering portaled floating elements doesn't dismiss the side menu.em-emoji-pickerstyles to.bn-root, removed stalez-index: 11000.Impact
.bn-container[data-color-scheme]selectors need to update to.bn-root[data-color-scheme]..bn-containerstill exists but is for layout only.overflow: hiddenancestors.Testing
overflow: hiddencontainers.Checklist
Additional Notes
themeprops to verify theming works correctly on the portal container..closest(".bn-root")check is broader than the original editor-wrapper containment check, butmouseOverEditorbounding-box guard mitigates multi-editor edge cases.Summary by CodeRabbit
New Features
Bug Fixes
Refactor