Site Motion Log 04: Why a Fixed Modal Drifted
The gallery and calligraphy detail modals were not broken by size alone. Page entry motion changed their positioning context, so the fix was to render them through a body portal.
This bug looked like a modal sizing issue at first. The photo detail modal was not centered. The calligraphy modal looked like it had frozen the page.
The real problem was not width, height, or image loading. It was layering.
Where It Came From
The direct trigger was this part of the history:
| Commit | Date | What changed |
|---|---|---|
3e0d7aa | 2026-04-13 | Added the calligraphy page and rendered CalligraphyModal inside CalligraphyGallery |
4b3ea35 | 2026-05-04 | Added the photo gallery and rendered PhotoModal inside PhotoGallery |
5a4b8fa | 2026-05-25 | Removed per-item gallery entrance motion to make route changes feel faster |
0d19818 | 2026-05-25 | Added PageEnter and wrapped the gallery and calligraphy page blocks in local entry motion |
a44cb5f | 2026-05-26 | Fixed modal sizing, scroll, and calligraphy null guards, but missed the root positioning issue |
4d50f86 | 2026-05-26 | Rendered both detail modals through a body portal |
So the bug was not present from the start.
The modals were already rendered inside their gallery components, but there was no animated ancestor that changed fixed positioning. They still centered against the viewport.
The direct trigger was 0d19818. That commit added PageEnterItem:
hidden: { opacity: 0, y: 18, filter: "blur(4px)" }
visible: { opacity: 1, y: 0, filter: "blur(0px)" }Then the gallery page became:
<PageEnterItem>
<PhotoGallery photos={photos} />
</PageEnterItem>The calligraphy page used the same shape:
<PageEnterItem>
<CalligraphyGallery items={items} />
</PageEnterItem>The detail modals were still inside those gallery components.
Why Fixed Was Not Fixed
I had been treating this as a safe assumption:
<div className="fixed inset-0" />That should mean viewport, right?
Not always. Some CSS properties create a new containing context. This case hit transform and filter.
Framer Motion's y: 18 becomes a transform. The blur filter also creates a special rendering layer. When a fixed element lives under that kind of ancestor, it can stop using the browser viewport as its reference and get constrained by the animated ancestor instead.
That is why the photo modal appeared around the page content area instead of the viewport center.
The calligraphy page felt worse because the backdrop appeared and body scroll was locked, but the usable modal was not where the user expected it. The page looked covered with no clear way forward.
The Old Shape
The old structure was:
function PhotoGallery() {
const [selectedPhoto, setSelectedPhoto] = useState(null);
return (
<>
<PhotoWall onSelect={setSelectedPhoto} />
<PhotoModal photo={selectedPhoto} />
</>
);
}Calligraphy followed the same idea:
function CalligraphyGallery() {
const [selectedItem, setSelectedItem] = useState(null);
return (
<>
<CalligraphyGrid onSelect={setSelectedItem} />
<CalligraphyModal item={selectedItem} />
</>
);
}The benefit is locality. The gallery owns the selected item and renders the detail modal nearby.
The cost is that state ownership and DOM placement are tied together. If the gallery gets wrapped by motion, filters, clipping, or scroll containers, the modal comes along for the ride.
The New Shape
The state stays where it is, but the modal DOM moves out:
const portalRoot = typeof document === "undefined" ? null : document.body;
const modalContent = (
<AnimatePresence>{photo && <DetailModal />}</AnimatePresence>
);
if (!portalRoot) return null;
return createPortal(modalContent, portalRoot);That is a React Portal.
It does not move the React state. PhotoGallery still controls selectedPhoto, and CalligraphyGallery still controls selectedItem.
It only changes where the DOM is mounted. The modal is now attached to document.body, so fixed inset-0 once again refers to the browser viewport.
Why the First Fix Missed It
The a44cb5f fix addressed visible symptoms:
- The photo modal got a safer height.
- The detail area became scrollable.
- The action button stayed visible.
- Calligraphy metadata access was guarded against null values.
Next Imagewas only mounted when there was a real image source.
Those were valid fixes, but they were inside the modal.
The fixed layer itself was still trapped inside the page entry wrapper. That is why photo was still off-center and calligraphy still felt stuck.
The later regression test makes the boundary explicit:
assert.match(modal, /import \{ createPortal \} from "react-dom";/);
assert.match(modal, /document\.body/);
assert.match(modal, /return createPortal/);The test does not judge the animation. It protects the modal from being rendered back inside the page motion tree.
What To Watch Next Time
These UI layers should be suspicious when they live inside animated page blocks:
- modal
- lightbox
- toast
- dropdown
- tooltip
If the layer needs viewport positioning, or if it needs to cover the whole page, it should usually render through a portal.
PageEnter can keep doing local page entrance motion. It should own content entry, not global overlay behavior.
Takeaway
The bug was a boundary problem.
The old shape treated the detail modal as part of the gallery DOM. That worked until the gallery became a child of a transformed and filtered motion wrapper.
The new shape keeps state inside the gallery and mounts the modal DOM under body.
State stays local. Layering becomes global. That split is the whole fix.

