feat: smoother mobile sheet entrance
Defer mounting heavy sheet content until the enter slide finishes (revealed on the slide's transitionend, with a timer fallback) so the animation no longer competes with a large content render. Shorten the slide to a quick rise from a small offset, and seat the sheet a few px below the top safe area. Revert the 100dvh sizing back to inset-0/100% — it did not fix the bottom inset and reintroduced intermittent top safe-area dimming.
This commit is contained in:
@@ -8,6 +8,15 @@ import { cn } from '@/lib/utils';
|
|||||||
const SURFACE_ROOT_ID = 'mobile-surface-root';
|
const SURFACE_ROOT_ID = 'mobile-surface-root';
|
||||||
const DISMISS_THRESHOLD_PX = 90;
|
const DISMISS_THRESHOLD_PX = 90;
|
||||||
const ENTER_DELAY_MS = 16;
|
const ENTER_DELAY_MS = 16;
|
||||||
|
// Enter-slide duration. Heavy content is revealed when this transition actually
|
||||||
|
// ends (transitionend); this also feeds the fallback timer.
|
||||||
|
const ENTER_DURATION_MS = 100;
|
||||||
|
// How far below its resting position the sheet starts the enter slide. Small
|
||||||
|
// offset → a short "rise + fade" rather than a full slide up from the bottom.
|
||||||
|
const ENTER_OFFSET_PX = 48;
|
||||||
|
// Extra gap above the sheet (below the top safe area) so it doesn't sit flush
|
||||||
|
// against the very top of the app.
|
||||||
|
const TOP_GAP_PX = 8;
|
||||||
|
|
||||||
const ensureSurfaceRoot = (): HTMLElement | null => {
|
const ensureSurfaceRoot = (): HTMLElement | null => {
|
||||||
if (typeof document === 'undefined') return null;
|
if (typeof document === 'undefined') return null;
|
||||||
@@ -52,6 +61,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
|||||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
const rootRef = React.useRef<HTMLElement | null>(null);
|
||||||
const [mounted, setMounted] = React.useState(false);
|
const [mounted, setMounted] = React.useState(false);
|
||||||
const [entered, setEntered] = React.useState(false);
|
const [entered, setEntered] = React.useState(false);
|
||||||
|
const [contentReady, setContentReady] = React.useState(false);
|
||||||
const [dragOffset, setDragOffset] = React.useState(0);
|
const [dragOffset, setDragOffset] = React.useState(0);
|
||||||
const dragStartYRef = React.useRef<number | null>(null);
|
const dragStartYRef = React.useRef<number | null>(null);
|
||||||
const isDraggingRef = React.useRef(false);
|
const isDraggingRef = React.useRef(false);
|
||||||
@@ -73,6 +83,19 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
|||||||
return () => window.clearTimeout(id);
|
return () => window.clearTimeout(id);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
// Defer mounting heavy children until the enter slide finishes, so the
|
||||||
|
// animation stays smooth instead of competing with a large content render.
|
||||||
|
// Primary trigger is the slide's transitionend (below); this is just a
|
||||||
|
// fallback in case it never fires (reduced motion / interrupted transition).
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setContentReady(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = window.setTimeout(() => setContentReady(true), ENTER_DELAY_MS + ENTER_DURATION_MS + 80);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const previousOverflow = document.body.style.overflow;
|
const previousOverflow = document.body.style.overflow;
|
||||||
@@ -177,7 +200,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
|||||||
// compositing layer — that layer is clipped to the safe-area viewport on iOS,
|
// compositing layer — that layer is clipped to the safe-area viewport on iOS,
|
||||||
// leaving a scrim gap below it over the home-indicator inset.
|
// leaving a scrim gap below it over the home-indicator inset.
|
||||||
const visualTransform = !entered
|
const visualTransform = !entered
|
||||||
? 'translateY(100%)'
|
? `translateY(${ENTER_OFFSET_PX}px)`
|
||||||
: dragOffset > 0
|
: dragOffset > 0
|
||||||
? `translateY(${dragOffset}px)`
|
? `translateY(${dragOffset}px)`
|
||||||
: 'none';
|
: 'none';
|
||||||
@@ -185,7 +208,7 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
|||||||
return createPortal(
|
return createPortal(
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed inset-x-0 top-0 z-50 flex flex-col bg-[rgb(0_0_0_/_0.45)]',
|
'fixed inset-0 z-50 flex flex-col bg-[rgb(0_0_0_/_0.45)]',
|
||||||
// The opacity transition keeps the scrim on its own compositing layer,
|
// The opacity transition keeps the scrim on its own compositing layer,
|
||||||
// which iOS Safari clips to the viewport — without it, a static scrim
|
// which iOS Safari clips to the viewport — without it, a static scrim
|
||||||
// bleeds the dim into the bottom toolbar overscroll zone. Quick fade so
|
// bleeds the dim into the bottom toolbar overscroll zone. Quick fade so
|
||||||
@@ -193,11 +216,6 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
|||||||
'transition-opacity duration-200 ease-out',
|
'transition-opacity duration-200 ease-out',
|
||||||
entered ? 'opacity-100' : 'opacity-0',
|
entered ? 'opacity-100' : 'opacity-0',
|
||||||
)}
|
)}
|
||||||
// 100dvh (not inset-0/100%): on iOS 26 the layout viewport ends above the
|
|
||||||
// bottom safe area, so a fixed inset:0 element stops short and leaves a gap
|
|
||||||
// over the home indicator. dvh spans the full dynamic viewport (incl. the
|
|
||||||
// home-indicator inset in standalone PWA).
|
|
||||||
style={{ height: '100dvh' }}
|
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
@@ -209,13 +227,20 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
|||||||
className="mt-auto flex min-h-0 w-full flex-col overflow-hidden rounded-t-[20px] border-t border-border/40 bg-background text-foreground"
|
className="mt-auto flex min-h-0 w-full flex-col overflow-hidden rounded-t-[20px] border-t border-border/40 bg-background text-foreground"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onTransitionEnd={(event) => {
|
||||||
|
// Reveal content exactly when the enter slide ends — not on a fixed timer.
|
||||||
|
if (entered && event.target === event.currentTarget && event.propertyName === 'transform') {
|
||||||
|
setContentReady(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
style={{
|
style={{
|
||||||
// Sized to leave the top safe area uncovered so the scrim dims it.
|
// Sized to leave the top safe area (plus a small gap) uncovered so the
|
||||||
height: 'calc(100dvh - var(--oc-safe-area-top, 0px))',
|
// scrim dims it and the sheet sits a few px below the very top.
|
||||||
|
height: `calc(100% - var(--oc-safe-area-top, 0px) - ${TOP_GAP_PX}px)`,
|
||||||
transform: visualTransform,
|
transform: visualTransform,
|
||||||
transition: isDraggingRef.current
|
transition: isDraggingRef.current
|
||||||
? 'none'
|
? 'none'
|
||||||
: 'transform 300ms cubic-bezier(0.32, 0.72, 0, 1)',
|
: `transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -252,9 +277,14 @@ export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-hidden">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
{children}
|
{contentReady ? (
|
||||||
|
<div className="h-full" style={{ animation: 'oc-surface-content-in 200ms ease-out' }}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<style>{'@keyframes oc-surface-content-in { from { opacity: 0 } to { opacity: 1 } }'}</style>
|
||||||
</div>,
|
</div>,
|
||||||
rootRef.current,
|
rootRef.current,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user