fix(stories): portal the story overlay to escape overflow-auto ancestor

Real root cause of the mobile rendering bug (confirmed via an actual
Android Chrome screenshot, not devtools emulation): StoryViewer's
fixed inset-0 div is a descendant of <main className="overflow-auto">
in the (app) layout. On Android Chrome, a position:fixed element
inside an overflow-auto ancestor can size itself against that
ancestor's full scrollable content instead of the real viewport —
the overlay rendered many times taller than the screen, so only a
small top slice was visible, looking like a heavily zoomed crop. The
previous two commits were chasing the video's object-fit/aspect-ratio,
which was never the actual problem.

Renders the overlay via createPortal to document.body instead, the
same pattern Radix/Base UI's Sheet and Dialog already use internally
for exactly this class of bug.
This commit is contained in:
admin committed 2026-08-16 17:09:50 +02:00
1 parent 661f36475a
commit 27cb34df27
1 file changed
+17 -3
+17 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { createPortal } from "react-dom";
import dynamic from "next/dynamic";
import { X, Eye, Volume2, VolumeX, Loader2 } from "lucide-react";
import { useTranslations } from "next-intl";
@@ -65,6 +66,12 @@ export function StoryViewer({
const [isMuted, setIsMuted] = useState(true);
const advancedRef = useRef(false);
// Portal target must wait for mount (document isn't available during SSR).
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const { data: stories = [] } = useQuery(
trpc.stories.listActive.queryOptions({ petId })
);
@@ -183,8 +190,14 @@ export function StoryViewer({
return (
<>
{/* Full-screen overlay */}
{open && (
{/* Full-screen overlay — portaled to document.body so `position: fixed`
escapes the (app) layout's <main className="overflow-auto"> ancestor.
A fixed descendant of an overflow-auto container can, on some mobile
browsers (confirmed on Android Chrome), size itself against that
container's full scrollable content instead of the real viewport —
the story then renders many times taller than the screen and only
a small top slice is visible, looking like a heavily zoomed crop. */}
{open && mounted && createPortal(
<div
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
role="dialog"
@@ -337,7 +350,8 @@ export function StoryViewer({
<span>{viewers.length}</span>
</button>
)}
</div>
</div>,
document.body
)}
{/* Viewer list sheet — owner only */}