From c9964a6e2dc355e7d81c6b1c9b8e6045979e7803 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Fri, 26 Sep 2025 11:54:24 +0200 Subject: [PATCH 01/44] wip --- src/SheetContainer.tsx | 46 ++++++++++++++++++++++++++--- src/SheetContent.tsx | 22 +++++++------- src/constants.ts | 5 +++- src/hooks/use-prevent-scroll.ts | 1 + src/index.tsx | 8 ++++- src/sheet.tsx | 52 ++++++++++++++++++++++++++++----- src/snap.ts | 24 +++++++++++++-- src/types.tsx | 5 ++++ src/utils.ts | 24 +++++++++++++++ 9 files changed, 161 insertions(+), 26 deletions(-) diff --git a/src/SheetContainer.tsx b/src/SheetContainer.tsx index 2773a14..adbd04c 100644 --- a/src/SheetContainer.tsx +++ b/src/SheetContainer.tsx @@ -1,4 +1,9 @@ -import { type MotionStyle, motion } from 'motion/react'; +import { + type MotionStyle, + motion, + useMotionValueEvent, + useTransform, +} from 'motion/react'; import React, { forwardRef } from 'react'; import { DEFAULT_HEIGHT } from './constants'; @@ -6,6 +11,7 @@ import { useSheetContext } from './context'; import { styles } from './styles'; import { type SheetContainerProps } from './types'; import { applyStyles, mergeRefs } from './utils'; +import { useDimensions } from './hooks/use-dimensions'; export const SheetContainer = forwardRef( ({ children, style, className = '', unstyled, ...rest }, ref) => { @@ -13,14 +19,46 @@ export const SheetContainer = forwardRef( const isUnstyled = unstyled ?? sheetContext.unstyled; + const sheetHeightConstraint = sheetContext.sheetHeightConstraint; + + useMotionValueEvent(sheetContext.yOverflow, 'change', (val) => { + sheetContext.sheetRef.current?.style.setProperty( + '--overflow', + val + 'px' + ); + }); + + // y might be negative due to elastic + // for a better experience, we clamp the y value to 0 + // and use the overflow value to add padding to the bottom of the container + // causing the illusion of the sheet being elastic + const y = sheetContext.y; + const nonNegativeY = useTransform(sheetContext.y, (val) => + Math.max(0, val) + ); + + const { windowHeight } = useDimensions(); + const didHitMaxHeight = + windowHeight - sheetHeightConstraint <= sheetContext.sheetHeight; + const containerStyle: MotionStyle = { ...applyStyles(styles.container, isUnstyled), ...style, - y: sheetContext.y, + ...(isUnstyled + ? { + y, + } + : { + y: nonNegativeY, + // compensate height for the elastic behavior of the sheet + ...(!didHitMaxHeight && { paddingBottom: sheetContext.yOverflow }), + }), }; + const constrainedHeight = `calc(${DEFAULT_HEIGHT} - ${sheetHeightConstraint}px)`; + if (sheetContext.detent === 'default') { - containerStyle.height = DEFAULT_HEIGHT; + containerStyle.height = constrainedHeight; } if (sheetContext.detent === 'full') { @@ -30,7 +68,7 @@ export const SheetContainer = forwardRef( if (sheetContext.detent === 'content') { containerStyle.height = 'auto'; - containerStyle.maxHeight = DEFAULT_HEIGHT; + containerStyle.maxHeight = constrainedHeight; } return ( diff --git a/src/SheetContent.tsx b/src/SheetContent.tsx index 963ec5f..c7b65f2 100644 --- a/src/SheetContent.tsx +++ b/src/SheetContent.tsx @@ -71,10 +71,6 @@ export const SheetContent = forwardRef( 'env(keyboard-inset-height, var(--keyboard-inset-height, 0px))'; } - if (disableScroll) { - scrollStyle.overflowY = 'hidden'; - } - return ( ( dragConstraints={dragConstraints.ref} onMeasureDragConstraints={dragConstraints.onMeasure} > - - {children} - + {disableScroll ? ( + children + ) : ( + + {children} + + )} ); } diff --git a/src/constants.ts b/src/constants.ts index e7f236e..ebc5017 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,6 +1,9 @@ import type { SheetTweenConfig } from './types'; -export const DEFAULT_HEIGHT = 'calc(100% - env(safe-area-inset-top) - 34px)'; +export const DEFAULT_TOP_CONSTRAINT = 34; + +export const DEFAULT_HEIGHT = + 'calc(var(--overflow, 0px) + 100% - env(safe-area-inset-top))'; export const IS_SSR = typeof window === 'undefined'; diff --git a/src/hooks/use-prevent-scroll.ts b/src/hooks/use-prevent-scroll.ts index 75dce89..1c990f6 100644 --- a/src/hooks/use-prevent-scroll.ts +++ b/src/hooks/use-prevent-scroll.ts @@ -158,6 +158,7 @@ function preventScrollMobileSafari() { const onTouchStart = (e: TouchEvent) => { // Use `composedPath` to support shadow DOM. const target = e.composedPath()?.[0] as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; // Store the nearest scrollable parent element from the element that the user touched. scrollable = getScrollParent(target, true); diff --git a/src/index.tsx b/src/index.tsx index 994a762..70f4114 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -6,13 +6,17 @@ import { SheetContent } from './SheetContent'; import { SheetDragIndicator } from './SheetDragIndicator'; import { SheetHeader } from './SheetHeader'; import { Sheet as SheetBase } from './sheet'; -import type { SheetCompound } from './types'; +import type { SheetCompound, SheetSnapPoint } from './types'; +import { useScrollPosition } from './hooks/use-scroll-position'; export interface SheetRef { y: MotionValue; yInverted: MotionValue; height: number; snapTo: (index: number) => Promise; + currentSnap: number | undefined; + getSnapPoint: (index: number) => SheetSnapPoint | null; + snapPoints: SheetSnapPoint[]; } export const Sheet: SheetCompound = Object.assign(SheetBase, { @@ -23,6 +27,8 @@ export const Sheet: SheetCompound = Object.assign(SheetBase, { Backdrop: SheetBackdrop, }); +export { useScrollPosition }; + // Export types export type { SheetBackdropProps, diff --git a/src/sheet.tsx b/src/sheet.tsx index 910a8c4..a54d9b3 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -1,5 +1,6 @@ import { animate, + Axis, type DragHandler, motion, type Transition, @@ -19,6 +20,7 @@ import useMeasure from 'react-use-measure'; import { DEFAULT_DRAG_CLOSE_THRESHOLD, DEFAULT_DRAG_VELOCITY_THRESHOLD, + DEFAULT_TOP_CONSTRAINT, DEFAULT_TWEEN_CONFIG, IS_SSR, REDUCED_MOTION_TWEEN_CONFIG, @@ -37,7 +39,7 @@ import { } from './snap'; import { styles } from './styles'; import { type SheetContextType, type SheetProps } from './types'; -import { applyStyles, waitForElement } from './utils'; +import { applyConstraints, applyStyles, waitForElement } from './utils'; export const Sheet = forwardRef( ( @@ -61,6 +63,7 @@ export const Sheet = forwardRef( style, tweenConfig = DEFAULT_TWEEN_CONFIG, unstyled = false, + dragConstraints: dragConstraintsProp, onOpenStart, onOpenEnd, onClose, @@ -83,9 +86,27 @@ export const Sheet = forwardRef( ? computeSnapPoints({ sheetHeight, snapPointsProp }) : []; + // for default & content detents, the sheet height is constrained instead of the drag + const sheetHeightConstraint = + detent === 'full' + ? 0 + : (dragConstraintsProp?.min ?? DEFAULT_TOP_CONSTRAINT); + + const dragBottomConstraint = + (dragConstraintsProp?.max ?? Infinity) - sheetHeightConstraint; + + const dragConstraints: Axis = { + min: 0, // top constraint (applied through sheet height instead) + max: dragBottomConstraint, // bottom constraint + }; + const { windowHeight } = useDimensions(); const closedY = sheetHeight > 0 ? sheetHeight : windowHeight; const y = useMotionValue(closedY); + const yUnconstrainedRef = useRef(undefined); + // y is below 0 when the sheet is overextended + // this happens because the sheet is elastic and can be dragged beyond the full open position + const yOverflow = useTransform(y, (val) => (val < 0 ? Math.abs(val) : 0)); const yInverted = useTransform(y, (val) => Math.max(sheetHeight - val, 0)); const indicatorRotation = useMotionValue(0); @@ -173,27 +194,37 @@ export const Sheet = forwardRef( }); const onDrag = useStableCallback((event, info) => { - onDragProp?.(event, info); + if (yUnconstrainedRef.current === undefined) return; - const currentY = y.get(); + onDragProp?.(event, info); + if (event.defaultPrevented) return; // Update drag indicator rotation based on drag velocity const velocity = y.getVelocity(); if (velocity > 0) indicatorRotation.set(10); if (velocity < 0) indicatorRotation.set(-10); - // Make sure user cannot drag beyond the top of the sheet - y.set(Math.max(currentY + info.delta.y, 0)); + const currentY = yUnconstrainedRef.current; + const nextY = currentY + info.delta.y; + yUnconstrainedRef.current = nextY; + const constrainedY = applyConstraints(nextY, dragConstraints, { + min: 0.1, + max: 0.1, + }); + y.set(constrainedY); }); const onDragStart = useStableCallback((event, info) => { - blurActiveInput(); + yUnconstrainedRef.current = y.get(); onDragStartProp?.(event, info); + if (event.defaultPrevented) return; + blurActiveInput(); }); const onDragEnd = useStableCallback((event, info) => { - blurActiveInput(); onDragEndProp?.(event, info); + if (event.defaultPrevented) return; + blurActiveInput(); const currentY = y.get(); @@ -258,6 +289,7 @@ export const Sheet = forwardRef( // Update the spring value so that the sheet is animated to the snap point animate(y, yTo, animationOptions); + yUnconstrainedRef.current = undefined; // +1px for imprecision tolerance // Only call onClose if disableDismiss is false or if we're actually closing @@ -274,6 +306,9 @@ export const Sheet = forwardRef( yInverted, height: sheetHeight, snapTo, + getSnapPoint, + snapPoints, + currentSnap, })); useModalEffect({ @@ -348,6 +383,9 @@ export const Sheet = forwardRef( sheetRef, unstyled, y, + yOverflow, + sheetHeight, + sheetHeightConstraint, }; const sheet = ( diff --git a/src/snap.ts b/src/snap.ts index cefdb57..13528b7 100644 --- a/src/snap.ts +++ b/src/snap.ts @@ -234,9 +234,29 @@ export function handleLowVelocityDrag({ }; } - // No snap point down, stay at current - return { + const noChangesResult = { yTo: currentSnapPoint.snapValueY, snapIndex: currentSnapPoint.snapIndex, }; + + switch (dragDirection) { + case 'down': { + const firstSnapPoint = snapPoints.at(0); + // No snap point down, stay at current + if (!firstSnapPoint) return noChangesResult; + return { + yTo: firstSnapPoint.snapValueY, + snapIndex: firstSnapPoint.snapIndex, + }; + } + case 'up': { + const lastSnapPoint = snapPoints.at(-1); + // No snap point up, stay at current + if (!lastSnapPoint) return noChangesResult; + return { + yTo: lastSnapPoint.snapValueY, + snapIndex: lastSnapPoint.snapIndex, + }; + } + } } diff --git a/src/types.tsx b/src/types.tsx index c918f3f..49d5575 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -9,6 +9,7 @@ import { } from 'react'; import { + Axis, type DragHandler, type EasingDefinition, type MotionValue, @@ -41,6 +42,7 @@ export type SheetProps = { disableScrollLocking?: boolean; dragCloseThreshold?: number; dragVelocityThreshold?: number; + dragConstraints?: Partial; initialSnap?: number; // index of snap points array isOpen: boolean; modalEffectRootId?: string; @@ -115,6 +117,9 @@ export interface SheetContextType { sheetRef: RefObject; unstyled: boolean; y: MotionValue; + yOverflow: MotionValue; + sheetHeight: number; + sheetHeightConstraint: number; } export interface SheetScrollerContextType { diff --git a/src/utils.ts b/src/utils.ts index 2b6f8c7..4d6bae2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,7 @@ import { type CSSProperties, type ForwardedRef, type RefCallback } from 'react'; import { IS_SSR } from './constants'; +import type { Axis } from 'motion/react'; +import { mixNumber } from 'motion/react'; export function applyStyles( styles: { base: CSSProperties; decorative: CSSProperties }, @@ -88,3 +90,25 @@ export function waitForElement( }, interval); }); } + +// source: https://github.com/motiondivision/motion/blob/main/packages/framer-motion/src/gestures/drag/utils/constraints.ts#L18 +/** + * Apply constraints to a point. These constraints are both physical along an + * axis, and an elastic factor that determines how much to constrain the point + * by if it does lie outside the defined parameters. + */ +export function applyConstraints( + point: number, + { min, max }: Partial, + elastic?: Axis +): number { + if (min !== undefined && point < min) { + // If we have a min point defined, and this is outside of that, constrain + point = elastic ? mixNumber(min, point, elastic.min) : Math.max(point, min); + } else if (max !== undefined && point > max) { + // If we have a max point defined, and this is outside of that, constrain + point = elastic ? mixNumber(max, point, elastic.max) : Math.min(point, max); + } + + return point; +} From fb6e85cce64089c8211d2406f1c3729adf4d6b4f Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Fri, 3 Oct 2025 08:29:54 +0200 Subject: [PATCH 02/44] correctly check disableScroll --- src/SheetContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SheetContent.tsx b/src/SheetContent.tsx index c7b65f2..7365bd3 100644 --- a/src/SheetContent.tsx +++ b/src/SheetContent.tsx @@ -81,7 +81,7 @@ export const SheetContent = forwardRef( dragConstraints={dragConstraints.ref} onMeasureDragConstraints={dragConstraints.onMeasure} > - {disableScroll ? ( + {disableScrollProp === true ? ( children ) : ( Date: Fri, 3 Oct 2025 14:05:55 +0200 Subject: [PATCH 03/44] recompute scrollPosition on scroller resize --- src/hooks/use-scroll-position.ts | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/hooks/use-scroll-position.ts b/src/hooks/use-scroll-position.ts index 730e318..9cd0e49 100644 --- a/src/hooks/use-scroll-position.ts +++ b/src/hooks/use-scroll-position.ts @@ -1,30 +1,26 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useState } from 'react'; +import { useStableCallback } from './use-stable-callback'; +import { useResizeObserver } from './use-resize-observer'; export function useScrollPosition() { - const ref = useRef(null); const [scrollPosition, setScrollPosition] = useState< 'top' | 'bottom' | 'middle' | undefined >(undefined); - useEffect(() => { - const element = ref.current; - if (!element) return; - - let scrollTimeout: number | null = null; - - function determineScrollPosition(element: HTMLDivElement) { + const determineScrollPosition = useStableCallback( + (element: HTMLDivElement) => { const { scrollTop, scrollHeight, clientHeight } = element; const isScrollable = scrollHeight > clientHeight; if (!isScrollable) { // Reset scroll position if the content is not scrollable anymore - if (scrollPosition) setScrollPosition(undefined); + setScrollPosition(undefined); return; } const isAtTop = scrollTop <= 0; const isAtBottom = - Math.ceil(scrollHeight) - Math.ceil(scrollTop) === + Math.ceil(scrollHeight) - Math.ceil(scrollTop) <= Math.ceil(clientHeight); let position: 'top' | 'bottom' | 'middle'; @@ -37,9 +33,19 @@ export function useScrollPosition() { position = 'middle'; } - if (position === scrollPosition) return; setScrollPosition(position); } + ); + + const { observeRef: ref } = useResizeObserver( + () => ref.current && determineScrollPosition(ref.current) + ); + + useEffect(() => { + const element = ref.current; + if (!element) return; + + let scrollTimeout: number | null = null; function onScroll(event: Event) { if (event.currentTarget instanceof HTMLDivElement) { From 9e375dde4e1aed88fb72111c93db9a8c8f75c07e Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Fri, 3 Oct 2025 22:54:25 +0200 Subject: [PATCH 04/44] Add optional bounce --- src/sheet.tsx | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/sheet.tsx b/src/sheet.tsx index a54d9b3..e2fc31b 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -216,6 +216,9 @@ export const Sheet = forwardRef( const onDragStart = useStableCallback((event, info) => { yUnconstrainedRef.current = y.get(); + if (y.isAnimating()) { + y.stop(); + } onDragStartProp?.(event, info); if (event.defaultPrevented) return; blurActiveInput(); @@ -229,6 +232,7 @@ export const Sheet = forwardRef( const currentY = y.get(); let yTo = 0; + let snapIndex: number | undefined; const currentSnapPoint = currentSnap !== undefined ? getSnapPoint(currentSnap) : null; @@ -257,6 +261,7 @@ export const Sheet = forwardRef( } yTo = result.yTo; + snapIndex = result.snapIndex; // If disableDismiss is true, prevent closing via gesture if (disableDismiss && yTo + 1 >= sheetHeight) { @@ -265,6 +270,7 @@ export const Sheet = forwardRef( if (bottomSnapPoint) { yTo = bottomSnapPoint.snapValueY; + snapIndex = bottomSnapPoint.snapIndex; updateSnap(bottomSnapPoint.snapIndex); } else { // If no open snap points available, stay at current position @@ -287,8 +293,14 @@ export const Sheet = forwardRef( } } + const shouldBounce = currentSnapPoint?.snapIndex !== snapIndex; + + const bounce = shouldBounce + ? linear(Math.abs(info.velocity.y), 0, 1000, 0.175, 0.25) + : 0; + // Update the spring value so that the sheet is animated to the snap point - animate(y, yTo, animationOptions); + animate(y, yTo, { ...animationOptions, bounce }); yUnconstrainedRef.current = undefined; // +1px for imprecision tolerance @@ -414,3 +426,17 @@ export const Sheet = forwardRef( ); Sheet.displayName = 'Sheet'; + +function linear( + value: number, + inputMin: number, + inputMax: number, + outputMin: number, + outputMax: number +): number { + const t = Math.max( + 0, + Math.min(1, (value - inputMin) / (inputMax - inputMin)) + ); + return outputMin + (outputMax - outputMin) * t; +} From 51854212a87a16fc2ff417259d4b7e4519d84c21 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Tue, 7 Oct 2025 18:12:48 +0200 Subject: [PATCH 05/44] Sign pushes --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 258840c..f7b5cc9 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "link": "yalc publish && npm run link:example && npm run link:example-ssr", "link:example": "cd example && yalc add react-modal-sheet && npm i", "link:example-ssr": "cd example-ssr && yalc add react-modal-sheet && npm i", - "link:update": "tsup --dts-only && yalc push --replace", + "link:update": "tsup --dts-only && yalc push --sig --replace", "test": "vitest run", "typecheck": "tsc --noEmit", "verify": "run-p format:check lint:check typecheck", From 8a6223f8d5788c7c3f0116cb47792e8c024ad299 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Wed, 8 Oct 2025 16:48:10 +0200 Subject: [PATCH 06/44] React to element changes on use-resize-observer and use-scroll-position --- src/hooks/use-resize-observer.ts | 10 +++++----- src/hooks/use-scroll-position.ts | 17 ++++++++++++----- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/hooks/use-resize-observer.ts b/src/hooks/use-resize-observer.ts index 4fbc8db..aa79453 100644 --- a/src/hooks/use-resize-observer.ts +++ b/src/hooks/use-resize-observer.ts @@ -1,10 +1,10 @@ -import { useEffect, useRef } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useStableCallback } from './use-stable-callback'; export function useResizeObserver( callback: ResizeObserverCallback ) { - const observeRef = useRef(null); + const [observeElement, setObserveElement] = useState(null); const timeoutRef = useRef | null>(null); const debouncedCallback: ResizeObserverCallback = useStableCallback( @@ -15,7 +15,7 @@ export function useResizeObserver( ); useEffect(() => { - const element = observeRef.current; + const element = observeElement; if (!element) return; const observer = new ResizeObserver(debouncedCallback); @@ -25,7 +25,7 @@ export function useResizeObserver( observer.disconnect(); if (timeoutRef.current) clearTimeout(timeoutRef.current); }; - }, []); + }, [observeElement]); - return { observeRef }; + return { observeRef: setObserveElement }; } diff --git a/src/hooks/use-scroll-position.ts b/src/hooks/use-scroll-position.ts index 9cd0e49..447dbbe 100644 --- a/src/hooks/use-scroll-position.ts +++ b/src/hooks/use-scroll-position.ts @@ -37,12 +37,19 @@ export function useScrollPosition() { } ); - const { observeRef: ref } = useResizeObserver( - () => ref.current && determineScrollPosition(ref.current) + const [ref, setRef] = useState(null); + + const { observeRef } = useResizeObserver( + () => ref && determineScrollPosition(ref) ); + const mergedRef = (el: HTMLDivElement | null) => { + setRef(el); + observeRef(el); + }; + useEffect(() => { - const element = ref.current; + const element = ref; if (!element) return; let scrollTimeout: number | null = null; @@ -72,7 +79,7 @@ export function useScrollPosition() { element.removeEventListener('scroll', onScroll); element.removeEventListener('touchstart', onTouchStart); }; - }, []); + }, [ref]); - return { ref, scrollPosition }; + return { ref: mergedRef, scrollPosition }; } From c3c24cfd812e258652d9b0f216cd2dc1b446ac18 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Wed, 15 Oct 2025 12:21:26 +0200 Subject: [PATCH 07/44] handle quick close and open more gracefully --- src/hooks/use-sheet-state.ts | 12 ++- src/index.tsx | 3 + src/sheet.tsx | 155 +++++++++++++++++++++++++++-------- 3 files changed, 128 insertions(+), 42 deletions(-) diff --git a/src/hooks/use-sheet-state.ts b/src/hooks/use-sheet-state.ts index 85d077a..2620202 100644 --- a/src/hooks/use-sheet-state.ts +++ b/src/hooks/use-sheet-state.ts @@ -25,12 +25,8 @@ export function useSheetState({ const onClosing = useStableCallback(() => _onClosing?.()); useEffect(() => { - if (isOpen && state === 'closed') { - setState('opening'); - } else if (!isOpen && (state === 'open' || state === 'opening')) { - setState('closing'); - } - }, [isOpen, state]); + setState(isOpen ? 'opening' : 'closing'); + }, [isOpen]); useEffect(() => { async function handle() { @@ -55,7 +51,9 @@ export function useSheetState({ } } handle().catch((error) => { - console.error('Internal sheet state error:', error); + if (error instanceof Error) { + console.error('Internal sheet state error:', error); + } }); }, [state]); diff --git a/src/index.tsx b/src/index.tsx index 70f4114..9615709 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,6 +8,7 @@ import { SheetHeader } from './SheetHeader'; import { Sheet as SheetBase } from './sheet'; import type { SheetCompound, SheetSnapPoint } from './types'; import { useScrollPosition } from './hooks/use-scroll-position'; +import { RefObject } from 'react'; export interface SheetRef { y: MotionValue; @@ -17,6 +18,8 @@ export interface SheetRef { currentSnap: number | undefined; getSnapPoint: (index: number) => SheetSnapPoint | null; snapPoints: SheetSnapPoint[]; + currentSnapPoint: SheetSnapPoint | null; + openStateRef: RefObject<'closed' | 'open' | 'opening' | 'closing'>; } export const Sheet: SheetCompound = Object.assign(SheetBase, { diff --git a/src/sheet.tsx b/src/sheet.tsx index e2fc31b..7e06c31 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -13,6 +13,7 @@ import React, { useImperativeHandle, useRef, useState, + useMemo, } from 'react'; import { createPortal } from 'react-dom'; import useMeasure from 'react-use-measure'; @@ -81,10 +82,11 @@ export const Sheet = forwardRef( const sheetRef = useRef(null); const sheetHeight = Math.round(sheetBounds.height); const [currentSnap, setCurrentSnap] = useState(initialSnap); - const snapPoints = - snapPointsProp && sheetHeight > 0 + const snapPoints = useMemo(() => { + return snapPointsProp && sheetHeight > 0 ? computeSnapPoints({ sheetHeight, snapPointsProp }) : []; + }, [sheetHeight, snapPointsProp]); // for default & content detents, the sheet height is constrained instead of the drag const sheetHeightConstraint = @@ -313,15 +315,37 @@ export const Sheet = forwardRef( indicatorRotation.set(0); }); - useImperativeHandle(ref, () => ({ - y, - yInverted, - height: sheetHeight, - snapTo, - getSnapPoint, - snapPoints, - currentSnap, - })); + const openStateRef = useRef<'closed' | 'open' | 'opening' | 'closing'>( + isOpen ? 'opening' : 'closed' + ); + + const currentSnapPoint = currentSnap ? getSnapPoint(currentSnap) : null; + + useImperativeHandle( + ref, + () => ({ + y, + yInverted, + height: sheetHeight, + snapTo, + getSnapPoint, + snapPoints, + currentSnap, + currentSnapPoint, + openStateRef, + }), + [ + y, + yInverted, + sheetHeight, + snapTo, + getSnapPoint, + snapPoints, + currentSnap, + currentSnapPoint, + openStateRef, + ] + ); useModalEffect({ y, @@ -340,37 +364,98 @@ export const Sheet = forwardRef( isDisabled: disableScrollLocking || !isOpen, }); + const yListenersRef = useRef([]); + const clearYListeners = useStableCallback(() => { + yListenersRef.current.forEach((listener) => listener()); + yListenersRef.current = []; + }); + const state = useSheetState({ isOpen, - onOpen: async () => { - onOpenStart?.(); - - /** - * This is not very React-y but we need to wait for the sheet - * but we need to wait for the sheet to be rendered and visible - * before we can measure it and animate it to the initial snap point. - */ - await waitForElement('react-modal-sheet-container'); - - const initialSnapPoint = - initialSnap !== undefined ? getSnapPoint(initialSnap) : null; - - const yTo = initialSnapPoint?.snapValueY ?? 0; + onOpen: () => { + return new Promise((resolve, reject) => { + clearYListeners(); + + openStateRef.current = 'opening'; + y.stop(); + onOpenStart?.(); + + const handleOpenEnd = () => { + if (initialSnap !== undefined) { + updateSnap(initialSnap); + } + + onOpenEnd?.(); + openStateRef.current = 'open'; + }; + + yListenersRef.current.push( + y.on('animationCancel', () => { + clearYListeners(); + + if (openStateRef.current === 'opening') { + handleOpenEnd(); + resolve(); + } else { + reject('stopped opening'); + } + }), + y.on('animationComplete', () => { + clearYListeners(); + + handleOpenEnd(); + resolve(); + }) + ); - await animate(y, yTo, animationOptions); + /** + * This is not very React-y but we need to wait for the sheet + * but we need to wait for the sheet to be rendered and visible + * before we can measure it and animate it to the initial snap point. + */ + waitForElement('react-modal-sheet-container').then(() => { + const initialSnapPoint = + initialSnap !== undefined ? getSnapPoint(initialSnap) : null; - if (initialSnap !== undefined) { - updateSnap(initialSnap); - } + const yTo = initialSnapPoint?.snapValueY ?? 0; - onOpenEnd?.(); + animate(y, yTo, animationOptions); + }); + }); }, - onClosing: async () => { - onCloseStart?.(); - - await animate(y, closedY, animationOptions); + onClosing: () => { + return new Promise((resolve, reject) => { + clearYListeners(); + + openStateRef.current = 'closing'; + onCloseStart?.(); + + const handleCloseEnd = () => { + onCloseEnd?.(); + openStateRef.current = 'closed'; + }; + + yListenersRef.current.push( + y.on('animationCancel', () => { + clearYListeners(); + + if (openStateRef.current === 'closing') { + handleCloseEnd(); + resolve(); + } else { + reject('stopped closing'); + } + }), + y.on('animationComplete', () => { + clearYListeners(); + + handleCloseEnd(); + resolve(); + }) + ); - onCloseEnd?.(); + animate(y, closedY, animationOptions); + }); }, }); From c1bee29569dbcfb242800bb31607988b51836f11 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Sat, 11 Oct 2025 00:09:11 +0200 Subject: [PATCH 08/44] keyboard improvements --- src/SheetContent.tsx | 17 ++++++++++++----- src/hooks/use-virtual-keyboard.ts | 11 +++++++++-- src/sheet.tsx | 29 +++++++++++++++++++++++++++++ src/types.tsx | 1 + src/utils.ts | 10 ++++++++++ 5 files changed, 61 insertions(+), 7 deletions(-) diff --git a/src/SheetContent.tsx b/src/SheetContent.tsx index 7365bd3..8127233 100644 --- a/src/SheetContent.tsx +++ b/src/SheetContent.tsx @@ -66,9 +66,16 @@ export const SheetContent = forwardRef( const scrollStyle: MotionStyle = applyStyles(styles.scroller, isUnstyled); + const shouldRenderScroller = disableScrollProp === false || !disableScroll; + if (sheetContext.avoidKeyboard) { - scrollStyle.paddingBottom = - 'env(keyboard-inset-height, var(--keyboard-inset-height, 0px))'; + if (disableScroll) { + contentStyle.paddingBottom = + 'env(keyboard-inset-height, var(--keyboard-inset-height, 0px))'; + } else { + scrollStyle.paddingBottom = + 'env(keyboard-inset-height, var(--keyboard-inset-height, 0px))'; + } } return ( @@ -81,9 +88,7 @@ export const SheetContent = forwardRef( dragConstraints={dragConstraints.ref} onMeasureDragConstraints={dragConstraints.onMeasure} > - {disableScrollProp === true ? ( - children - ) : ( + {shouldRenderScroller ? ( ( > {children} + ) : ( + children )} ); diff --git a/src/hooks/use-virtual-keyboard.ts b/src/hooks/use-virtual-keyboard.ts index c9e750f..bafc76d 100644 --- a/src/hooks/use-virtual-keyboard.ts +++ b/src/hooks/use-virtual-keyboard.ts @@ -1,5 +1,6 @@ import { type RefObject, useEffect, useRef, useState } from 'react'; import { useStableCallback } from './use-stable-callback'; +import { isIOSSafari26 } from '../utils'; type VirtualKeyboardState = { isVisible: boolean; @@ -63,12 +64,18 @@ export function useVirtualKeyboard({ function setKeyboardInsetHeightEnv(height: number) { containerRef.current?.style.setProperty( '--keyboard-inset-height', - `${height}px` + // Safari 26 uses a floating address bar when keyboard is open that occludes the bottom of the sheet + // and its height is not considered in the visual viewport. It is estimated to be 25px. + `${isIOSSafari26() ? (height ? height + 25 : 0) : height}px` ); } function handleFocusIn(e: FocusEvent) { - if (e.target instanceof HTMLElement && isTextInput(e.target)) { + if ( + e.target instanceof HTMLElement && + isTextInput(e.target) && + containerRef.current?.contains(e.target) + ) { focusedElementRef.current = e.target; updateKeyboardState(); } diff --git a/src/sheet.tsx b/src/sheet.tsx index 7e06c31..c04f625 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -10,6 +10,7 @@ import { } from 'motion/react'; import React, { forwardRef, + useEffect, useImperativeHandle, useRef, useState, @@ -74,6 +75,7 @@ export const Sheet = forwardRef( onDrag: onDragProp, onDragStart: onDragStartProp, onDragEnd: onDragEndProp, + onKeyboardOpen, ...rest }, ref @@ -122,6 +124,7 @@ export const Sheet = forwardRef( const keyboard = useVirtualKeyboard({ isEnabled: isOpen && avoidKeyboard, containerRef: sheetRef, + debounceDelay: 0, }); // Disable drag if the keyboard is open to avoid weird behavior @@ -356,6 +359,32 @@ export const Sheet = forwardRef( startThreshold: modalEffectThreshold, }); + const lastSnapPointIndex = snapPoints.length - 1; + + const handleKeyboardOpen = useStableCallback(() => { + if (!onKeyboardOpen) { + const currentSnapPoint = currentSnap; + if (currentSnapPoint === lastSnapPointIndex) return; + + // fully open the sheet + snapTo(lastSnapPointIndex); + + // restore the previous snap point once the keyboard is closed + return () => { + currentSnapPoint !== undefined && snapTo(currentSnapPoint); + }; + } + + return onKeyboardOpen(); + }); + + useEffect(() => { + if (openStateRef.current !== 'open') return; + if (detent !== 'default') return; + if (!keyboard.isKeyboardOpen) return; + return handleKeyboardOpen(); + }, [keyboard.isKeyboardOpen]); + /** * Motion should handle body scroll locking but it's not working properly on iOS. * Scroll locking from React Aria seems to work much better 🤷‍♂️ diff --git a/src/types.tsx b/src/types.tsx index 49d5575..11e65e6 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -35,6 +35,7 @@ export interface SheetTweenConfig { export type SheetProps = { unstyled?: boolean; avoidKeyboard?: boolean; + onKeyboardOpen?: (() => VoidFunction) | (() => void); children: ReactNode; detent?: SheetDetent; disableDismiss?: boolean; diff --git a/src/utils.ts b/src/utils.ts index 4d6bae2..0b1ccff 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -70,6 +70,16 @@ export const isIOS = cached(function () { return isIPhone() || isIPad(); }); +const isSafari = cached(function () { + return navigator.userAgent.search(/Safari/g) !== -1; +}); + +export const isIOSSafari26 = cached(function () { + if (!isIOS()) return false; + if (!isSafari()) return false; + return navigator.userAgent.search(/Version\/26[0-9.]*/g) !== -1; +}); + /** Wait for an element to be rendered and visible */ export function waitForElement( className: string, From 5be1ff1411efbe5f129181be6b61b2b68087cbb0 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Wed, 15 Oct 2025 19:10:24 +0200 Subject: [PATCH 09/44] stop ongoing animation when closing --- src/sheet.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sheet.tsx b/src/sheet.tsx index c04f625..e7ad39a 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -456,6 +456,7 @@ export const Sheet = forwardRef( return new Promise((resolve, reject) => { clearYListeners(); + y.stop(); openStateRef.current = 'closing'; onCloseStart?.(); From 49339557405d64eea794c496fb4001e19adf5247 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Sat, 18 Oct 2025 13:53:16 +0200 Subject: [PATCH 10/44] Use virtual keyboard api when possible to detect keyboard height --- src/hooks/use-virtual-keyboard.ts | 36 +++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/hooks/use-virtual-keyboard.ts b/src/hooks/use-virtual-keyboard.ts index bafc76d..8ca7028 100644 --- a/src/hooks/use-virtual-keyboard.ts +++ b/src/hooks/use-virtual-keyboard.ts @@ -62,12 +62,20 @@ export function useVirtualKeyboard({ const vk = (navigator as any).virtualKeyboard; function setKeyboardInsetHeightEnv(height: number) { - containerRef.current?.style.setProperty( - '--keyboard-inset-height', - // Safari 26 uses a floating address bar when keyboard is open that occludes the bottom of the sheet - // and its height is not considered in the visual viewport. It is estimated to be 25px. - `${isIOSSafari26() ? (height ? height + 25 : 0) : height}px` - ); + // Virtual Keyboard API is only available in secure context + if (window.isSecureContext) { + containerRef.current?.style.setProperty( + '--keyboard-inset-height', + `env(keyboard-inset-height, ${height}px)` + ); + } else { + containerRef.current?.style.setProperty( + '--keyboard-inset-height', + // Safari 26 uses a floating address bar when keyboard is open that occludes the bottom of the sheet + // and its height is not considered in the visual viewport. It is estimated to be 25px. + `${isIOSSafari26() ? (height ? height + 25 : 0) : height}px` + ); + } } function handleFocusIn(e: FocusEvent) { @@ -100,6 +108,18 @@ export function useVirtualKeyboard({ return; } + if (vk) { + const virtualKeyboardHeight = vk.boundingRect.height; + + setKeyboardInsetHeightEnv(virtualKeyboardHeight); + setState({ + isVisible: virtualKeyboardHeight > 0, + height: virtualKeyboardHeight, + }); + + return; + } + if (vv) { const heightDiff = window.innerHeight - vv.height; @@ -110,6 +130,8 @@ export function useVirtualKeyboard({ setKeyboardInsetHeightEnv(0); setState({ isVisible: false, height: 0 }); } + + return; } }, debounceDelay); } @@ -127,6 +149,7 @@ export function useVirtualKeyboard({ if (vk) { currentOverlaysContent = vk.overlaysContent; vk.overlaysContent = true; + vk.addEventListener('geometrychange', updateKeyboardState); } return () => { @@ -140,6 +163,7 @@ export function useVirtualKeyboard({ if (vk) { vk.overlaysContent = currentOverlaysContent; + vk.removeEventListener('geometrychange', updateKeyboardState); } if (debounceTimer.current) { From fb4c932015047b82abd686dcc680b06666a5a453 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Tue, 11 Nov 2025 18:34:28 +0100 Subject: [PATCH 11/44] Stop using env var keyboard-inset-height --- src/SheetContent.tsx | 6 ++---- src/hooks/use-virtual-keyboard.ts | 20 ++++++-------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/SheetContent.tsx b/src/SheetContent.tsx index 8127233..4c69668 100644 --- a/src/SheetContent.tsx +++ b/src/SheetContent.tsx @@ -70,11 +70,9 @@ export const SheetContent = forwardRef( if (sheetContext.avoidKeyboard) { if (disableScroll) { - contentStyle.paddingBottom = - 'env(keyboard-inset-height, var(--keyboard-inset-height, 0px))'; + contentStyle.paddingBottom = 'var(--keyboard-inset-height, 0px)'; } else { - scrollStyle.paddingBottom = - 'env(keyboard-inset-height, var(--keyboard-inset-height, 0px))'; + scrollStyle.paddingBottom = 'var(--keyboard-inset-height, 0px)'; } } diff --git a/src/hooks/use-virtual-keyboard.ts b/src/hooks/use-virtual-keyboard.ts index 8ca7028..caead8a 100644 --- a/src/hooks/use-virtual-keyboard.ts +++ b/src/hooks/use-virtual-keyboard.ts @@ -62,20 +62,12 @@ export function useVirtualKeyboard({ const vk = (navigator as any).virtualKeyboard; function setKeyboardInsetHeightEnv(height: number) { - // Virtual Keyboard API is only available in secure context - if (window.isSecureContext) { - containerRef.current?.style.setProperty( - '--keyboard-inset-height', - `env(keyboard-inset-height, ${height}px)` - ); - } else { - containerRef.current?.style.setProperty( - '--keyboard-inset-height', - // Safari 26 uses a floating address bar when keyboard is open that occludes the bottom of the sheet - // and its height is not considered in the visual viewport. It is estimated to be 25px. - `${isIOSSafari26() ? (height ? height + 25 : 0) : height}px` - ); - } + containerRef.current?.style.setProperty( + '--keyboard-inset-height', + // Safari 26 uses a floating address bar when keyboard is open that occludes the bottom of the sheet + // and its height is not considered in the visual viewport. It is estimated to be 25px. + `${isIOSSafari26() ? (height ? height + 25 : 0) : height}px` + ); } function handleFocusIn(e: FocusEvent) { From 135996e4efa9869a4c5ec744ce2972b84c3ad7b2 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Thu, 13 Nov 2025 12:55:36 +0100 Subject: [PATCH 12/44] mock ref --- src/hooks/use-scroll-position.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/src/hooks/use-scroll-position.ts b/src/hooks/use-scroll-position.ts index 447dbbe..69ab005 100644 --- a/src/hooks/use-scroll-position.ts +++ b/src/hooks/use-scroll-position.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { RefObject, useCallback, useEffect, useMemo, useState } from 'react'; import { useStableCallback } from './use-stable-callback'; import { useResizeObserver } from './use-resize-observer'; @@ -37,19 +37,27 @@ export function useScrollPosition() { } ); - const [ref, setRef] = useState(null); + const [internalRef, setInternalRef] = useState(null); const { observeRef } = useResizeObserver( - () => ref && determineScrollPosition(ref) + () => internalRef && determineScrollPosition(internalRef) ); - const mergedRef = (el: HTMLDivElement | null) => { - setRef(el); - observeRef(el); - }; + const mergedRef = useCallback( + (el: HTMLDivElement | null) => { + setInternalRef(el); + observeRef(el); + }, + [observeRef] + ); + + const ref = useMemo( + () => Object.assign(mergedRef, { current: internalRef }), + [mergedRef, internalRef] + ) as RefObject; useEffect(() => { - const element = ref; + const element = internalRef; if (!element) return; let scrollTimeout: number | null = null; @@ -79,7 +87,7 @@ export function useScrollPosition() { element.removeEventListener('scroll', onScroll); element.removeEventListener('touchstart', onTouchStart); }; - }, [ref]); + }, [internalRef]); - return { ref: mergedRef, scrollPosition }; + return { ref, scrollPosition }; } From 9d8878dd9824a3df69928b27b6d0d9639e78ac2c Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Sat, 15 Nov 2025 14:38:35 +0100 Subject: [PATCH 13/44] avoid sheet dimension change when overflowing --- src/SheetContainer.tsx | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/src/SheetContainer.tsx b/src/SheetContainer.tsx index adbd04c..878af63 100644 --- a/src/SheetContainer.tsx +++ b/src/SheetContainer.tsx @@ -1,7 +1,7 @@ import { type MotionStyle, motion, - useMotionValueEvent, + useMotionTemplate, useTransform, } from 'motion/react'; import React, { forwardRef } from 'react'; @@ -21,13 +21,6 @@ export const SheetContainer = forwardRef( const sheetHeightConstraint = sheetContext.sheetHeightConstraint; - useMotionValueEvent(sheetContext.yOverflow, 'change', (val) => { - sheetContext.sheetRef.current?.style.setProperty( - '--overflow', - val + 'px' - ); - }); - // y might be negative due to elastic // for a better experience, we clamp the y value to 0 // and use the overflow value to add padding to the bottom of the container @@ -42,6 +35,8 @@ export const SheetContainer = forwardRef( windowHeight - sheetHeightConstraint <= sheetContext.sheetHeight; const containerStyle: MotionStyle = { + // Use motion template for performant CSS variable updates + '--overflow': useMotionTemplate`${sheetContext.yOverflow}px`, ...applyStyles(styles.container, isUnstyled), ...style, ...(isUnstyled @@ -53,7 +48,7 @@ export const SheetContainer = forwardRef( // compensate height for the elastic behavior of the sheet ...(!didHitMaxHeight && { paddingBottom: sheetContext.yOverflow }), }), - }; + } as any; const constrainedHeight = `calc(${DEFAULT_HEIGHT} - ${sheetHeightConstraint}px)`; @@ -74,15 +69,21 @@ export const SheetContainer = forwardRef( return ( - {children} +
+ {children} +
); } From 3c14ca712659a48d73eea214b87657557d20d587 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Mon, 17 Nov 2025 22:33:59 +0100 Subject: [PATCH 14/44] use clip to avoid sheet root to scroll programmatically --- src/styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/styles.ts b/src/styles.ts index a37fdad..baba02d 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -8,7 +8,7 @@ export const styles = { bottom: 0, left: 0, right: 0, - overflow: 'hidden', + overflow: 'clip', pointerEvents: 'none', }, decorative: {}, From 002cb74398c12f17c4f067380adfef1ab21e30ec Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Tue, 18 Nov 2025 03:47:41 +0100 Subject: [PATCH 15/44] better safe area inset management --- src/SheetContainer.tsx | 24 ++++------ src/SheetContent.tsx | 4 ++ src/constants.ts | 3 +- src/hooks/use-safe-area-insets.ts | 72 +++++++++++++++++++----------- src/hooks/use-virtual-keyboard.ts | 2 +- src/sheet.tsx | 73 +++++++++++++++++++++++-------- src/snap.ts | 36 +++++++++++---- src/types.tsx | 8 ++-- 8 files changed, 146 insertions(+), 76 deletions(-) diff --git a/src/SheetContainer.tsx b/src/SheetContainer.tsx index 878af63..3e40c45 100644 --- a/src/SheetContainer.tsx +++ b/src/SheetContainer.tsx @@ -50,10 +50,8 @@ export const SheetContainer = forwardRef( }), } as any; - const constrainedHeight = `calc(${DEFAULT_HEIGHT} - ${sheetHeightConstraint}px)`; - if (sheetContext.detent === 'default') { - containerStyle.height = constrainedHeight; + containerStyle.height = DEFAULT_HEIGHT; } if (sheetContext.detent === 'full') { @@ -63,27 +61,21 @@ export const SheetContainer = forwardRef( if (sheetContext.detent === 'content') { containerStyle.height = 'auto'; - containerStyle.maxHeight = constrainedHeight; + containerStyle.maxHeight = DEFAULT_HEIGHT; } return ( -
- {children} -
+ {children}
); } diff --git a/src/SheetContent.tsx b/src/SheetContent.tsx index 4c69668..d4de24b 100644 --- a/src/SheetContent.tsx +++ b/src/SheetContent.tsx @@ -76,6 +76,10 @@ export const SheetContent = forwardRef( } } + if (sheetContext.detent === 'content') { + contentStyle.paddingBottom = `max(${contentStyle.paddingBottom}, env(safe-area-inset-bottom))`; + } + return ( { - const fallback = { top: 0, left: 0, right: 0, bottom: 0 }; + const [insets, setInsets] = useState(fallback); - if (IS_SSR) return fallback; + useLayoutEffect(() => { + if (IS_SSR) return setInsets(fallback); - const root = document.querySelector(':root'); + // Create a hidden element that uses safe area insets + const safeAreaDetector = createSafeAreaDetector(); - if (!root) return fallback; + const observer = new ResizeObserver(() => { + const insets = getSafeAreaInsets(safeAreaDetector); + setInsets(insets); + }); - root.style.setProperty('--rms-sat', 'env(safe-area-inset-top)'); - root.style.setProperty('--rms-sal', 'env(safe-area-inset-left)'); - root.style.setProperty('--rms-sar', 'env(safe-area-inset-right)'); - root.style.setProperty('--rms-sab', 'env(safe-area-inset-bottom)'); + observer.observe(safeAreaDetector); - const computedStyle = getComputedStyle(root); - const sat = getComputedValue(computedStyle, '--rms-sat'); - const sal = getComputedValue(computedStyle, '--rms-sal'); - const sar = getComputedValue(computedStyle, '--rms-sar'); - const sab = getComputedValue(computedStyle, '--rms-sab'); - - root.style.removeProperty('--rms-sat'); - root.style.removeProperty('--rms-sal'); - root.style.removeProperty('--rms-sar'); - root.style.removeProperty('--rms-sab'); - - return { top: sat, left: sal, right: sar, bottom: sab }; - }); + return () => { + observer.disconnect(); + document.body.removeChild(safeAreaDetector); + }; + }, []); return insets; } -function getComputedValue(computed: CSSStyleDeclaration, property: string) { - const strValue = computed.getPropertyValue(property).replace('px', '').trim(); - return parseInt(strValue, 10) || 0; +function createSafeAreaDetector() { + const safeAreaDetector = document.createElement('div'); + safeAreaDetector.style.cssText = ` + position: fixed; + top: -1000px; + left: -1000px; + pointer-events: none; + z-index: -1; + visibility: hidden; + width: 1px; + height: 1px; + padding-top: env(safe-area-inset-top); + padding-right: env(safe-area-inset-right); + padding-bottom: env(safe-area-inset-bottom); + padding-left: env(safe-area-inset-left); +`; + document.body.appendChild(safeAreaDetector); + return safeAreaDetector; +} + +// Read the computed values +function getSafeAreaInsets(element: HTMLElement) { + const styles = getComputedStyle(element); + return { + top: parseFloat(styles.paddingTop) || 0, + right: parseFloat(styles.paddingRight) || 0, + bottom: parseFloat(styles.paddingBottom) || 0, + left: parseFloat(styles.paddingLeft) || 0, + }; } diff --git a/src/hooks/use-virtual-keyboard.ts b/src/hooks/use-virtual-keyboard.ts index caead8a..70f3327 100644 --- a/src/hooks/use-virtual-keyboard.ts +++ b/src/hooks/use-virtual-keyboard.ts @@ -66,7 +66,7 @@ export function useVirtualKeyboard({ '--keyboard-inset-height', // Safari 26 uses a floating address bar when keyboard is open that occludes the bottom of the sheet // and its height is not considered in the visual viewport. It is estimated to be 25px. - `${isIOSSafari26() ? (height ? height + 25 : 0) : height}px` + `${isIOSSafari26() ? (height ? height + 10 : 0) : height}px` ); } diff --git a/src/sheet.tsx b/src/sheet.tsx index e7ad39a..a6758f0 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -42,6 +42,7 @@ import { import { styles } from './styles'; import { type SheetContextType, type SheetProps } from './types'; import { applyConstraints, applyStyles, waitForElement } from './utils'; +import { useSafeAreaInsets } from './hooks/use-safe-area-insets'; export const Sheet = forwardRef( ( @@ -65,7 +66,7 @@ export const Sheet = forwardRef( style, tweenConfig = DEFAULT_TWEEN_CONFIG, unstyled = false, - dragConstraints: dragConstraintsProp, + safeSpace: safeSpaceProp, onOpenStart, onOpenEnd, onClose, @@ -80,31 +81,50 @@ export const Sheet = forwardRef( }, ref ) => { + const { windowHeight } = useDimensions(); + const safeAreaInsets = useSafeAreaInsets(); + const [sheetBoundsRef, sheetBounds] = useMeasure(); const sheetRef = useRef(null); const sheetHeight = Math.round(sheetBounds.height); const [currentSnap, setCurrentSnap] = useState(initialSnap); - const snapPoints = useMemo(() => { - return snapPointsProp && sheetHeight > 0 - ? computeSnapPoints({ sheetHeight, snapPointsProp }) - : []; - }, [sheetHeight, snapPointsProp]); - // for default & content detents, the sheet height is constrained instead of the drag - const sheetHeightConstraint = - detent === 'full' - ? 0 - : (dragConstraintsProp?.min ?? DEFAULT_TOP_CONSTRAINT); + const safeSpaceTop = + detent === 'full' ? 0 : (safeSpaceProp?.top ?? DEFAULT_TOP_CONSTRAINT); + + const safeSpaceBottom = + (safeSpaceProp?.bottom ?? 0) + safeAreaInsets.bottom; - const dragBottomConstraint = - (dragConstraintsProp?.max ?? Infinity) - sheetHeightConstraint; + const minSnapValue = safeSpaceBottom; + const maxSnapValueOnDefaultDetent = + windowHeight - safeSpaceTop - safeAreaInsets.top; + const maxSnapValue = + detent === 'full' + ? windowHeight + : detent === 'content' + ? Math.min(sheetHeight, maxSnapValueOnDefaultDetent) + : maxSnapValueOnDefaultDetent; const dragConstraints: Axis = { - min: 0, // top constraint (applied through sheet height instead) - max: dragBottomConstraint, // bottom constraint + min: + detent === 'full' || + (detent === 'content' && sheetHeight < windowHeight) + ? 0 + : safeSpaceTop + safeAreaInsets.top, // top constraint (applied through sheet height instead) + max: windowHeight - safeSpaceBottom, // bottom constraint }; - const { windowHeight } = useDimensions(); + const snapPoints = useMemo(() => { + return snapPointsProp && sheetHeight > 0 + ? computeSnapPoints({ + sheetHeight, + snapPointsProp, + minSnapValue, + maxSnapValue, + }) + : []; + }, [sheetHeight, snapPointsProp, minSnapValue, maxSnapValue]); + const closedY = sheetHeight > 0 ? sheetHeight : windowHeight; const y = useMotionValue(closedY); const yUnconstrainedRef = useRef(undefined); @@ -182,6 +202,11 @@ export const Sheet = forwardRef( }); }); + useEffect(() => { + if (currentSnap === undefined) return; + snapTo(currentSnap); + }, [snapPoints]); + const blurActiveInput = useStableCallback(() => { // Find focused input inside the sheet and blur it when dragging starts // to prevent a weird ghost caret "bug" on mobile @@ -446,9 +471,19 @@ export const Sheet = forwardRef( const initialSnapPoint = initialSnap !== undefined ? getSnapPoint(initialSnap) : null; - const yTo = initialSnapPoint?.snapValueY ?? 0; + if (!initialSnapPoint) { + console.warn( + 'No initial snap point found', + initialSnap, + snapPoints + ); + clearYListeners(); + handleOpenEnd(); + resolve(); + return; + } - animate(y, yTo, animationOptions); + animate(y, initialSnapPoint.snapValueY, animationOptions); }); }); }, @@ -512,7 +547,7 @@ export const Sheet = forwardRef( y, yOverflow, sheetHeight, - sheetHeightConstraint, + sheetHeightConstraint: maxSnapValue, }; const sheet = ( diff --git a/src/snap.ts b/src/snap.ts index 13528b7..e404ce9 100644 --- a/src/snap.ts +++ b/src/snap.ts @@ -39,9 +39,13 @@ import { isAscendingOrder } from './utils'; export function computeSnapPoints({ snapPointsProp, sheetHeight, + minSnapValue, + maxSnapValue, }: { snapPointsProp: number[]; sheetHeight: number; + minSnapValue: number; + maxSnapValue: number; }): SheetSnapPoint[] { if (snapPointsProp[0] !== 0) { console.error( @@ -69,11 +73,15 @@ export function computeSnapPoints({ const snapPointValues = snapPointsProp.map((point) => { // Percentage values e.g. between 0.0 and 1.0 + let value: number; if (point > 0 && point <= 1) { - return Math.round(point * sheetHeight); + value = Math.round(point * sheetHeight); + } else { + value = point < 0 ? sheetHeight + point : point; // negative values } - return point < 0 ? sheetHeight + point : point; // negative values + // Apply min/max constraints to the snap values + return Math.min(Math.max(value, minSnapValue), maxSnapValue); }); console.assert( @@ -91,20 +99,30 @@ export function computeSnapPoints({ } }); - if (!snapPointValues.includes(sheetHeight)) { + const constrainedSheetHeight = Math.min( + Math.max(sheetHeight, minSnapValue), + maxSnapValue + ); + if (!snapPointValues.includes(constrainedSheetHeight)) { console.warn( 'Snap points do not include the sheet height.' + 'Please include `1` as the last snap point or it will be included automatically.' + 'This is to ensure the sheet can be fully opened.' ); - snapPointValues.push(sheetHeight); + snapPointValues.push(constrainedSheetHeight); } - return snapPointValues.map((snap, index) => ({ - snapIndex: index, - snapValue: snap, // Absolute value from the bottom of the sheet - snapValueY: sheetHeight - snap, // Y value is inverted as `y = 0` means sheet is at the top - })); + const minSnapValueY = sheetHeight - maxSnapValue; + const maxSnapValueY = sheetHeight - minSnapValue; + + return snapPointValues.map((snap, index) => { + const snapValueY = sheetHeight - snap; + return { + snapIndex: index, + snapValue: snap, // Absolute value from the bottom of the sheet + snapValueY: Math.min(Math.max(snapValueY, minSnapValueY), maxSnapValueY), // Y value is inverted as `y = 0` means sheet is at the top + }; + }); } function findClosestSnapPoint({ diff --git a/src/types.tsx b/src/types.tsx index 11e65e6..52ca832 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -9,7 +9,6 @@ import { } from 'react'; import { - Axis, type DragHandler, type EasingDefinition, type MotionValue, @@ -25,7 +24,10 @@ type CommonProps = { type MotionProps = ComponentPropsWithoutRef; -type MotionCommonProps = Omit; +type MotionCommonProps = Omit< + MotionProps, + 'initial' | 'animate' | 'exit' | 'dragConstraints' +>; export interface SheetTweenConfig { ease: EasingDefinition; @@ -43,7 +45,7 @@ export type SheetProps = { disableScrollLocking?: boolean; dragCloseThreshold?: number; dragVelocityThreshold?: number; - dragConstraints?: Partial; + safeSpace?: Partial<{ top: number; bottom: number }>; // pixels initialSnap?: number; // index of snap points array isOpen: boolean; modalEffectRootId?: string; From 01b101a26d914dd57e60770526377f915ac0c525 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Tue, 18 Nov 2025 14:38:55 +0100 Subject: [PATCH 16/44] adjust sheet y if current snap changes --- src/sheet.tsx | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/sheet.tsx b/src/sheet.tsx index a6758f0..eaa0d08 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -202,11 +202,6 @@ export const Sheet = forwardRef( }); }); - useEffect(() => { - if (currentSnap === undefined) return; - snapTo(currentSnap); - }, [snapPoints]); - const blurActiveInput = useStableCallback(() => { // Find focused input inside the sheet and blur it when dragging starts // to prevent a weird ghost caret "bug" on mobile @@ -410,6 +405,17 @@ export const Sheet = forwardRef( return handleKeyboardOpen(); }, [keyboard.isKeyboardOpen]); + // keep the sheet at the current snap point if it changes + const currentSnapPointY = currentSnap + ? getSnapPoint(currentSnap)?.snapValueY + : null; + useEffect(() => { + if (currentSnapPointY === undefined) return; + if (currentSnapPointY === null) return; + if (openStateRef.current !== 'open') return; + animate(y, currentSnapPointY); + }, [currentSnapPointY]); + /** * Motion should handle body scroll locking but it's not working properly on iOS. * Scroll locking from React Aria seems to work much better 🤷‍♂️ From 599a75113baccae4e9f157d80b3b4e38280d4cb5 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Tue, 18 Nov 2025 14:39:07 +0100 Subject: [PATCH 17/44] export useSafeAreaInsets --- src/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/index.tsx b/src/index.tsx index 9615709..9a6cf1e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -8,6 +8,7 @@ import { SheetHeader } from './SheetHeader'; import { Sheet as SheetBase } from './sheet'; import type { SheetCompound, SheetSnapPoint } from './types'; import { useScrollPosition } from './hooks/use-scroll-position'; +import { useSafeAreaInsets } from './hooks/use-safe-area-insets'; import { RefObject } from 'react'; export interface SheetRef { @@ -30,7 +31,7 @@ export const Sheet: SheetCompound = Object.assign(SheetBase, { Backdrop: SheetBackdrop, }); -export { useScrollPosition }; +export { useScrollPosition, useSafeAreaInsets }; // Export types export type { From 282825717a972f64cbcd67f82989e57d5e3468b8 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Wed, 19 Nov 2025 19:54:47 +0100 Subject: [PATCH 18/44] better keyboard avoidance --- src/SheetContainer.tsx | 6 ++---- src/SheetContent.tsx | 9 ++++----- src/sheet.tsx | 20 +++++++++----------- src/types.tsx | 4 +++- 4 files changed, 18 insertions(+), 21 deletions(-) diff --git a/src/SheetContainer.tsx b/src/SheetContainer.tsx index 3e40c45..0146c69 100644 --- a/src/SheetContainer.tsx +++ b/src/SheetContainer.tsx @@ -19,8 +19,6 @@ export const SheetContainer = forwardRef( const isUnstyled = unstyled ?? sheetContext.unstyled; - const sheetHeightConstraint = sheetContext.sheetHeightConstraint; - // y might be negative due to elastic // for a better experience, we clamp the y value to 0 // and use the overflow value to add padding to the bottom of the container @@ -32,7 +30,7 @@ export const SheetContainer = forwardRef( const { windowHeight } = useDimensions(); const didHitMaxHeight = - windowHeight - sheetHeightConstraint <= sheetContext.sheetHeight; + windowHeight - sheetContext.safeSpaceTop <= sheetContext.sheetHeight; const containerStyle: MotionStyle = { // Use motion template for performant CSS variable updates @@ -61,7 +59,7 @@ export const SheetContainer = forwardRef( if (sheetContext.detent === 'content') { containerStyle.height = 'auto'; - containerStyle.maxHeight = DEFAULT_HEIGHT; + containerStyle.maxHeight = `calc(${DEFAULT_HEIGHT} - ${sheetContext.safeSpaceTop}px)`; } return ( diff --git a/src/SheetContent.tsx b/src/SheetContent.tsx index d4de24b..2bb04b0 100644 --- a/src/SheetContent.tsx +++ b/src/SheetContent.tsx @@ -18,6 +18,7 @@ export const SheetContent = forwardRef( className = '', scrollRef: scrollRefProp = null, unstyled, + avoidKeyboard: avoidKeyboardProp, ...rest }, ref @@ -68,7 +69,9 @@ export const SheetContent = forwardRef( const shouldRenderScroller = disableScrollProp === false || !disableScroll; - if (sheetContext.avoidKeyboard) { + const avoidKeyboard = avoidKeyboardProp ?? sheetContext.avoidKeyboard; + + if (avoidKeyboard) { if (disableScroll) { contentStyle.paddingBottom = 'var(--keyboard-inset-height, 0px)'; } else { @@ -76,10 +79,6 @@ export const SheetContent = forwardRef( } } - if (sheetContext.detent === 'content') { - contentStyle.paddingBottom = `max(${contentStyle.paddingBottom}, env(safe-area-inset-bottom))`; - } - return ( ( const safeSpaceTop = detent === 'full' ? 0 : (safeSpaceProp?.top ?? DEFAULT_TOP_CONSTRAINT); - const safeSpaceBottom = - (safeSpaceProp?.bottom ?? 0) + safeAreaInsets.bottom; + const safeSpaceBottom = safeSpaceProp?.bottom ?? 0; - const minSnapValue = safeSpaceBottom; + const minSnapValue = + safeSpaceBottom + (detent === 'default' ? safeAreaInsets.bottom : 0); const maxSnapValueOnDefaultDetent = windowHeight - safeSpaceTop - safeAreaInsets.top; const maxSnapValue = - detent === 'full' + detent === 'full' || detent === 'content' ? windowHeight - : detent === 'content' - ? Math.min(sheetHeight, maxSnapValueOnDefaultDetent) - : maxSnapValueOnDefaultDetent; + : maxSnapValueOnDefaultDetent; const dragConstraints: Axis = { min: - detent === 'full' || - (detent === 'content' && sheetHeight < windowHeight) + detent === 'full' || detent === 'content' ? 0 : safeSpaceTop + safeAreaInsets.top, // top constraint (applied through sheet height instead) - max: windowHeight - safeSpaceBottom, // bottom constraint + max: windowHeight - safeSpaceBottom - safeAreaInsets.bottom, // bottom constraint }; const snapPoints = useMemo(() => { @@ -553,7 +550,8 @@ export const Sheet = forwardRef( y, yOverflow, sheetHeight, - sheetHeightConstraint: maxSnapValue, + safeSpaceTop: safeSpaceTop + safeAreaInsets.top, + safeSpaceBottom: safeSpaceBottom + safeAreaInsets.bottom, }; const sheet = ( diff --git a/src/types.tsx b/src/types.tsx index 52ca832..055b6ae 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -78,6 +78,7 @@ export type SheetContentProps = MotionCommonProps & disableDrag?: boolean | ((args: SheetStateInfo) => boolean); disableScroll?: boolean | ((args: SheetStateInfo) => boolean); scrollRef?: RefObject; + avoidKeyboard?: boolean; }; export type SheetBackdropProps = MotionProps & @@ -122,7 +123,8 @@ export interface SheetContextType { y: MotionValue; yOverflow: MotionValue; sheetHeight: number; - sheetHeightConstraint: number; + safeSpaceTop: number; + safeSpaceBottom: number; } export interface SheetScrollerContextType { From 2a43b4526fc210c09ee0cb3d6b630cfda7ca7c83 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Wed, 19 Nov 2025 22:55:04 +0100 Subject: [PATCH 19/44] make sheet to full screen when opening keyboard instant --- src/sheet.tsx | 50 +++++++++++++++++++++++++++++--------------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/src/sheet.tsx b/src/sheet.tsx index cd0de5a..e8c9ebf 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -175,29 +175,37 @@ export const Sheet = forwardRef( return null; }); - const snapTo = useStableCallback(async (snapIndex: number) => { - if (!snapPointsProp) { - console.warn('Snapping is not possible without `snapPoints` prop.'); - return; - } + const snapTo = useStableCallback( + async (snapIndex: number, instant: boolean = false) => { + if (!snapPointsProp) { + console.warn('Snapping is not possible without `snapPoints` prop.'); + return; + } - const snapPoint = getSnapPoint(snapIndex); + const snapPoint = getSnapPoint(snapIndex); - if (snapPoint === null) { - console.warn(`Invalid snap index ${snapIndex}.`); - return; - } + if (snapPoint === null) { + console.warn(`Invalid snap index ${snapIndex}.`); + return; + } - if (snapIndex === 0) { - onClose(); - return; - } + if (snapIndex === 0) { + onClose(); + return; + } - await animate(y, snapPoint.snapValueY, { - ...animationOptions, - onComplete: () => updateSnap(snapIndex), - }); - }); + if (instant) { + y.set(snapPoint.snapValueY); + updateSnap(snapIndex); + return; + } + + await animate(y, snapPoint.snapValueY, { + ...animationOptions, + onComplete: () => updateSnap(snapIndex), + }); + } + ); const blurActiveInput = useStableCallback(() => { // Find focused input inside the sheet and blur it when dragging starts @@ -384,11 +392,11 @@ export const Sheet = forwardRef( if (currentSnapPoint === lastSnapPointIndex) return; // fully open the sheet - snapTo(lastSnapPointIndex); + snapTo(lastSnapPointIndex, true); // restore the previous snap point once the keyboard is closed return () => { - currentSnapPoint !== undefined && snapTo(currentSnapPoint); + currentSnapPoint !== undefined && snapTo(currentSnapPoint, true); }; } From 0f1cf96af94567e2b9df6db8c52e34e64a1acaf4 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Wed, 19 Nov 2025 23:50:55 +0100 Subject: [PATCH 20/44] fix safeToRemove not working inside onCloseEnd --- src/sheet.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/sheet.tsx b/src/sheet.tsx index e8c9ebf..9a2142e 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -507,7 +507,11 @@ export const Sheet = forwardRef( onCloseStart?.(); const handleCloseEnd = () => { - onCloseEnd?.(); + if (onCloseEnd) { + // waiting a frame to ensure the sheet is fully closed + // otherwise it was causing some issue with AnimatePresence's safeToRemove + requestAnimationFrame(() => onCloseEnd()); + } openStateRef.current = 'closed'; }; From 584b429c50a5b9e7b17e0d9ac685477e8b4a4731 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Wed, 19 Nov 2025 23:53:52 +0100 Subject: [PATCH 21/44] reuse safe area detector between sheets --- src/hooks/use-safe-area-insets.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/hooks/use-safe-area-insets.ts b/src/hooks/use-safe-area-insets.ts index 00d0a94..80ce121 100644 --- a/src/hooks/use-safe-area-insets.ts +++ b/src/hooks/use-safe-area-insets.ts @@ -28,9 +28,8 @@ export function useSafeAreaInsets() { return insets; } -function createSafeAreaDetector() { - const safeAreaDetector = document.createElement('div'); - safeAreaDetector.style.cssText = ` +const safeAreaDetectorId = 'react-modal-sheet-safe-area-detector'; +const safeAreaDetectorStyle = ` position: fixed; top: -1000px; left: -1000px; @@ -44,6 +43,14 @@ function createSafeAreaDetector() { padding-bottom: env(safe-area-inset-bottom); padding-left: env(safe-area-inset-left); `; + +function createSafeAreaDetector() { + let safeAreaDetector = document.getElementById(safeAreaDetectorId); + if (safeAreaDetector) return safeAreaDetector; + + safeAreaDetector = document.createElement('div'); + safeAreaDetector.id = safeAreaDetectorId; + safeAreaDetector.style.cssText = safeAreaDetectorStyle; document.body.appendChild(safeAreaDetector); return safeAreaDetector; } From 4e66e64895406ad872b8a91beb857ad52bac13f2 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Wed, 19 Nov 2025 23:59:09 +0100 Subject: [PATCH 22/44] correctly type snapTo with immediate option --- src/index.tsx | 2 +- src/sheet.tsx | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 9a6cf1e..1ef2d6a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -15,7 +15,7 @@ export interface SheetRef { y: MotionValue; yInverted: MotionValue; height: number; - snapTo: (index: number) => Promise; + snapTo: (index: number, options?: { immediate?: boolean }) => Promise; currentSnap: number | undefined; getSnapPoint: (index: number) => SheetSnapPoint | null; snapPoints: SheetSnapPoint[]; diff --git a/src/sheet.tsx b/src/sheet.tsx index 9a2142e..1a4c29d 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -176,7 +176,7 @@ export const Sheet = forwardRef( }); const snapTo = useStableCallback( - async (snapIndex: number, instant: boolean = false) => { + async (snapIndex: number, options?: { immediate?: boolean }) => { if (!snapPointsProp) { console.warn('Snapping is not possible without `snapPoints` prop.'); return; @@ -194,7 +194,7 @@ export const Sheet = forwardRef( return; } - if (instant) { + if (options?.immediate) { y.set(snapPoint.snapValueY); updateSnap(snapIndex); return; @@ -392,11 +392,12 @@ export const Sheet = forwardRef( if (currentSnapPoint === lastSnapPointIndex) return; // fully open the sheet - snapTo(lastSnapPointIndex, true); + snapTo(lastSnapPointIndex, { immediate: true }); // restore the previous snap point once the keyboard is closed return () => { - currentSnapPoint !== undefined && snapTo(currentSnapPoint, true); + currentSnapPoint !== undefined && + snapTo(currentSnapPoint, { immediate: true }); }; } From bfc3f277a2238dc66432be41c5895358028fc1c8 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Thu, 20 Nov 2025 00:12:44 +0100 Subject: [PATCH 23/44] do not remove safe area detector, it might be used by someone else --- src/hooks/use-safe-area-insets.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/use-safe-area-insets.ts b/src/hooks/use-safe-area-insets.ts index 80ce121..e61c9b4 100644 --- a/src/hooks/use-safe-area-insets.ts +++ b/src/hooks/use-safe-area-insets.ts @@ -21,7 +21,6 @@ export function useSafeAreaInsets() { return () => { observer.disconnect(); - document.body.removeChild(safeAreaDetector); }; }, []); From dacecf6fcc404c025527669301672f94d79f1bb1 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Thu, 20 Nov 2025 10:21:23 +0100 Subject: [PATCH 24/44] fix closable sheet with default detent not fully closing --- src/sheet.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/sheet.tsx b/src/sheet.tsx index 1a4c29d..72eb4d4 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -94,8 +94,9 @@ export const Sheet = forwardRef( const safeSpaceBottom = safeSpaceProp?.bottom ?? 0; - const minSnapValue = - safeSpaceBottom + (detent === 'default' ? safeAreaInsets.bottom : 0); + const minSnapValue = safeSpaceBottom + ? safeSpaceBottom + safeAreaInsets.bottom + : 0; const maxSnapValueOnDefaultDetent = windowHeight - safeSpaceTop - safeAreaInsets.top; const maxSnapValue = From 23f5b7547b86c51a3dea0721bb03100c2e6a6e92 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Thu, 20 Nov 2025 20:37:50 +0100 Subject: [PATCH 25/44] add skipOpenAnimation so the sheet opens as soon as possible --- src/sheet.tsx | 31 +++++++++++++++++++++++-------- src/types.tsx | 1 + 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/sheet.tsx b/src/sheet.tsx index 72eb4d4..f9a3a8f 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -77,6 +77,7 @@ export const Sheet = forwardRef( onDragStart: onDragStartProp, onDragEnd: onDragEndProp, onKeyboardOpen, + skipOpenAnimation = false, ...rest }, ref @@ -475,12 +476,7 @@ export const Sheet = forwardRef( }) ); - /** - * This is not very React-y but we need to wait for the sheet - * but we need to wait for the sheet to be rendered and visible - * before we can measure it and animate it to the initial snap point. - */ - waitForElement('react-modal-sheet-container').then(() => { + const doWhenSheetReady = () => { const initialSnapPoint = initialSnap !== undefined ? getSnapPoint(initialSnap) : null; @@ -496,8 +492,27 @@ export const Sheet = forwardRef( return; } - animate(y, initialSnapPoint.snapValueY, animationOptions); - }); + if (skipOpenAnimation) { + y.set(initialSnapPoint.snapValueY); + handleOpenEnd(); + resolve(); + } else { + animate(y, initialSnapPoint.snapValueY, animationOptions); + } + }; + + /** + * This is not very React-y but we need to wait for the sheet + * but we need to wait for the sheet to be rendered and visible + * before we can measure it and animate it to the initial snap point. + */ + if (detent === 'content') { + waitForElement('react-modal-sheet-container').then( + doWhenSheetReady + ); + } else { + doWhenSheetReady(); + } }); }, onClosing: () => { diff --git a/src/types.tsx b/src/types.tsx index 055b6ae..eb8e5cb 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -54,6 +54,7 @@ export type SheetProps = { prefersReducedMotion?: boolean; snapPoints?: number[]; tweenConfig?: SheetTweenConfig; + skipOpenAnimation?: boolean; onClose: () => void; onCloseEnd?: () => void; onCloseStart?: () => void; From 9fb7cf7ba099cbd15f9b51f9590f2cee2dec1ec7 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Fri, 21 Nov 2025 16:19:24 +0100 Subject: [PATCH 26/44] handle sheet opening and closing so race conditions are unlikely --- src/hooks/use-sheet-state.ts | 15 ++++++++-- src/sheet.tsx | 55 ++++++++++++++++++------------------ 2 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/hooks/use-sheet-state.ts b/src/hooks/use-sheet-state.ts index 2620202..89eac8c 100644 --- a/src/hooks/use-sheet-state.ts +++ b/src/hooks/use-sheet-state.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useStableCallback } from './use-stable-callback'; type SheetState = 'closed' | 'opening' | 'open' | 'closing'; @@ -19,16 +19,21 @@ export function useSheetState({ onClosing: _onClosing, }: UseSheetStatesProps) { const [state, setState] = useState(isOpen ? 'opening' : 'closed'); + const abortControllerRef = useRef(null); const onClosed = useStableCallback(() => _onClosed?.()); const onOpening = useStableCallback(() => _onOpening?.()); const onOpen = useStableCallback(() => _onOpen?.()); const onClosing = useStableCallback(() => _onClosing?.()); useEffect(() => { + abortControllerRef.current?.abort(); setState(isOpen ? 'opening' : 'closing'); }, [isOpen]); useEffect(() => { + const abortController = new AbortController(); + abortControllerRef.current = abortController; + async function handle() { switch (state) { case 'closed': @@ -37,7 +42,7 @@ export function useSheetState({ case 'opening': await onOpening?.(); - setState('open'); + if (!abortController.signal.aborted) setState('open'); break; case 'open': @@ -46,7 +51,7 @@ export function useSheetState({ case 'closing': await onClosing?.(); - setState('closed'); + if (!abortController.signal.aborted) setState('closed'); break; } } @@ -55,6 +60,10 @@ export function useSheetState({ console.error('Internal sheet state error:', error); } }); + + return () => { + abortController.abort(); + }; }, [state]); return state; diff --git a/src/sheet.tsx b/src/sheet.tsx index f9a3a8f..801a087 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -87,7 +87,10 @@ export const Sheet = forwardRef( const [sheetBoundsRef, sheetBounds] = useMeasure(); const sheetRef = useRef(null); - const sheetHeight = Math.round(sheetBounds.height); + const sheetHeight = + detent === 'default' || detent === 'full' + ? windowHeight + : Math.round(sheetBounds.height); const [currentSnap, setCurrentSnap] = useState(initialSnap); const safeSpaceTop = @@ -179,6 +182,8 @@ export const Sheet = forwardRef( const snapTo = useStableCallback( async (snapIndex: number, options?: { immediate?: boolean }) => { + if (openStateRef.current !== 'open') return; + if (!snapPointsProp) { console.warn('Snapping is not possible without `snapPoints` prop.'); return; @@ -349,7 +354,9 @@ export const Sheet = forwardRef( isOpen ? 'opening' : 'closed' ); - const currentSnapPoint = currentSnap ? getSnapPoint(currentSnap) : null; + const currentSnapPoint = currentSnap + ? (snapPoints[currentSnap] ?? null) + : null; useImperativeHandle( ref, @@ -415,7 +422,7 @@ export const Sheet = forwardRef( // keep the sheet at the current snap point if it changes const currentSnapPointY = currentSnap - ? getSnapPoint(currentSnap)?.snapValueY + ? snapPoints[currentSnap]?.snapValueY : null; useEffect(() => { if (currentSnapPointY === undefined) return; @@ -440,7 +447,7 @@ export const Sheet = forwardRef( const state = useSheetState({ isOpen, - onOpen: () => { + onOpening: () => { return new Promise((resolve, reject) => { clearYListeners(); @@ -457,25 +464,6 @@ export const Sheet = forwardRef( openStateRef.current = 'open'; }; - yListenersRef.current.push( - y.on('animationCancel', () => { - clearYListeners(); - - if (openStateRef.current === 'opening') { - handleOpenEnd(); - resolve(); - } else { - reject('stopped opening'); - } - }), - y.on('animationComplete', () => { - clearYListeners(); - - handleOpenEnd(); - resolve(); - }) - ); - const doWhenSheetReady = () => { const initialSnapPoint = initialSnap !== undefined ? getSnapPoint(initialSnap) : null; @@ -486,17 +474,30 @@ export const Sheet = forwardRef( initialSnap, snapPoints ); - clearYListeners(); + } + + if (skipOpenAnimation || !initialSnapPoint) { handleOpenEnd(); resolve(); - return; } + if (!initialSnapPoint) return; + if (skipOpenAnimation) { y.set(initialSnapPoint.snapValueY); - handleOpenEnd(); - resolve(); } else { + yListenersRef.current.push( + y.on('animationCancel', () => { + clearYListeners(); + reject('stopped opening'); + }), + y.on('animationComplete', () => { + clearYListeners(); + handleOpenEnd(); + resolve(); + }) + ); + animate(y, initialSnapPoint.snapValueY, animationOptions); } }; From db03c95b8c6f5be762f3518e0e01e5bd517ad245 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Fri, 21 Nov 2025 19:33:51 +0100 Subject: [PATCH 27/44] Disable backdrop userSelect on WebKit --- src/styles.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/styles.ts b/src/styles.ts index baba02d..12098f8 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -23,6 +23,7 @@ export const styles = { height: '100%', touchAction: 'none', userSelect: 'none', + WebkitUserSelect: 'none', }, decorative: { backgroundColor: 'rgba(0, 0, 0, 0.2)', From 8ff8ea08a9198ccdc89b738e1944f9b53c653d28 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Mon, 24 Nov 2025 13:22:39 +0100 Subject: [PATCH 28/44] earlier insets computing --- src/hooks/use-safe-area-insets.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/hooks/use-safe-area-insets.ts b/src/hooks/use-safe-area-insets.ts index e61c9b4..e08ecaf 100644 --- a/src/hooks/use-safe-area-insets.ts +++ b/src/hooks/use-safe-area-insets.ts @@ -4,7 +4,9 @@ import { IS_SSR } from '../constants'; const fallback = { top: 0, left: 0, right: 0, bottom: 0 }; export function useSafeAreaInsets() { - const [insets, setInsets] = useState(fallback); + const [insets, setInsets] = useState(() => + IS_SSR ? fallback : getSafeAreaInsets(createSafeAreaDetector()) + ); useLayoutEffect(() => { if (IS_SSR) return setInsets(fallback); @@ -12,11 +14,12 @@ export function useSafeAreaInsets() { // Create a hidden element that uses safe area insets const safeAreaDetector = createSafeAreaDetector(); - const observer = new ResizeObserver(() => { - const insets = getSafeAreaInsets(safeAreaDetector); - setInsets(insets); - }); + const computeInsets = () => + setInsets(() => getSafeAreaInsets(safeAreaDetector)); + const observer = new ResizeObserver(computeInsets); + + computeInsets(); observer.observe(safeAreaDetector); return () => { From 195fe2ad96dcb712a403775b24ef4f58cbe53448 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Wed, 26 Nov 2025 14:50:23 +0100 Subject: [PATCH 29/44] correctly handle changing sheet y while it's opening --- src/sheet.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/sheet.tsx b/src/sheet.tsx index 801a087..90b42ab 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -182,13 +182,13 @@ export const Sheet = forwardRef( const snapTo = useStableCallback( async (snapIndex: number, options?: { immediate?: boolean }) => { - if (openStateRef.current !== 'open') return; - if (!snapPointsProp) { console.warn('Snapping is not possible without `snapPoints` prop.'); return; } + if (currentSnap === snapIndex) return; + const snapPoint = getSnapPoint(snapIndex); if (snapPoint === null) { @@ -474,22 +474,25 @@ export const Sheet = forwardRef( initialSnap, snapPoints ); - } - - if (skipOpenAnimation || !initialSnapPoint) { handleOpenEnd(); resolve(); + return; } - if (!initialSnapPoint) return; - if (skipOpenAnimation) { + handleOpenEnd(); + resolve(); y.set(initialSnapPoint.snapValueY); } else { yListenersRef.current.push( y.on('animationCancel', () => { clearYListeners(); - reject('stopped opening'); + if (openStateRef.current === 'opening') { + handleOpenEnd(); + resolve(); + } else { + reject('stopped opening'); + } }), y.on('animationComplete', () => { clearYListeners(); From 30e42f7341defc47faeb339263569ac3d174b61c Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Mon, 8 Dec 2025 23:36:27 +0100 Subject: [PATCH 30/44] Keep focused inputs on the view when opening keyboard --- src/hooks/use-scroll-to-focused-input.ts | 208 +++++++++++++++++++++++ src/sheet.tsx | 8 + 2 files changed, 216 insertions(+) create mode 100644 src/hooks/use-scroll-to-focused-input.ts diff --git a/src/hooks/use-scroll-to-focused-input.ts b/src/hooks/use-scroll-to-focused-input.ts new file mode 100644 index 0000000..67d7cf6 --- /dev/null +++ b/src/hooks/use-scroll-to-focused-input.ts @@ -0,0 +1,208 @@ +import { type RefObject, useEffect, useRef } from 'react'; + +type UseScrollToFocusedInputOptions = { + /** + * Ref to the container element that contains the inputs + */ + containerRef: RefObject; + /** + * Whether the keyboard is currently open + */ + isKeyboardOpen: boolean; + /** + * The current keyboard height in pixels + */ + keyboardHeight: number; + /** + * Bottom offset to account for (e.g. safe area + custom spacing) + */ + bottomOffset?: number; +}; + +/** + * Finds the nearest scrollable ancestor of an element + */ +function findScrollableAncestor(element: HTMLElement): HTMLElement | null { + let parent = element.parentElement; + + while (parent) { + const style = getComputedStyle(parent); + const overflowY = style.overflowY; + + if ( + overflowY === 'auto' || + overflowY === 'scroll' || + // Check if element is actually scrollable + (overflowY !== 'hidden' && parent.scrollHeight > parent.clientHeight) + ) { + return parent; + } + + parent = parent.parentElement; + } + + return null; +} + +/** + * Finds the label associated with an input element + */ +function findAssociatedLabel(element: HTMLElement): HTMLElement | null { + // Check if input is wrapped in a label + const parentLabel = element.closest('label'); + if (parentLabel) return parentLabel as HTMLElement; + + // Check for label with matching 'for' attribute + if (element.id) { + const labelFor = document.querySelector( + `label[for="${element.id}"]` + ) as HTMLElement | null; + if (labelFor) return labelFor; + } + + // Check for aria-labelledby + const labelledBy = element.getAttribute('aria-labelledby'); + if (labelledBy) { + const ariaLabel = document.getElementById(labelledBy) as HTMLElement | null; + if (ariaLabel) return ariaLabel; + } + + return null; +} + +/** + * Checks if an element is a text input + */ +function isTextInput(el: Element | null): el is HTMLElement { + return ( + el instanceof HTMLElement && + (el.tagName === 'INPUT' || + el.tagName === 'TEXTAREA' || + el.isContentEditable) + ); +} + +/** + * Scrolls a focused input (and its label) into view, centering it in the + * visible area while respecting scroll bounds. + */ +function scrollFocusedInputIntoView( + element: HTMLElement, + keyboardHeight: number, + bottomOffset: number +) { + requestAnimationFrame(() => { + const inputRect = element.getBoundingClientRect(); + const label = findAssociatedLabel(element); + + // Calculate combined rect including label + let targetTop = inputRect.top; + let targetBottom = inputRect.bottom; + + if (label) { + const labelRect = label.getBoundingClientRect(); + targetTop = Math.min(inputRect.top, labelRect.top); + targetBottom = Math.max(inputRect.bottom, labelRect.bottom); + } + + const scrollContainer = findScrollableAncestor(element); + + if (scrollContainer) { + const containerRect = scrollContainer.getBoundingClientRect(); + + // Account for keyboard height + bottom offset when calculating visible bottom + const effectiveBottomOffset = Math.max(keyboardHeight, bottomOffset); + + // Calculate visible boundaries relative to viewport + const visibleTop = containerRect.top; + const visibleBottom = Math.min( + containerRect.bottom, + window.innerHeight - effectiveBottomOffset + ); + + // Calculate centers for centering logic + const targetCenter = (targetTop + targetBottom) / 2; + const visibleCenter = (visibleTop + visibleBottom) / 2; + + // Calculate ideal scroll to center the element + let scrollOffset = targetCenter - visibleCenter; + + // Clamp scroll offset to prevent overscrolling + const maxScrollDown = + scrollContainer.scrollHeight - + scrollContainer.scrollTop - + scrollContainer.clientHeight; + const maxScrollUp = -scrollContainer.scrollTop; + + scrollOffset = Math.max( + maxScrollUp, + Math.min(maxScrollDown, scrollOffset) + ); + + // Only scroll if there's a meaningful offset + if (Math.abs(scrollOffset) > 1) { + scrollContainer.scrollBy({ + top: scrollOffset, + behavior: 'smooth', + }); + } + } else { + // Fallback to native scrollIntoView + element.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + }); +} + +/** + * Hook that automatically scrolls focused inputs into view when the keyboard + * opens or when focus changes between inputs while the keyboard is open. + */ +export function useScrollToFocusedInput({ + containerRef, + isKeyboardOpen, + keyboardHeight, + bottomOffset = 0, +}: UseScrollToFocusedInputOptions) { + const prevKeyboardOpen = useRef(false); + + useEffect(() => { + const keyboardOpening = isKeyboardOpen && !prevKeyboardOpen.current; + prevKeyboardOpen.current = isKeyboardOpen; + + // Scroll on keyboard open + if (keyboardOpening && containerRef.current) { + const focusedElement = document.activeElement; + if ( + isTextInput(focusedElement) && + containerRef.current.contains(focusedElement) + ) { + scrollFocusedInputIntoView( + focusedElement, + keyboardHeight, + bottomOffset + ); + } + } + + // Listen for focus changes while keyboard is open + if (!isKeyboardOpen) return; + if (!containerRef.current) return; + + const handleFocusIn = (e: FocusEvent) => { + const target = e.target as Element | null; + if (isTextInput(target) && containerRef.current?.contains(target)) { + scrollFocusedInputIntoView(target, keyboardHeight, bottomOffset); + } + }; + + containerRef.current.addEventListener('focusin', handleFocusIn); + const currentContainerRef = containerRef.current; + + return () => { + currentContainerRef.removeEventListener('focusin', handleFocusIn); + }; + }, [isKeyboardOpen, keyboardHeight, bottomOffset, containerRef]); +} diff --git a/src/sheet.tsx b/src/sheet.tsx index 90b42ab..7314903 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -33,6 +33,7 @@ import { useModalEffect } from './hooks/use-modal-effect'; import { usePreventScroll } from './hooks/use-prevent-scroll'; import { useSheetState } from './hooks/use-sheet-state'; import { useStableCallback } from './hooks/use-stable-callback'; +import { useScrollToFocusedInput } from './hooks/use-scroll-to-focused-input'; import { useVirtualKeyboard } from './hooks/use-virtual-keyboard'; import { computeSnapPoints, @@ -420,6 +421,13 @@ export const Sheet = forwardRef( return handleKeyboardOpen(); }, [keyboard.isKeyboardOpen]); + useScrollToFocusedInput({ + containerRef: sheetRef, + isKeyboardOpen: keyboard.isKeyboardOpen, + keyboardHeight: keyboard.keyboardHeight, + bottomOffset: safeAreaInsets.bottom + safeSpaceBottom, + }); + // keep the sheet at the current snap point if it changes const currentSnapPointY = currentSnap ? snapPoints[currentSnap]?.snapValueY From b1e6a517655c046a04778f16ca2a148b3cbd3622 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Mon, 8 Dec 2025 23:36:54 +0100 Subject: [PATCH 31/44] Extract position logic from Container into its own component and introduce renderAbove --- src/SheetContainer.tsx | 104 ++++++++++++++++++++++++++++++----------- src/styles.ts | 13 +++++- src/types.tsx | 3 ++ 3 files changed, 90 insertions(+), 30 deletions(-) diff --git a/src/SheetContainer.tsx b/src/SheetContainer.tsx index 0146c69..590b4e1 100644 --- a/src/SheetContainer.tsx +++ b/src/SheetContainer.tsx @@ -13,11 +13,15 @@ import { type SheetContainerProps } from './types'; import { applyStyles, mergeRefs } from './utils'; import { useDimensions } from './hooks/use-dimensions'; -export const SheetContainer = forwardRef( - ({ children, style, className = '', unstyled, ...rest }, ref) => { +type SheetPositionerProps = { + children: React.ReactNode; +}; + +export const SheetPositioner = forwardRef( + ({ children }, ref) => { const sheetContext = useSheetContext(); - const isUnstyled = unstyled ?? sheetContext.unstyled; + const isUnstyled = sheetContext.unstyled; // y might be negative due to elastic // for a better experience, we clamp the y value to 0 @@ -28,50 +32,36 @@ export const SheetContainer = forwardRef( Math.max(0, val) ); - const { windowHeight } = useDimensions(); - const didHitMaxHeight = - windowHeight - sheetContext.safeSpaceTop <= sheetContext.sheetHeight; - - const containerStyle: MotionStyle = { + const positionerStyle: MotionStyle = { // Use motion template for performant CSS variable updates '--overflow': useMotionTemplate`${sheetContext.yOverflow}px`, - ...applyStyles(styles.container, isUnstyled), - ...style, - ...(isUnstyled - ? { - y, - } - : { - y: nonNegativeY, - // compensate height for the elastic behavior of the sheet - ...(!didHitMaxHeight && { paddingBottom: sheetContext.yOverflow }), - }), + ...applyStyles(styles.positioner, isUnstyled), + ...(isUnstyled ? { y } : { y: nonNegativeY }), } as any; if (sheetContext.detent === 'default') { - containerStyle.height = DEFAULT_HEIGHT; + positionerStyle.height = DEFAULT_HEIGHT; } if (sheetContext.detent === 'full') { - containerStyle.height = '100%'; - containerStyle.maxHeight = '100%'; + positionerStyle.height = '100%'; + positionerStyle.maxHeight = '100%'; } if (sheetContext.detent === 'content') { - containerStyle.height = 'auto'; - containerStyle.maxHeight = `calc(${DEFAULT_HEIGHT} - ${sheetContext.safeSpaceTop}px)`; + positionerStyle.height = 'auto'; + positionerStyle.maxHeight = `calc(${DEFAULT_HEIGHT} - ${sheetContext.safeSpaceTop}px)`; } return ( {children} @@ -79,4 +69,62 @@ export const SheetContainer = forwardRef( } ); +SheetPositioner.displayName = 'SheetPositioner'; + +export const SheetContainer = forwardRef( + ( + { + children, + style, + className = '', + unstyled, + renderAbove, + positionerRef, + ...rest + }, + ref + ) => { + const sheetContext = useSheetContext(); + + const isUnstyled = unstyled ?? sheetContext.unstyled; + + const { windowHeight } = useDimensions(); + const didHitMaxHeight = + windowHeight - sheetContext.safeSpaceTop <= sheetContext.sheetHeight; + + const containerStyle: MotionStyle = { + ...applyStyles(styles.container, isUnstyled), + ...style, + // compensate height for the elastic behavior of the sheet + ...(!didHitMaxHeight && { paddingBottom: sheetContext.yOverflow }), + } as any; + + return ( + + {renderAbove && ( +
+ {renderAbove} +
+ )} + + {children} + +
+ ); + } +); + SheetContainer.displayName = 'SheetContainer'; diff --git a/src/styles.ts b/src/styles.ts index 12098f8..df863ea 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -31,16 +31,25 @@ export const styles = { WebkitTapHighlightColor: 'transparent', }, }, - container: { + positioner: { base: { zIndex: 2, position: 'absolute', left: 0, bottom: 0, + display: 'flex', + flexDirection: 'column', width: '100%', - pointerEvents: 'auto', + pointerEvents: 'none', + }, + decorative: {}, + }, + container: { + base: { display: 'flex', flexDirection: 'column', + pointerEvents: 'auto', + flex: 1, }, decorative: { backgroundColor: '#fff', diff --git a/src/types.tsx b/src/types.tsx index eb8e5cb..1238b1a 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -1,4 +1,5 @@ import { + type Ref, type ComponentPropsWithoutRef, type ForwardRefExoticComponent, type FunctionComponent, @@ -66,6 +67,8 @@ export type SheetProps = { export type SheetContainerProps = MotionCommonProps & CommonProps & { children: ReactNode; + renderAbove?: ReactNode; + positionerRef?: Ref; }; export type SheetHeaderProps = MotionCommonProps & From 68cb76c4efc825fc6da68073e39944c2bf2ee2ca Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Tue, 9 Dec 2025 16:46:56 +0100 Subject: [PATCH 32/44] Animate react-modal-sheet-above enter and exit --- src/SheetContainer.tsx | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/src/SheetContainer.tsx b/src/SheetContainer.tsx index 590b4e1..a891667 100644 --- a/src/SheetContainer.tsx +++ b/src/SheetContainer.tsx @@ -97,22 +97,42 @@ export const SheetContainer = forwardRef( ...style, // compensate height for the elastic behavior of the sheet ...(!didHitMaxHeight && { paddingBottom: sheetContext.yOverflow }), - } as any; + // Ensure the container sits above the "above" element + position: 'relative', + zIndex: 1, + }; + + // Animate the translateY of the above element so it slides down behind the sheet as it closes + // The minimum y value (fully open) depends on the detent type + const minY = + sheetContext.detent === 'full' || sheetContext.detent === 'content' + ? 0 + : sheetContext.safeSpaceTop; + + // When y is at minY (fully open), translateY is -100% (above the sheet) + // As y increases (sheet closing), translateY moves toward 0% (behind the sheet) + const aboveTranslateYPercent = useTransform( + sheetContext.y, + [minY, sheetContext.sheetHeight], + [-100, 0] + ); + const aboveTranslateY = useMotionTemplate`translateY(${aboveTranslateYPercent}%)`; return ( {renderAbove && ( -
{renderAbove} -
+
)} Date: Wed, 10 Dec 2025 17:32:27 +0100 Subject: [PATCH 33/44] Better usage of positioner and container refs --- src/SheetContainer.tsx | 7 ++++--- src/hooks/use-virtual-keyboard.ts | 10 +++++----- src/sheet.tsx | 14 ++++++++------ src/types.tsx | 3 ++- 4 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/SheetContainer.tsx b/src/SheetContainer.tsx index a891667..9a59a7a 100644 --- a/src/SheetContainer.tsx +++ b/src/SheetContainer.tsx @@ -56,7 +56,7 @@ export const SheetPositioner = forwardRef( return ( ( SheetPositioner.displayName = 'SheetPositioner'; -export const SheetContainer = forwardRef( +export const SheetContainer = forwardRef( ( { children, @@ -85,6 +85,7 @@ export const SheetContainer = forwardRef( ref ) => { const sheetContext = useSheetContext(); + const containerRef = sheetContext.containerRef; const isUnstyled = unstyled ?? sheetContext.unstyled; @@ -136,7 +137,7 @@ export const SheetContainer = forwardRef( )} diff --git a/src/hooks/use-virtual-keyboard.ts b/src/hooks/use-virtual-keyboard.ts index 70f3327..c2f6e3a 100644 --- a/src/hooks/use-virtual-keyboard.ts +++ b/src/hooks/use-virtual-keyboard.ts @@ -9,9 +9,9 @@ type VirtualKeyboardState = { type UseVirtualKeyboardOptions = { /** - * Ref to the container element to apply `keyboard-inset-height` CSS variable updates (required) + * Ref to the positioner element to apply `keyboard-inset-height` CSS variable updates (required) */ - containerRef: RefObject; + positionerRef: RefObject; /** * Enable or disable the hook entirely (default: true) */ @@ -31,7 +31,7 @@ type UseVirtualKeyboardOptions = { }; export function useVirtualKeyboard({ - containerRef, + positionerRef, isEnabled = true, debounceDelay = 100, includeContentEditable = true, @@ -62,7 +62,7 @@ export function useVirtualKeyboard({ const vk = (navigator as any).virtualKeyboard; function setKeyboardInsetHeightEnv(height: number) { - containerRef.current?.style.setProperty( + positionerRef.current?.style.setProperty( '--keyboard-inset-height', // Safari 26 uses a floating address bar when keyboard is open that occludes the bottom of the sheet // and its height is not considered in the visual viewport. It is estimated to be 25px. @@ -74,7 +74,7 @@ export function useVirtualKeyboard({ if ( e.target instanceof HTMLElement && isTextInput(e.target) && - containerRef.current?.contains(e.target) + positionerRef.current?.contains(e.target) ) { focusedElementRef.current = e.target; updateKeyboardState(); diff --git a/src/sheet.tsx b/src/sheet.tsx index 7314903..041bf42 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -87,7 +87,8 @@ export const Sheet = forwardRef( const safeAreaInsets = useSafeAreaInsets(); const [sheetBoundsRef, sheetBounds] = useMeasure(); - const sheetRef = useRef(null); + const positionerRef = useRef(null); + const containerRef = useRef(null); const sheetHeight = detent === 'default' || detent === 'full' ? windowHeight @@ -146,7 +147,7 @@ export const Sheet = forwardRef( const keyboard = useVirtualKeyboard({ isEnabled: isOpen && avoidKeyboard, - containerRef: sheetRef, + positionerRef, debounceDelay: 0, }); @@ -219,14 +220,14 @@ export const Sheet = forwardRef( // Find focused input inside the sheet and blur it when dragging starts // to prevent a weird ghost caret "bug" on mobile const focusedElement = document.activeElement as HTMLElement | null; - if (!focusedElement || !sheetRef.current) return; + if (!focusedElement || !positionerRef.current) return; const isInput = focusedElement.tagName === 'INPUT' || focusedElement.tagName === 'TEXTAREA'; // Only blur the focused element if it's inside the sheet - if (isInput && sheetRef.current.contains(focusedElement)) { + if (isInput && positionerRef.current.contains(focusedElement)) { focusedElement.blur(); } }); @@ -422,7 +423,7 @@ export const Sheet = forwardRef( }, [keyboard.isKeyboardOpen]); useScrollToFocusedInput({ - containerRef: sheetRef, + containerRef, isKeyboardOpen: keyboard.isKeyboardOpen, keyboardHeight: keyboard.keyboardHeight, bottomOffset: safeAreaInsets.bottom + safeSpaceBottom, @@ -586,7 +587,8 @@ export const Sheet = forwardRef( indicatorRotation, avoidKeyboard, sheetBoundsRef, - sheetRef, + positionerRef, + containerRef, unstyled, y, yOverflow, diff --git a/src/types.tsx b/src/types.tsx index 1238b1a..e529610 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -122,7 +122,8 @@ export interface SheetContextType { indicatorRotation: MotionValue; avoidKeyboard: boolean; sheetBoundsRef: (node: HTMLDivElement | null) => void; - sheetRef: RefObject; + positionerRef: RefObject; + containerRef: RefObject; unstyled: boolean; y: MotionValue; yOverflow: MotionValue; From 61f6ef2c026bfbb0f105b123e8f55d6bc622596d Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Wed, 10 Dec 2025 17:33:13 +0100 Subject: [PATCH 34/44] Add disableClose property. Handle close on escape. --- src/sheet.tsx | 35 +++++++++++++++++++++++++++++++++-- src/types.tsx | 2 ++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/sheet.tsx b/src/sheet.tsx index 041bf42..bc105e9 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -53,6 +53,7 @@ export const Sheet = forwardRef( className = '', detent = 'default', disableDismiss = false, + disableClose = false, disableDrag: disableDragProp = false, disableScrollLocking = false, dragCloseThreshold = DEFAULT_DRAG_CLOSE_THRESHOLD, @@ -79,6 +80,7 @@ export const Sheet = forwardRef( onDragEnd: onDragEndProp, onKeyboardOpen, skipOpenAnimation = false, + inert, ...rest }, ref @@ -156,10 +158,10 @@ export const Sheet = forwardRef( // +2 for tolerance in case the animated value is slightly off const zIndex = useTransform(y, (val) => - val + 2 >= closedY ? -1 : (style?.zIndex ?? 9999) + val >= closedY ? -1 : (style?.zIndex ?? 9999) ); const visibility = useTransform(y, (val) => - val + 2 >= closedY ? 'hidden' : 'visible' + val >= closedY ? 'hidden' : 'visible' ); const updateSnap = useStableCallback((snapIndex: number) => { @@ -448,6 +450,34 @@ export const Sheet = forwardRef( isDisabled: disableScrollLocking || !isOpen, }); + // Close the sheet when the escape key is pressed + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (inert === '') return; + + const visibilityValue = visibility.get(); + if (visibilityValue === 'hidden') return; + + if (disableClose) { + const isLastSnapPoint = currentSnap === lastSnapPointIndex; + if (isLastSnapPoint) { + event.preventDefault(); + snapTo(1); + } + } else { + event.preventDefault(); + onClose(); + } + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isOpen, disableClose, onClose, visibility, inert]); + const yListenersRef = useRef([]); const clearYListeners = useStableCallback(() => { yListenersRef.current.forEach((listener) => listener()); @@ -602,6 +632,7 @@ export const Sheet = forwardRef( void; onOpenStart?: () => void; onSnap?: (index: number) => void; + inert?: ''; } & MotionCommonProps; export type SheetContainerProps = MotionCommonProps & From aaba1859d1f568ed89fa64351fdc9cdd77552fd3 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Wed, 10 Dec 2025 20:47:29 +0100 Subject: [PATCH 35/44] better opening and closing --- src/hooks/use-sheet-state.ts | 28 +++++--------------- src/sheet.tsx | 51 ++++++++++++++++++++++++------------ 2 files changed, 41 insertions(+), 38 deletions(-) diff --git a/src/hooks/use-sheet-state.ts b/src/hooks/use-sheet-state.ts index 89eac8c..16e05be 100644 --- a/src/hooks/use-sheet-state.ts +++ b/src/hooks/use-sheet-state.ts @@ -5,56 +5,42 @@ type SheetState = 'closed' | 'opening' | 'open' | 'closing'; type UseSheetStatesProps = { isOpen: boolean; - onClosed?: () => Promise | void; onOpening?: () => Promise | void; - onOpen?: () => Promise | void; onClosing?: () => Promise | void; }; export function useSheetState({ isOpen, - onClosed: _onClosed, onOpening: _onOpening, - onOpen: _onOpen, onClosing: _onClosing, }: UseSheetStatesProps) { const [state, setState] = useState(isOpen ? 'opening' : 'closed'); const abortControllerRef = useRef(null); - const onClosed = useStableCallback(() => _onClosed?.()); const onOpening = useStableCallback(() => _onOpening?.()); - const onOpen = useStableCallback(() => _onOpen?.()); const onClosing = useStableCallback(() => _onClosing?.()); useEffect(() => { abortControllerRef.current?.abort(); - setState(isOpen ? 'opening' : 'closing'); - }, [isOpen]); - useEffect(() => { const abortController = new AbortController(); abortControllerRef.current = abortController; async function handle() { - switch (state) { - case 'closed': - await onClosed?.(); - break; - - case 'opening': + switch (isOpen) { + case true: + setState('opening'); await onOpening?.(); if (!abortController.signal.aborted) setState('open'); break; - case 'open': - await onOpen?.(); - break; - - case 'closing': + case false: + setState('closing'); await onClosing?.(); if (!abortController.signal.aborted) setState('closed'); break; } } + handle().catch((error) => { if (error instanceof Error) { console.error('Internal sheet state error:', error); @@ -64,7 +50,7 @@ export function useSheetState({ return () => { abortController.abort(); }; - }, [state]); + }, [isOpen]); return state; } diff --git a/src/sheet.tsx b/src/sheet.tsx index bc105e9..03efc05 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -507,14 +507,19 @@ export const Sheet = forwardRef( const initialSnapPoint = initialSnap !== undefined ? getSnapPoint(initialSnap) : null; + const onAnimationComplete = makeCallableSingleTime(() => { + clearYListeners(); + handleOpenEnd(); + resolve(); + }); + if (!initialSnapPoint) { console.warn( 'No initial snap point found', initialSnap, snapPoints ); - handleOpenEnd(); - resolve(); + onAnimationComplete(); return; } @@ -527,20 +532,17 @@ export const Sheet = forwardRef( y.on('animationCancel', () => { clearYListeners(); if (openStateRef.current === 'opening') { - handleOpenEnd(); - resolve(); + onAnimationComplete(); } else { reject('stopped opening'); } }), - y.on('animationComplete', () => { - clearYListeners(); - handleOpenEnd(); - resolve(); - }) + y.on('animationComplete', onAnimationComplete) ); - animate(y, initialSnapPoint.snapValueY, animationOptions); + animate(y, initialSnapPoint.snapValueY, animationOptions).then( + onAnimationComplete + ); } }; @@ -575,30 +577,36 @@ export const Sheet = forwardRef( openStateRef.current = 'closed'; }; + const onAnimationComplete = makeCallableSingleTime(() => { + clearYListeners(); + handleCloseEnd(); + resolve(); + }); + yListenersRef.current.push( y.on('animationCancel', () => { clearYListeners(); if (openStateRef.current === 'closing') { - handleCloseEnd(); - resolve(); + onAnimationComplete(); } else { reject('stopped closing'); } }), y.on('animationComplete', () => { - clearYListeners(); - - handleCloseEnd(); - resolve(); + onAnimationComplete(); }) ); - animate(y, closedY, animationOptions); + animate(y, closedY, animationOptions).then(() => { + onAnimationComplete(); + }); }); }, }); + console.log(rest.id, 'isOpen', isOpen, 'state', state); + const dragProps: SheetContextType['dragProps'] = { drag: 'y', dragElastic: 0, @@ -668,3 +676,12 @@ function linear( ); return outputMin + (outputMax - outputMin) * t; } + +function makeCallableSingleTime(fn: () => T) { + let called = false; + return () => { + if (called) return; + called = true; + fn(); + }; +} From 0f797dffcea78d021d824e2ac26870fc3b759179 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Wed, 10 Dec 2025 21:07:22 +0100 Subject: [PATCH 36/44] add support for disabling closing sheet on escape --- src/sheet.tsx | 12 ++++++++++-- src/types.tsx | 1 + 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/sheet.tsx b/src/sheet.tsx index 03efc05..f842a7b 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -56,6 +56,7 @@ export const Sheet = forwardRef( disableClose = false, disableDrag: disableDragProp = false, disableScrollLocking = false, + disableCloseOnEscape = false, dragCloseThreshold = DEFAULT_DRAG_CLOSE_THRESHOLD, dragVelocityThreshold = DEFAULT_DRAG_VELOCITY_THRESHOLD, initialSnap, @@ -452,7 +453,7 @@ export const Sheet = forwardRef( // Close the sheet when the escape key is pressed useEffect(() => { - if (!isOpen) return; + if (!isOpen || disableCloseOnEscape) return; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { @@ -476,7 +477,14 @@ export const Sheet = forwardRef( document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [isOpen, disableClose, onClose, visibility, inert]); + }, [ + disableCloseOnEscape, + isOpen, + disableClose, + onClose, + visibility, + inert, + ]); const yListenersRef = useRef([]); const clearYListeners = useStableCallback(() => { diff --git a/src/types.tsx b/src/types.tsx index 6402ad2..97ec933 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -45,6 +45,7 @@ export type SheetProps = { disableClose?: boolean; disableDrag?: boolean; disableScrollLocking?: boolean; + disableCloseOnEscape?: boolean; dragCloseThreshold?: number; dragVelocityThreshold?: number; safeSpace?: Partial<{ top: number; bottom: number }>; // pixels From c63c83eacf54ad4c1de5fcc580e77a799f26bc30 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Thu, 11 Dec 2025 11:24:27 +0100 Subject: [PATCH 37/44] remove console.log --- src/sheet.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/sheet.tsx b/src/sheet.tsx index f842a7b..5a81c2c 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -613,8 +613,6 @@ export const Sheet = forwardRef( }, }); - console.log(rest.id, 'isOpen', isOpen, 'state', state); - const dragProps: SheetContextType['dragProps'] = { drag: 'y', dragElastic: 0, From f68addefb17101c1992bd52a3aa5828073bd88b9 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Thu, 11 Dec 2025 16:28:25 +0100 Subject: [PATCH 38/44] remove disableClose --- src/sheet.tsx | 22 +++------------------- src/types.tsx | 1 - 2 files changed, 3 insertions(+), 20 deletions(-) diff --git a/src/sheet.tsx b/src/sheet.tsx index 5a81c2c..19d751f 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -53,7 +53,6 @@ export const Sheet = forwardRef( className = '', detent = 'default', disableDismiss = false, - disableClose = false, disableDrag: disableDragProp = false, disableScrollLocking = false, disableCloseOnEscape = false, @@ -462,29 +461,14 @@ export const Sheet = forwardRef( const visibilityValue = visibility.get(); if (visibilityValue === 'hidden') return; - if (disableClose) { - const isLastSnapPoint = currentSnap === lastSnapPointIndex; - if (isLastSnapPoint) { - event.preventDefault(); - snapTo(1); - } - } else { - event.preventDefault(); - onClose(); - } + event.preventDefault(); + onClose(); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); - }, [ - disableCloseOnEscape, - isOpen, - disableClose, - onClose, - visibility, - inert, - ]); + }, [disableCloseOnEscape, isOpen, onClose, visibility, inert]); const yListenersRef = useRef([]); const clearYListeners = useStableCallback(() => { diff --git a/src/types.tsx b/src/types.tsx index 97ec933..12a0575 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -42,7 +42,6 @@ export type SheetProps = { children: ReactNode; detent?: SheetDetent; disableDismiss?: boolean; - disableClose?: boolean; disableDrag?: boolean; disableScrollLocking?: boolean; disableCloseOnEscape?: boolean; From be50e1515faf4a3d8614d45c13847e76b7532a33 Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Mon, 15 Dec 2025 11:56:12 +0100 Subject: [PATCH 39/44] Fix issue with scrolling to focused element when focus changes programatically --- package-lock.json | 231 ++++++++++++++++++++++- package.json | 3 +- src/hooks/use-scroll-to-focused-input.ts | 6 +- 3 files changed, 236 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index d93f202..f338133 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,8 @@ "tsup": "8.4.0", "typescript": "5.8.3", "vite": "6.3.3", - "vitest": "3.1.2" + "vitest": "3.1.2", + "yalc": "1.0.0-pre.53" }, "engines": { "node": ">=18" @@ -2447,6 +2448,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/detect-indent": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-6.1.0.tgz", + "integrity": "sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -2883,6 +2894,28 @@ } } }, + "node_modules/fs-extra": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", + "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3277,6 +3310,55 @@ "node": ">=0.10.0" } }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/ignore-walk": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-3.0.4.tgz", + "integrity": "sha512-PY6Ii8o1jMRA1z4F2hRkH/xN59ox43DavKvD3oDpfurRlOJyAHpifIwpbdv1n4jt4ov0jSpw3kQ4GhJnpBL6WQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "minimatch": "^3.0.4" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ini": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -3774,6 +3856,16 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -4076,6 +4168,64 @@ "semver": "bin/semver" } }, + "node_modules/npm-bundled": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-1.1.2.tgz", + "integrity": "sha512-x5DHup0SuyQcmL3s7Rx/YQ8sbw/Hzg0rj48eN0dV7hf5cmQq5PXIeioroH3raV1QC1yh3uTYuMThvEQF3iKgGQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "npm-normalize-package-bin": "^1.0.1" + } + }, + "node_modules/npm-normalize-package-bin": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", + "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==", + "dev": true, + "license": "ISC" + }, + "node_modules/npm-packlist": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-2.2.2.tgz", + "integrity": "sha512-Jt01acDvJRhJGthnUJVF/w6gumWOZxO7IkpY/lsX9//zqQgnF7OJaxgQXcerd4uQOLu7W5bkb4mChL9mdfm+Zg==", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.6", + "ignore-walk": "^3.0.3", + "npm-bundled": "^1.1.1", + "npm-normalize-package-bin": "^1.0.1" + }, + "bin": { + "npm-packlist": "bin/index.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/npm-packlist/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/npm-run-all": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/npm-run-all/-/npm-run-all-4.1.5.tgz", @@ -4231,6 +4381,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -4282,6 +4442,16 @@ "dev": true, "license": "MIT" }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", @@ -5633,6 +5803,16 @@ "node": ">=4" } }, + "node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", @@ -6123,6 +6303,13 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, "node_modules/ws": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", @@ -6172,6 +6359,48 @@ "node": ">=10" } }, + "node_modules/yalc": { + "version": "1.0.0-pre.53", + "resolved": "https://registry.npmjs.org/yalc/-/yalc-1.0.0-pre.53.tgz", + "integrity": "sha512-tpNqBCpTXplnduzw5XC+FF8zNJ9L/UXmvQyyQj7NKrDNavbJtHvzmZplL5ES/RCnjX7JR7W9wz5GVDXVP3dHUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "detect-indent": "^6.0.0", + "fs-extra": "^8.0.1", + "glob": "^7.1.4", + "ignore": "^5.0.4", + "ini": "^2.0.0", + "npm-packlist": "^2.1.5", + "yargs": "^16.1.1" + }, + "bin": { + "yalc": "src/yalc.js" + } + }, + "node_modules/yalc/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/package.json b/package.json index f7b5cc9..1bb89b2 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,8 @@ "tsup": "8.4.0", "typescript": "5.8.3", "vite": "6.3.3", - "vitest": "3.1.2" + "vitest": "3.1.2", + "yalc": "1.0.0-pre.53" }, "engines": { "node": ">=18" diff --git a/src/hooks/use-scroll-to-focused-input.ts b/src/hooks/use-scroll-to-focused-input.ts index 67d7cf6..540fa27 100644 --- a/src/hooks/use-scroll-to-focused-input.ts +++ b/src/hooks/use-scroll-to-focused-input.ts @@ -91,7 +91,9 @@ function scrollFocusedInputIntoView( keyboardHeight: number, bottomOffset: number ) { - requestAnimationFrame(() => { + // setTimeout instead of requestAnimationFrame is required otherwise the + // scrolling doesn't work if you switch from one field to another. + setTimeout(() => { const inputRect = element.getBoundingClientRect(); const label = findAssociatedLabel(element); @@ -153,7 +155,7 @@ function scrollFocusedInputIntoView( block: 'center', }); } - }); + }, 0); } /** From 53895550aee33107b5ce639d95a524a6bdb10019 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Mon, 15 Dec 2025 13:26:30 +0100 Subject: [PATCH 40/44] Prevent hitting input when scrolling --- src/hooks/use-prevent-scroll.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/hooks/use-prevent-scroll.ts b/src/hooks/use-prevent-scroll.ts index 1c990f6..3f9ca58 100644 --- a/src/hooks/use-prevent-scroll.ts +++ b/src/hooks/use-prevent-scroll.ts @@ -154,10 +154,16 @@ function preventScrollStandard() { function preventScrollMobileSafari() { let scrollable: Element | undefined; let lastY = 0; + // Track if the user moved their finger during the touch gesture (scroll vs tap) + let didScroll = false; const onTouchStart = (e: TouchEvent) => { // Use `composedPath` to support shadow DOM. const target = e.composedPath()?.[0] as HTMLElement; + + // Reset scroll tracking for new gesture + didScroll = false; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; // Store the nearest scrollable parent element from the element that the user touched. @@ -174,6 +180,9 @@ function preventScrollMobileSafari() { }; const onTouchMove = (e: TouchEvent) => { + // Mark that user is scrolling (not tapping) - must be before any returns + didScroll = true; + // In special situations, `onTouchStart` may be called without `onTouchStart` being called. // (e.g. when the user places a finger on the screen before the is mounted and then moves the finger after it is mounted). // If `onTouchStart` is not called, `scrollable` is `undefined`. Therefore, such cases are ignored. @@ -214,6 +223,11 @@ function preventScrollMobileSafari() { // Use `composedPath` to support shadow DOM. const target = e.composedPath()?.[0] as HTMLElement; + // Skip focusing if user was scrolling, not tapping + if (didScroll) { + return; + } + // Apply this change if we're not already focused on the target element if (willOpenKeyboard(target) && target !== document.activeElement) { e.preventDefault(); From 8bc85e91f61c5cb4eb90d59ad7e118a0d0b45a4c Mon Sep 17 00:00:00 2001 From: Tom Hicks Date: Mon, 22 Dec 2025 13:41:40 +0100 Subject: [PATCH 41/44] Ensure sheet content height is constrained --- src/styles.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/styles.ts b/src/styles.ts index df863ea..6b08a91 100644 --- a/src/styles.ts +++ b/src/styles.ts @@ -48,6 +48,7 @@ export const styles = { base: { display: 'flex', flexDirection: 'column', + overflow: 'hidden', pointerEvents: 'auto', flex: 1, }, From 1e6190f01b557c57e49f30d2567c88af6e9dd784 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Thu, 18 Dec 2025 16:38:22 +0100 Subject: [PATCH 42/44] Extract utility isTextInput. Extract useSnapOnFocus for clarity and improves it for smoother experience. --- src/hooks/isTextInput.ts | 12 ++++ src/hooks/use-prevent-scroll.ts | 3 +- src/hooks/use-scroll-to-focused-input.ts | 13 +--- src/hooks/use-snap-on-focus.ts | 81 ++++++++++++++++++++++++ src/hooks/use-virtual-keyboard.ts | 12 +--- src/sheet.tsx | 19 ++++-- 6 files changed, 109 insertions(+), 31 deletions(-) create mode 100644 src/hooks/isTextInput.ts create mode 100644 src/hooks/use-snap-on-focus.ts diff --git a/src/hooks/isTextInput.ts b/src/hooks/isTextInput.ts new file mode 100644 index 0000000..4a401b7 --- /dev/null +++ b/src/hooks/isTextInput.ts @@ -0,0 +1,12 @@ +/** + * Checks if an element is a text input + */ + +export function isTextInput(el: Element | null): el is HTMLElement { + return ( + el instanceof HTMLElement && + (el.tagName === 'INPUT' || + el.tagName === 'TEXTAREA' || + el.isContentEditable) + ); +} diff --git a/src/hooks/use-prevent-scroll.ts b/src/hooks/use-prevent-scroll.ts index 3f9ca58..afa92f1 100644 --- a/src/hooks/use-prevent-scroll.ts +++ b/src/hooks/use-prevent-scroll.ts @@ -2,6 +2,7 @@ import { useIsomorphicLayoutEffect } from './use-isomorphic-layout-effect'; import { isIOS } from '../utils'; +import { isTextInput } from './isTextInput'; const KEYBOARD_BUFFER = 24; @@ -164,7 +165,7 @@ function preventScrollMobileSafari() { // Reset scroll tracking for new gesture didScroll = false; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return; + if (isTextInput(target)) return; // Store the nearest scrollable parent element from the element that the user touched. scrollable = getScrollParent(target, true); diff --git a/src/hooks/use-scroll-to-focused-input.ts b/src/hooks/use-scroll-to-focused-input.ts index 540fa27..c18ab06 100644 --- a/src/hooks/use-scroll-to-focused-input.ts +++ b/src/hooks/use-scroll-to-focused-input.ts @@ -1,4 +1,5 @@ import { type RefObject, useEffect, useRef } from 'react'; +import { isTextInput } from './isTextInput'; type UseScrollToFocusedInputOptions = { /** @@ -70,18 +71,6 @@ function findAssociatedLabel(element: HTMLElement): HTMLElement | null { return null; } -/** - * Checks if an element is a text input - */ -function isTextInput(el: Element | null): el is HTMLElement { - return ( - el instanceof HTMLElement && - (el.tagName === 'INPUT' || - el.tagName === 'TEXTAREA' || - el.isContentEditable) - ); -} - /** * Scrolls a focused input (and its label) into view, centering it in the * visible area while respecting scroll bounds. diff --git a/src/hooks/use-snap-on-focus.ts b/src/hooks/use-snap-on-focus.ts new file mode 100644 index 0000000..1f3fd8a --- /dev/null +++ b/src/hooks/use-snap-on-focus.ts @@ -0,0 +1,81 @@ +import { useEffect, type RefObject } from 'react'; +import { isTextInput } from './isTextInput'; + +type UseSnapOnFocusOptions = { + /** Ref to the container element to listen for focus events */ + containerRef: RefObject; + /** Whether the sheet is open */ + isOpen: boolean; + /** Current snap point index */ + currentSnap: number | undefined; + /** The last (full height) snap point index */ + lastSnapPointIndex: number; + /** Whether the hook is enabled */ + isEnabled: boolean; + /** Callback to snap to full height, returns a cleanup function to restore */ + onSnapToFull: (() => VoidFunction) | (() => void); +}; + +/** + * Snaps the sheet to full height when an input/textarea inside receives focus. + * This happens before the keyboard opens, providing a smoother experience. + */ +export function useSnapOnFocus({ + containerRef, + isOpen, + currentSnap, + lastSnapPointIndex, + isEnabled, + onSnapToFull, +}: UseSnapOnFocusOptions) { + useEffect(() => { + if (!isOpen) return; + if (!isEnabled) return; + + const container = containerRef.current; + if (!container) return; + + let cleanup: (() => void) | undefined; + + const handleFocusIn = (event: FocusEvent) => { + const target = event.target as HTMLElement | null; + if (!target) return; + + if (!isTextInput(target)) return; + + // Already at full height, nothing to do + if (currentSnap === lastSnapPointIndex) return; + + cleanup = onSnapToFull() ?? undefined; + }; + + const handleFocusOut = (event: FocusEvent) => { + const relatedTarget = event.relatedTarget as HTMLElement | null; + + // If focus is moving to another input inside the container, don't restore + if (relatedTarget && container.contains(relatedTarget)) { + if (!isTextInput(relatedTarget)) return; + } + + // Focus left the container or moved to a non-input element, restore snap + cleanup?.(); + cleanup = undefined; + }; + + container.addEventListener('focusin', handleFocusIn); + container.addEventListener('focusout', handleFocusOut); + + return () => { + container.removeEventListener('focusin', handleFocusIn); + container.removeEventListener('focusout', handleFocusOut); + cleanup?.(); + }; + }, [ + isOpen, + isEnabled, + containerRef, + currentSnap, + lastSnapPointIndex, + onSnapToFull, + ]); +} diff --git a/src/hooks/use-virtual-keyboard.ts b/src/hooks/use-virtual-keyboard.ts index c2f6e3a..5d222de 100644 --- a/src/hooks/use-virtual-keyboard.ts +++ b/src/hooks/use-virtual-keyboard.ts @@ -1,6 +1,6 @@ import { type RefObject, useEffect, useRef, useState } from 'react'; -import { useStableCallback } from './use-stable-callback'; import { isIOSSafari26 } from '../utils'; +import { isTextInput } from './isTextInput'; type VirtualKeyboardState = { isVisible: boolean; @@ -45,16 +45,6 @@ export function useVirtualKeyboard({ const focusedElementRef = useRef(null); const debounceTimer = useRef | null>(null); - const isTextInput = useStableCallback((el: Element | null) => { - return ( - el?.tagName === 'INPUT' || - el?.tagName === 'TEXTAREA' || - (includeContentEditable && - el instanceof HTMLElement && - el.isContentEditable) - ); - }); - useEffect(() => { if (!isEnabled) return; diff --git a/src/sheet.tsx b/src/sheet.tsx index 19d751f..4a044c8 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -34,6 +34,7 @@ import { usePreventScroll } from './hooks/use-prevent-scroll'; import { useSheetState } from './hooks/use-sheet-state'; import { useStableCallback } from './hooks/use-stable-callback'; import { useScrollToFocusedInput } from './hooks/use-scroll-to-focused-input'; +import { useSnapOnFocus } from './hooks/use-snap-on-focus'; import { useVirtualKeyboard } from './hooks/use-virtual-keyboard'; import { computeSnapPoints, @@ -417,12 +418,14 @@ export const Sheet = forwardRef( return onKeyboardOpen(); }); - useEffect(() => { - if (openStateRef.current !== 'open') return; - if (detent !== 'default') return; - if (!keyboard.isKeyboardOpen) return; - return handleKeyboardOpen(); - }, [keyboard.isKeyboardOpen]); + useSnapOnFocus({ + containerRef: positionerRef, + isOpen: openStateRef.current === 'open', + currentSnap, + lastSnapPointIndex, + isEnabled: detent === 'default', + onSnapToFull: handleKeyboardOpen, + }); useScrollToFocusedInput({ containerRef, @@ -491,8 +494,10 @@ export const Sheet = forwardRef( updateSnap(initialSnap); } - onOpenEnd?.(); openStateRef.current = 'open'; + requestAnimationFrame(() => { + onOpenEnd?.(); + }); }; const doWhenSheetReady = () => { From bd28f9794a22b7319b1870c71f56673b7ad09e23 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Tue, 30 Dec 2025 18:08:21 +0100 Subject: [PATCH 43/44] introduce content-fixed: sheet height doesnt change after initial render --- src/SheetContainer.tsx | 15 ++++++++++++++- src/sheet.tsx | 38 +++++++++++++++++++++++++++++++++----- src/types.tsx | 4 +++- 3 files changed, 50 insertions(+), 7 deletions(-) diff --git a/src/SheetContainer.tsx b/src/SheetContainer.tsx index 9a59a7a..42325aa 100644 --- a/src/SheetContainer.tsx +++ b/src/SheetContainer.tsx @@ -53,6 +53,16 @@ export const SheetPositioner = forwardRef( positionerStyle.maxHeight = `calc(${DEFAULT_HEIGHT} - ${sheetContext.safeSpaceTop}px)`; } + if (sheetContext.detent === 'content-fixed') { + // Use locked height if available, otherwise auto (during initial measurement) + if (sheetContext.lockedContentHeight !== null) { + positionerStyle.height = `${sheetContext.lockedContentHeight}px`; + } else { + positionerStyle.height = 'auto'; + } + positionerStyle.maxHeight = `calc(${DEFAULT_HEIGHT} - ${sheetContext.safeSpaceTop}px)`; + } + return ( ( // Animate the translateY of the above element so it slides down behind the sheet as it closes // The minimum y value (fully open) depends on the detent type + const isContentDetent = + sheetContext.detent === 'content' || + sheetContext.detent === 'content-fixed'; const minY = - sheetContext.detent === 'full' || sheetContext.detent === 'content' + sheetContext.detent === 'full' || isContentDetent ? 0 : sheetContext.safeSpaceTop; diff --git a/src/sheet.tsx b/src/sheet.tsx index 4a044c8..c37e8ad 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -92,11 +92,25 @@ export const Sheet = forwardRef( const [sheetBoundsRef, sheetBounds] = useMeasure(); const positionerRef = useRef(null); const containerRef = useRef(null); + const [currentSnap, setCurrentSnap] = useState(initialSnap); + + // For content-fixed detent, lock the height once the sheet opens + const [lockedContentHeight, setLockedContentHeight] = useState< + number | null + >(null); + + const measuredContentHeight = Math.round(sheetBounds.height); + // Keep a ref to access current measured height in callbacks + const measuredContentHeightRef = useRef(measuredContentHeight); + measuredContentHeightRef.current = measuredContentHeight; const sheetHeight = detent === 'default' || detent === 'full' ? windowHeight - : Math.round(sheetBounds.height); - const [currentSnap, setCurrentSnap] = useState(initialSnap); + : detent === 'content-fixed' && lockedContentHeight !== null + ? lockedContentHeight + : measuredContentHeight; + + const isContentDetent = detent === 'content' || detent === 'content-fixed'; const safeSpaceTop = detent === 'full' ? 0 : (safeSpaceProp?.top ?? DEFAULT_TOP_CONSTRAINT); @@ -109,13 +123,13 @@ export const Sheet = forwardRef( const maxSnapValueOnDefaultDetent = windowHeight - safeSpaceTop - safeAreaInsets.top; const maxSnapValue = - detent === 'full' || detent === 'content' + detent === 'full' || isContentDetent ? windowHeight : maxSnapValueOnDefaultDetent; const dragConstraints: Axis = { min: - detent === 'full' || detent === 'content' + detent === 'full' || isContentDetent ? 0 : safeSpaceTop + safeAreaInsets.top, // top constraint (applied through sheet height instead) max: windowHeight - safeSpaceBottom - safeAreaInsets.bottom, // bottom constraint @@ -494,6 +508,13 @@ export const Sheet = forwardRef( updateSnap(initialSnap); } + // Lock the content height for content-fixed detent to prevent resizing + // Use ref to get current measured height (not stale closure value) + const currentMeasuredHeight = measuredContentHeightRef.current; + if (detent === 'content-fixed' && currentMeasuredHeight > 0) { + setLockedContentHeight(currentMeasuredHeight); + } + openStateRef.current = 'open'; requestAnimationFrame(() => { onOpenEnd?.(); @@ -548,7 +569,7 @@ export const Sheet = forwardRef( * but we need to wait for the sheet to be rendered and visible * before we can measure it and animate it to the initial snap point. */ - if (detent === 'content') { + if (isContentDetent) { waitForElement('react-modal-sheet-container').then( doWhenSheetReady ); @@ -566,6 +587,11 @@ export const Sheet = forwardRef( onCloseStart?.(); const handleCloseEnd = () => { + // Reset locked content height for content-fixed detent + if (detent === 'content-fixed') { + setLockedContentHeight(null); + } + if (onCloseEnd) { // waiting a frame to ensure the sheet is fully closed // otherwise it was causing some issue with AnimatePresence's safeToRemove @@ -628,6 +654,7 @@ export const Sheet = forwardRef( sheetHeight, safeSpaceTop: safeSpaceTop + safeAreaInsets.top, safeSpaceBottom: safeSpaceBottom + safeAreaInsets.bottom, + lockedContentHeight, }; const sheet = ( @@ -637,6 +664,7 @@ export const Sheet = forwardRef( ref={ref} inert={inert} data-sheet-state={state} + data-sheet-detent={detent} className={`react-modal-sheet-root ${className}`} style={{ ...applyStyles(styles.root, unstyled), diff --git a/src/types.tsx b/src/types.tsx index 12a0575..7c34731 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -16,7 +16,7 @@ import { type motion, } from 'motion/react'; -export type SheetDetent = 'default' | 'full' | 'content'; +export type SheetDetent = 'default' | 'full' | 'content' | 'content-fixed'; type CommonProps = { className?: string; @@ -132,6 +132,8 @@ export interface SheetContextType { sheetHeight: number; safeSpaceTop: number; safeSpaceBottom: number; + /** For content-fixed detent: the locked height once the sheet opens */ + lockedContentHeight: number | null; } export interface SheetScrollerContextType { From b82fb79f4ea62256ae77d86ff5ce24a7e2b1e834 Mon Sep 17 00:00:00 2001 From: Sergio Clebal Date: Mon, 5 Jan 2026 13:41:08 +0100 Subject: [PATCH 44/44] rename content-fixed to initial-content --- src/SheetContainer.tsx | 4 ++-- src/sheet.tsx | 15 ++++++++------- src/types.tsx | 4 ++-- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/SheetContainer.tsx b/src/SheetContainer.tsx index 42325aa..6da269b 100644 --- a/src/SheetContainer.tsx +++ b/src/SheetContainer.tsx @@ -53,7 +53,7 @@ export const SheetPositioner = forwardRef( positionerStyle.maxHeight = `calc(${DEFAULT_HEIGHT} - ${sheetContext.safeSpaceTop}px)`; } - if (sheetContext.detent === 'content-fixed') { + if (sheetContext.detent === 'initial-content') { // Use locked height if available, otherwise auto (during initial measurement) if (sheetContext.lockedContentHeight !== null) { positionerStyle.height = `${sheetContext.lockedContentHeight}px`; @@ -117,7 +117,7 @@ export const SheetContainer = forwardRef( // The minimum y value (fully open) depends on the detent type const isContentDetent = sheetContext.detent === 'content' || - sheetContext.detent === 'content-fixed'; + sheetContext.detent === 'initial-content'; const minY = sheetContext.detent === 'full' || isContentDetent ? 0 diff --git a/src/sheet.tsx b/src/sheet.tsx index c37e8ad..8d8920e 100644 --- a/src/sheet.tsx +++ b/src/sheet.tsx @@ -94,7 +94,7 @@ export const Sheet = forwardRef( const containerRef = useRef(null); const [currentSnap, setCurrentSnap] = useState(initialSnap); - // For content-fixed detent, lock the height once the sheet opens + // For initial-content detent, lock the height once the sheet opens const [lockedContentHeight, setLockedContentHeight] = useState< number | null >(null); @@ -106,11 +106,12 @@ export const Sheet = forwardRef( const sheetHeight = detent === 'default' || detent === 'full' ? windowHeight - : detent === 'content-fixed' && lockedContentHeight !== null + : detent === 'initial-content' && lockedContentHeight !== null ? lockedContentHeight : measuredContentHeight; - const isContentDetent = detent === 'content' || detent === 'content-fixed'; + const isContentDetent = + detent === 'content' || detent === 'initial-content'; const safeSpaceTop = detent === 'full' ? 0 : (safeSpaceProp?.top ?? DEFAULT_TOP_CONSTRAINT); @@ -508,10 +509,10 @@ export const Sheet = forwardRef( updateSnap(initialSnap); } - // Lock the content height for content-fixed detent to prevent resizing + // Lock the content height for initial-content detent to prevent resizing // Use ref to get current measured height (not stale closure value) const currentMeasuredHeight = measuredContentHeightRef.current; - if (detent === 'content-fixed' && currentMeasuredHeight > 0) { + if (detent === 'initial-content' && currentMeasuredHeight > 0) { setLockedContentHeight(currentMeasuredHeight); } @@ -587,8 +588,8 @@ export const Sheet = forwardRef( onCloseStart?.(); const handleCloseEnd = () => { - // Reset locked content height for content-fixed detent - if (detent === 'content-fixed') { + // Reset locked content height for initial-content detent + if (detent === 'initial-content') { setLockedContentHeight(null); } diff --git a/src/types.tsx b/src/types.tsx index 7c34731..095c72f 100644 --- a/src/types.tsx +++ b/src/types.tsx @@ -16,7 +16,7 @@ import { type motion, } from 'motion/react'; -export type SheetDetent = 'default' | 'full' | 'content' | 'content-fixed'; +export type SheetDetent = 'default' | 'full' | 'content' | 'initial-content'; type CommonProps = { className?: string; @@ -132,7 +132,7 @@ export interface SheetContextType { sheetHeight: number; safeSpaceTop: number; safeSpaceBottom: number; - /** For content-fixed detent: the locked height once the sheet opens */ + /** For initial-content detent: the locked height once the sheet opens */ lockedContentHeight: number | null; }