From 23aea74a72b8d1b3e211279242914871d6a2701c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 10 Sep 2026 10:36:42 +0000 Subject: [PATCH 1/4] Modal: velocity-aware release spring and rubber band for the swipe gesture The release animation ignored the gesture: closing used a fixed 150ms accelerate transition regardless of the finger speed, and returning to the open position reset the drag offset while the dragging class still disabled transitions, so it snapped back. On release the velocity is measured over the last 100ms of the gesture and the animation is resolved from a critically damped spring starting at that velocity, rendered as a css linear() easing with a matching duration. A flick above 0.4px/ms closes the modal regardless of the distance, a drag past the threshold closes unless the finger was moving back, and everything else returns to the open position through the same spring. Browsers without linear() support fall back to a velocity-scaled duration with the decelerate easing. Dragging past the open position now applies a rubber band with a 40px limit, but only when nothing under the finger could scroll in that direction instead. The css clamps on the drag offset are removed and a solid box-shadow bleed extends the background past the trailing edge so neither the rubber band nor a spring overshoot reveals a gap. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EAi9QQVSFuJ7Gkf8SUTYJq --- .changeset/modal-drag-physics.md | 5 + .../src/components/Modal/Modal.module.css | 24 ++- .../reshaped/src/components/Modal/Modal.tsx | 172 +++++++++++++++--- packages/reshaped/src/utilities/animation.ts | 69 +++++++ 4 files changed, 243 insertions(+), 27 deletions(-) create mode 100644 .changeset/modal-drag-physics.md diff --git a/.changeset/modal-drag-physics.md b/.changeset/modal-drag-physics.md new file mode 100644 index 00000000..65dd110a --- /dev/null +++ b/.changeset/modal-drag-physics.md @@ -0,0 +1,5 @@ +--- +"reshaped": minor +--- + +Improved the `Modal` swipe gesture physics. On release, the closing or returning animation now continues from the current velocity of the gesture using a critically damped spring, so a quick flick closes the modal right away while a slow release settles smoothly, instead of restarting a fixed-duration transition from zero. Dragging the modal past its open position now applies a rubber band effect instead of being ignored, as long as the content under the finger can't scroll in that direction. Returning to the open position is animated as well, where previously it snapped back. diff --git a/packages/reshaped/src/components/Modal/Modal.module.css b/packages/reshaped/src/components/Modal/Modal.module.css index 094dbccc..86aa473a 100644 --- a/packages/reshaped/src/components/Modal/Modal.module.css +++ b/packages/reshaped/src/components/Modal/Modal.module.css @@ -2,6 +2,9 @@ --rs-modal-container-width: 100vw; --rs-modal-container-height: 100vh; + /* Extends the background past the trailing edge so no gap is visible when the modal is rubber-banded or overshoots */ + --rs-modal-bleed: calc(var(--rs-unit-x1) * 25); + transition: var(--rs-duration-fast) var(--rs-easing-accelerate); transition-property: transform, opacity; background: var(--rs-color-background-elevation-overlay); @@ -66,10 +69,13 @@ overflow: auto; overscroll-behavior: none; opacity: 1; + box-shadow: + 0 var(--rs-modal-bleed) 0 0 var(--rs-color-background-elevation-overlay), + var(--rs-shadow-overlay); &.--active, [dir="rtl"] &.--active { - transform: translate(0, max(var(--rs-modal-drag, 0px), 0px)); + transform: translate(0, var(--rs-modal-drag, 0)); } } @@ -90,14 +96,20 @@ overflow: auto; overscroll-behavior: none; opacity: 1; + box-shadow: + calc(var(--rs-modal-bleed) * -1) 0 0 0 var(--rs-color-background-elevation-overlay), + var(--rs-shadow-overlay); [dir="rtl"] & { transform: translate(100%, 0); + box-shadow: + var(--rs-modal-bleed) 0 0 0 var(--rs-color-background-elevation-overlay), + var(--rs-shadow-overlay); } &.--active, [dir="rtl"] &.--active { - transform: translate(min(var(--rs-modal-drag, 0px), 0px), 0); + transform: translate(var(--rs-modal-drag, 0), 0); } } @@ -118,14 +130,20 @@ overflow: auto; overscroll-behavior: none; opacity: 1; + box-shadow: + var(--rs-modal-bleed) 0 0 0 var(--rs-color-background-elevation-overlay), + var(--rs-shadow-overlay); [dir="rtl"] & { transform: translate(-100%, 0); + box-shadow: + calc(var(--rs-modal-bleed) * -1) 0 0 0 var(--rs-color-background-elevation-overlay), + var(--rs-shadow-overlay); } &.--active, [dir="rtl"] &.--active { - transform: translate(max(var(--rs-modal-drag, 0px), 0px), 0); + transform: translate(var(--rs-modal-drag, 0), 0); } } diff --git a/packages/reshaped/src/components/Modal/Modal.tsx b/packages/reshaped/src/components/Modal/Modal.tsx index 8c57ddb6..ec5d09a0 100644 --- a/packages/reshaped/src/components/Modal/Modal.tsx +++ b/packages/reshaped/src/components/Modal/Modal.tsx @@ -17,7 +17,9 @@ import Overlay, { type OverlayInstance } from "@/components/Overlay"; import Text from "@/components/Text"; import useElementId from "@/hooks/useElementId"; import useHandlerRef from "@/hooks/useHandlerRef"; +import useIsomorphicLayoutEffect from "@/hooks/useIsomorphicLayoutEffect"; import useResponsiveClientValue from "@/hooks/useResponsiveClientValue"; +import { resolveSpringTransition } from "@/utilities/animation"; import { responsiveClassNames, responsiveVariables } from "@/utilities/props"; import { resolveMixin } from "@/styles/mixin"; import type * as T from "./Modal.types"; @@ -27,6 +29,27 @@ const DRAG_THRESHOLD = 32; const DRAG_OPPOSITE_THRESHOLD = 100; const DRAG_EDGE_BOUNDARY = 32; const DRAG_SCROLL_LOCK_THRESHOLD = 8; +/** Velocity in px/ms closing the modal on release regardless of the dragged distance */ +const DRAG_VELOCITY_THRESHOLD = 0.4; +/** Time window in ms used for measuring the release velocity */ +const DRAG_VELOCITY_WINDOW = 100; +/** Velocity in px/ms passed to the release spring is capped to keep the overshoot within the modal bleed */ +const DRAG_VELOCITY_LIMIT = 3; +/** How far the modal can be rubber-banded past its open position, in px */ +const DRAG_OVERDRAG_LIMIT = 40; +/** Initial resistance of the rubber band, 1 follows the finger exactly */ +const DRAG_OVERDRAG_RESISTANCE = 0.55; +/** Fallback release duration range in ms for browsers without linear() easing support */ +const DRAG_RELEASE_MIN_DURATION = 100; +const DRAG_RELEASE_MAX_DURATION = 300; + +/** + * Rubber band: follows the finger at first and asymptotically approaches the limit + */ +const dampenOverdrag = (distance: number) => { + const resisted = distance * DRAG_OVERDRAG_RESISTANCE; + return (DRAG_OVERDRAG_LIMIT * resisted) / (resisted + DRAG_OVERDRAG_LIMIT); +}; const Context = createContext({ id: "", @@ -103,12 +126,18 @@ const Modal: FC = (props) => { const dragStartCoordinatesRef = useRef({ x: 0, y: 0 }); const dragLastCoordinateRef = useRef(0); const dragDistanceRef = useRef(0); + const dragOffsetRef = useRef(0); const dragFrameRef = useRef(null); const dragPendingDistanceRef = useRef(0); const dragSizeRef = useRef(1); + const dragSamplesRef = useRef<{ time: number; coordinate: number }[]>([]); + const dragCanOverdragRef = useRef(false); + const dragReleasedRef = useRef(false); const overlayRef = useRef(null); const dragDirectionRef = useRef(0); const dragShouldCloseRef = useRef(false); + // Closing direction on the drag axis, start drawers close towards the negative coordinates + const dragClosingSign = clientPosition === "start" ? -1 : 1; const mixinStyles = resolveMixin({ padding }); const shouldBeContained = containerRef && contained !== false; @@ -117,22 +146,82 @@ const Modal: FC = (props) => { const rootEl = rootRef.current; if (!rootEl) return; - const dragOffset = - Math.abs(distance) < DRAG_THRESHOLD - ? 0 - : distance + DRAG_THRESHOLD * (clientPosition === "start" ? 1 : -1); + // Positive when dragged towards the closing direction + const closingDistance = distance * dragClosingSign; + let dragOffset = 0; + + if (closingDistance > DRAG_THRESHOLD) { + dragOffset = closingDistance - DRAG_THRESHOLD; + } else if (closingDistance < 0 && dragCanOverdragRef.current) { + dragOffset = -dampenOverdrag(-closingDistance); + } - rootEl.style.setProperty("--rs-modal-drag", `${dragOffset}px`); + dragOffsetRef.current = dragOffset; + rootEl.style.setProperty("--rs-modal-drag", `${dragOffset * dragClosingSign}px`); if (!transparentOverlay && overlayRef.current) { - const progress = Math.min(1, Math.abs(distance) / dragSizeRef.current); + const progress = Math.min(1, Math.max(0, closingDistance) / dragSizeRef.current); const opacity = Math.max(0, 1 - progress * 0.5); overlayRef.current.setOpacity(opacity); } }, - [clientPosition, overlayRef, transparentOverlay, rootRef] + [dragClosingSign, overlayRef, transparentOverlay, rootRef] + ); + + /** + * Continues the release animation from the current gesture velocity, + * velocity is passed in px/ms and is positive when moving towards the closing direction + */ + const setReleaseTransition = useCallback( + (args: { closing: boolean; velocity: number }) => { + const rootEl = rootRef.current; + if (!rootEl) return; + + const { closing, velocity } = args; + const target = closing ? dragSizeRef.current : 0; + // Both values are measured on the same axis, positive towards the closing direction + const displacement = dragOffsetRef.current - target; + const cappedVelocity = + Math.sign(velocity) * Math.min(Math.abs(velocity), DRAG_VELOCITY_LIMIT); + + // No transition will run without a displacement, so there is nothing to override + if (!displacement) return; + + const spring = resolveSpringTransition({ displacement, velocity: cappedVelocity * 1000 }); + + if (spring) { + rootEl.style.setProperty("transition-duration", `${Math.round(spring.duration)}ms`); + rootEl.style.setProperty("transition-timing-function", spring.easing); + return; + } + + const duration = Math.abs(displacement) / Math.max(Math.abs(velocity), 0.001); + rootEl.style.setProperty( + "transition-duration", + `${Math.round(Math.min(Math.max(duration, DRAG_RELEASE_MIN_DURATION), DRAG_RELEASE_MAX_DURATION))}ms` + ); + rootEl.style.setProperty("transition-timing-function", "var(--rs-easing-decelerate)"); + }, + [rootRef] ); + const resetReleaseTransition = useCallback(() => { + rootRef.current?.style.removeProperty("transition-duration"); + rootRef.current?.style.removeProperty("transition-timing-function"); + }, [rootRef]); + + const getReleaseVelocity = useCallback((time: number) => { + const samples = dragSamplesRef.current.filter( + (sample) => time - sample.time <= DRAG_VELOCITY_WINDOW + ); + const first = samples[0]; + const last = samples[samples.length - 1]; + + if (!first || !last || first === last) return 0; + + return (last.coordinate - first.coordinate) / (last.time - first.time); + }, []); + const value = useMemo( () => ({ titleMounted, @@ -151,6 +240,7 @@ const Modal: FC = (props) => { dragLastCoordinateRef.current = 0; dragDirectionRef.current = 0; dragShouldCloseRef.current = false; + dragSamplesRef.current = []; setDragStyles(0); }, [setDragStyles]); @@ -171,6 +261,9 @@ const Modal: FC = (props) => { let currentEl = e.target as HTMLElement | null; const rootEl = rootRef.current; + const isInline = ["start", "end"].includes(clientPosition); + // Rubber band is only applied when there is no content that could scroll in the opposite direction instead + let canOverdrag = true; while (currentEl && (currentEl === rootEl || rootEl?.contains(currentEl))) { // Prioritize scrolling over modal swiping @@ -178,6 +271,11 @@ const Modal: FC = (props) => { // Start dragging only when starting on static elements if (currentEl.matches("input,textarea")) return; + const scrollable = isInline + ? currentEl.scrollWidth > currentEl.clientWidth + : currentEl.scrollHeight > currentEl.clientHeight; + if (scrollable) canOverdrag = false; + currentEl = currentEl ? currentEl.parentElement : null; } @@ -185,32 +283,57 @@ const Modal: FC = (props) => { if (clientPosition === "start" && e.targetTouches[0].clientX < DRAG_EDGE_BOUNDARY) return; if (rootEl) { - const isInline = ["start", "end"].includes(clientPosition); dragSizeRef.current = Math.max(1, isInline ? rootEl.clientWidth : rootEl.clientHeight); } + dragCanOverdragRef.current = canOverdrag; + dragReleasedRef.current = false; + dragSamplesRef.current = []; + resetReleaseTransition(); + if (containerRef?.current) disableScroll(); setDragging(true); }; - // Once modal is closed - reset its drag data const handleTransitionEnd = (e: React.TransitionEvent) => { - if (active) return; if (e.propertyName !== "transform") return; if (e.currentTarget !== e.target) return; + resetReleaseTransition(); + + // Once modal is closed - reset its drag data + if (active) return; resetDragData(); unlockDragScroll(); }; + // Modal returns to its open position after the dragging class is removed, + // so the transition is applied to the reset instead of snapping back + useIsomorphicLayoutEffect(() => { + if (dragging || !dragReleasedRef.current) return; + dragReleasedRef.current = false; + resetDragData(); + }, [dragging, resetDragData]); + useEffect(() => { if (!dragging) return; - const handleDragEnd = () => { - const shouldClose = - clientPosition === "start" ? dragDirectionRef.current < 0 : dragDirectionRef.current > 0; - dragShouldCloseRef.current = - Math.abs(dragDistanceRef.current) > DRAG_THRESHOLD && shouldClose; + const handleDragEnd = (e: TouchEvent) => { + // Positive when moving towards the closing direction + const velocity = getReleaseVelocity(e.timeStamp) * dragClosingSign; + const closingDistance = dragDistanceRef.current * dragClosingSign; + const flicked = velocity > DRAG_VELOCITY_THRESHOLD; + const dragged = closingDistance > DRAG_THRESHOLD && velocity >= 0; + dragShouldCloseRef.current = flicked || dragged; + + // Apply the pending frame before the release so the transition starts from the current position + if (dragFrameRef.current !== null) { + window.cancelAnimationFrame(dragFrameRef.current); + dragFrameRef.current = null; + setDragStyles(dragPendingDistanceRef.current); + } + + setReleaseTransition({ closing: dragShouldCloseRef.current, velocity }); if (containerRef?.current) enableScroll(); if (!dragShouldCloseRef.current) unlockDragScroll(); @@ -219,7 +342,7 @@ const Modal: FC = (props) => { if (dragShouldCloseRef.current) { onCloseRef.current?.({ reason: "drag" }); } else { - resetDragData(); + dragReleasedRef.current = true; } }; @@ -256,20 +379,19 @@ const Modal: FC = (props) => { dragDirectionRef.current = coordinate[key] - dragLastCoordinateRef.current; dragLastCoordinateRef.current = coordinate[key]; - const isClosingDirection = - clientPosition === "start" ? dragDirectionRef.current < 0 : dragDirectionRef.current > 0; + const isClosingDirection = dragDirectionRef.current * dragClosingSign > 0; if (next > DRAG_SCROLL_LOCK_THRESHOLD && isClosingDirection) { lockDragScroll(); } - dragDistanceRef.current = - clientPosition === "start" - ? Math.min(0, dragDistanceRef.current + dragDirectionRef.current) - : Math.max(0, dragDistanceRef.current + dragDirectionRef.current); - + // Distance is not clamped so the rubber band can follow the finger past the open position + dragDistanceRef.current += dragDirectionRef.current; dragPendingDistanceRef.current = dragDistanceRef.current; + dragSamplesRef.current.push({ time: e.timeStamp, coordinate: coordinate[key] }); + if (dragSamplesRef.current.length > 10) dragSamplesRef.current.shift(); + if (dragFrameRef.current === null) { dragFrameRef.current = window.requestAnimationFrame(() => { setDragStyles(dragPendingDistanceRef.current); @@ -293,12 +415,14 @@ const Modal: FC = (props) => { }, [ dragging, clientPosition, + dragClosingSign, onCloseRef, position, rootRef, containerRef, setDragStyles, - resetDragData, + setReleaseTransition, + getReleaseVelocity, lockDragScroll, unlockDragScroll, ]); diff --git a/packages/reshaped/src/utilities/animation.ts b/packages/reshaped/src/utilities/animation.ts index cb3a012a..1c408630 100644 --- a/packages/reshaped/src/utilities/animation.ts +++ b/packages/reshaped/src/utilities/animation.ts @@ -21,3 +21,72 @@ export const checkTransitions = () => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; return !window.matchMedia(reducedMotionMediaQuery).matches; }; + +/** + * Angular frequency of the critically damped spring used for the gesture release animations, in 1/s. + * Higher values settle faster, 20 settles from a resting position in ~300ms + */ +const SPRING_OMEGA = 20; +const SPRING_SAMPLE_RATE = 120; +const SPRING_MAX_DURATION = 0.6; +const SPRING_MIN_DURATION = 0.05; +const SPRING_REST_DISTANCE = 0.5; +const SPRING_REST_VELOCITY = 20; + +let supportsLinearEasing: boolean | null = null; + +const checkLinearEasing = () => { + if (supportsLinearEasing !== null) return supportsLinearEasing; + supportsLinearEasing = + typeof CSS !== "undefined" && + typeof CSS.supports === "function" && + CSS.supports("transition-timing-function", "linear(0, 1)"); + return supportsLinearEasing; +}; + +/** + * Resolves a critically damped spring into a css transition duration and a linear() easing, + * so the release animation continues from the current velocity of the gesture instead of restarting from zero. + * + * @param displacement - current position relative to the target position in px + * @param velocity - current velocity in px/s measured on the same axis as the displacement + * @returns null when the browser doesn't support linear() easing or there is nothing to animate + */ +export const resolveSpringTransition = (args: { + displacement: number; + velocity: number; +}): { duration: number; easing: string } | null => { + const { displacement, velocity } = args; + + if (!displacement || !checkLinearEasing()) return null; + + const step = 1 / SPRING_SAMPLE_RATE; + // Position over time: (a + b * t) * e^(-omega * t) + const a = displacement; + const b = velocity + SPRING_OMEGA * displacement; + const values: number[] = []; + let time = 0; + + while (true) { + const decay = Math.exp(-SPRING_OMEGA * time); + const position = (a + b * time) * decay; + const speed = (b - SPRING_OMEGA * (a + b * time)) * decay; + + values.push(1 - position / displacement); + + const settled = + Math.abs(position) < SPRING_REST_DISTANCE && Math.abs(speed) < SPRING_REST_VELOCITY; + + if (settled && time >= SPRING_MIN_DURATION) break; + if (time >= SPRING_MAX_DURATION) break; + + time += step; + } + + values[values.length - 1] = 1; + + return { + duration: time * 1000, + easing: `linear(${values.map((value) => value.toFixed(4)).join(", ")})`, + }; +}; From 061891afa61ece48e5ba169f6fda690833034378 Mon Sep 17 00:00:00 2001 From: Dmitry Belyaev Date: Sat, 12 Sep 2026 19:53:46 +0200 Subject: [PATCH 2/4] modal velocity --- .changeset/modal-drag-physics.md | 2 +- .../src/components/Modal/Modal.module.css | 24 +------ .../reshaped/src/components/Modal/Modal.tsx | 66 ++++++++++--------- 3 files changed, 40 insertions(+), 52 deletions(-) diff --git a/.changeset/modal-drag-physics.md b/.changeset/modal-drag-physics.md index 65dd110a..853e1274 100644 --- a/.changeset/modal-drag-physics.md +++ b/.changeset/modal-drag-physics.md @@ -2,4 +2,4 @@ "reshaped": minor --- -Improved the `Modal` swipe gesture physics. On release, the closing or returning animation now continues from the current velocity of the gesture using a critically damped spring, so a quick flick closes the modal right away while a slow release settles smoothly, instead of restarting a fixed-duration transition from zero. Dragging the modal past its open position now applies a rubber band effect instead of being ignored, as long as the content under the finger can't scroll in that direction. Returning to the open position is animated as well, where previously it snapped back. +Improved the `Modal` swipe gesture physics. On release, the closing or returning animation now continues from the current velocity of the gesture using a critically damped spring, so a quick flick closes the modal right away while a slow release settles smoothly, instead of restarting a fixed-duration transition from zero. Returning to the open position is animated as well, where previously it snapped back, and starting a new swipe while the modal is still animating picks it up from its current position instead of snapping to the position the animation was heading to. diff --git a/packages/reshaped/src/components/Modal/Modal.module.css b/packages/reshaped/src/components/Modal/Modal.module.css index 86aa473a..094dbccc 100644 --- a/packages/reshaped/src/components/Modal/Modal.module.css +++ b/packages/reshaped/src/components/Modal/Modal.module.css @@ -2,9 +2,6 @@ --rs-modal-container-width: 100vw; --rs-modal-container-height: 100vh; - /* Extends the background past the trailing edge so no gap is visible when the modal is rubber-banded or overshoots */ - --rs-modal-bleed: calc(var(--rs-unit-x1) * 25); - transition: var(--rs-duration-fast) var(--rs-easing-accelerate); transition-property: transform, opacity; background: var(--rs-color-background-elevation-overlay); @@ -69,13 +66,10 @@ overflow: auto; overscroll-behavior: none; opacity: 1; - box-shadow: - 0 var(--rs-modal-bleed) 0 0 var(--rs-color-background-elevation-overlay), - var(--rs-shadow-overlay); &.--active, [dir="rtl"] &.--active { - transform: translate(0, var(--rs-modal-drag, 0)); + transform: translate(0, max(var(--rs-modal-drag, 0px), 0px)); } } @@ -96,20 +90,14 @@ overflow: auto; overscroll-behavior: none; opacity: 1; - box-shadow: - calc(var(--rs-modal-bleed) * -1) 0 0 0 var(--rs-color-background-elevation-overlay), - var(--rs-shadow-overlay); [dir="rtl"] & { transform: translate(100%, 0); - box-shadow: - var(--rs-modal-bleed) 0 0 0 var(--rs-color-background-elevation-overlay), - var(--rs-shadow-overlay); } &.--active, [dir="rtl"] &.--active { - transform: translate(var(--rs-modal-drag, 0), 0); + transform: translate(min(var(--rs-modal-drag, 0px), 0px), 0); } } @@ -130,20 +118,14 @@ overflow: auto; overscroll-behavior: none; opacity: 1; - box-shadow: - var(--rs-modal-bleed) 0 0 0 var(--rs-color-background-elevation-overlay), - var(--rs-shadow-overlay); [dir="rtl"] & { transform: translate(-100%, 0); - box-shadow: - calc(var(--rs-modal-bleed) * -1) 0 0 0 var(--rs-color-background-elevation-overlay), - var(--rs-shadow-overlay); } &.--active, [dir="rtl"] &.--active { - transform: translate(var(--rs-modal-drag, 0), 0); + transform: translate(max(var(--rs-modal-drag, 0px), 0px), 0); } } diff --git a/packages/reshaped/src/components/Modal/Modal.tsx b/packages/reshaped/src/components/Modal/Modal.tsx index ec5d09a0..7ed8c826 100644 --- a/packages/reshaped/src/components/Modal/Modal.tsx +++ b/packages/reshaped/src/components/Modal/Modal.tsx @@ -33,22 +33,23 @@ const DRAG_SCROLL_LOCK_THRESHOLD = 8; const DRAG_VELOCITY_THRESHOLD = 0.4; /** Time window in ms used for measuring the release velocity */ const DRAG_VELOCITY_WINDOW = 100; -/** Velocity in px/ms passed to the release spring is capped to keep the overshoot within the modal bleed */ +/** Velocity in px/ms passed to the release spring is capped to keep the animation duration reasonable */ const DRAG_VELOCITY_LIMIT = 3; -/** How far the modal can be rubber-banded past its open position, in px */ -const DRAG_OVERDRAG_LIMIT = 40; -/** Initial resistance of the rubber band, 1 follows the finger exactly */ -const DRAG_OVERDRAG_RESISTANCE = 0.55; /** Fallback release duration range in ms for browsers without linear() easing support */ const DRAG_RELEASE_MIN_DURATION = 100; const DRAG_RELEASE_MAX_DURATION = 300; /** - * Rubber band: follows the finger at first and asymptotically approaches the limit + * Distance in px the modal is currently rendered at from its open position, towards the closing direction. + * Resolved from the transform so a gesture can pick up an animation that is still in flight, + * the dragging class disables transitions and would otherwise snap the modal to the animation target */ -const dampenOverdrag = (distance: number) => { - const resisted = distance * DRAG_OVERDRAG_RESISTANCE; - return (DRAG_OVERDRAG_LIMIT * resisted) / (resisted + DRAG_OVERDRAG_LIMIT); +const getRenderedOffset = (el: HTMLElement, args: { inline: boolean; closingSign: number }) => { + const { transform } = window.getComputedStyle(el); + if (!transform || transform === "none") return 0; + + const matrix = new DOMMatrixReadOnly(transform); + return Math.max(0, (args.inline ? matrix.m41 : matrix.m42) * args.closingSign); }; const Context = createContext({ @@ -131,7 +132,7 @@ const Modal: FC = (props) => { const dragPendingDistanceRef = useRef(0); const dragSizeRef = useRef(1); const dragSamplesRef = useRef<{ time: number; coordinate: number }[]>([]); - const dragCanOverdragRef = useRef(false); + const dragSeedOffsetRef = useRef(0); const dragReleasedRef = useRef(false); const overlayRef = useRef(null); const dragDirectionRef = useRef(0); @@ -146,21 +147,21 @@ const Modal: FC = (props) => { const rootEl = rootRef.current; if (!rootEl) return; + const seedOffset = dragSeedOffsetRef.current; // Positive when dragged towards the closing direction const closingDistance = distance * dragClosingSign; - let dragOffset = 0; - - if (closingDistance > DRAG_THRESHOLD) { - dragOffset = closingDistance - DRAG_THRESHOLD; - } else if (closingDistance < 0 && dragCanOverdragRef.current) { - dragOffset = -dampenOverdrag(-closingDistance); - } + // A gesture picking up an animation starts on an already moving modal, so it skips the dead zone + const threshold = seedOffset ? 0 : DRAG_THRESHOLD; + const dragOffset = Math.max(0, seedOffset + closingDistance - threshold); dragOffsetRef.current = dragOffset; rootEl.style.setProperty("--rs-modal-drag", `${dragOffset * dragClosingSign}px`); if (!transparentOverlay && overlayRef.current) { - const progress = Math.min(1, Math.max(0, closingDistance) / dragSizeRef.current); + const progress = Math.min( + 1, + Math.max(0, seedOffset + closingDistance) / dragSizeRef.current + ); const opacity = Math.max(0, 1 - progress * 0.5); overlayRef.current.setOpacity(opacity); } @@ -241,6 +242,7 @@ const Modal: FC = (props) => { dragDirectionRef.current = 0; dragShouldCloseRef.current = false; dragSamplesRef.current = []; + dragSeedOffsetRef.current = 0; setDragStyles(0); }, [setDragStyles]); @@ -262,8 +264,6 @@ const Modal: FC = (props) => { let currentEl = e.target as HTMLElement | null; const rootEl = rootRef.current; const isInline = ["start", "end"].includes(clientPosition); - // Rubber band is only applied when there is no content that could scroll in the opposite direction instead - let canOverdrag = true; while (currentEl && (currentEl === rootEl || rootEl?.contains(currentEl))) { // Prioritize scrolling over modal swiping @@ -271,11 +271,6 @@ const Modal: FC = (props) => { // Start dragging only when starting on static elements if (currentEl.matches("input,textarea")) return; - const scrollable = isInline - ? currentEl.scrollWidth > currentEl.clientWidth - : currentEl.scrollHeight > currentEl.clientHeight; - if (scrollable) canOverdrag = false; - currentEl = currentEl ? currentEl.parentElement : null; } @@ -286,11 +281,20 @@ const Modal: FC = (props) => { dragSizeRef.current = Math.max(1, isInline ? rootEl.clientWidth : rootEl.clientHeight); } - dragCanOverdragRef.current = canOverdrag; - dragReleasedRef.current = false; - dragSamplesRef.current = []; + // Measured before anything is reset, a transition in flight resolves to its current position + const seedOffset = + active && rootEl + ? getRenderedOffset(rootEl, { inline: isInline, closingSign: dragClosingSign }) + : 0; + + // The previous gesture can be left unresolved, e.g. when the modal stays open after a drag close + resetDragData(); resetReleaseTransition(); + dragReleasedRef.current = false; + dragSeedOffsetRef.current = seedOffset; + setDragStyles(0); + if (containerRef?.current) disableScroll(); setDragging(true); }; @@ -385,8 +389,10 @@ const Modal: FC = (props) => { lockDragScroll(); } - // Distance is not clamped so the rubber band can follow the finger past the open position - dragDistanceRef.current += dragDirectionRef.current; + // Clamped so the modal can't be dragged past its open position + const nextDistance = (dragDistanceRef.current + dragDirectionRef.current) * dragClosingSign; + dragDistanceRef.current = + Math.max(-dragSeedOffsetRef.current, nextDistance) * dragClosingSign; dragPendingDistanceRef.current = dragDistanceRef.current; dragSamplesRef.current.push({ time: e.timeStamp, coordinate: coordinate[key] }); From b71e8f87120311902944b225091d05ed32fb8947 Mon Sep 17 00:00:00 2001 From: Dmitry Belyaev Date: Sat, 12 Sep 2026 20:14:01 +0200 Subject: [PATCH 3/4] Modal: Swipe velocity --- .../reshaped/src/components/Modal/Modal.tsx | 352 +----------------- .../src/components/Modal/Modal.types.ts | 4 +- .../src/components/Modal/useModalDrag.ts | 331 ++++++++++++++++ packages/reshaped/src/utilities/animation.ts | 60 +-- 4 files changed, 385 insertions(+), 362 deletions(-) create mode 100644 packages/reshaped/src/components/Modal/useModalDrag.ts diff --git a/packages/reshaped/src/components/Modal/Modal.tsx b/packages/reshaped/src/components/Modal/Modal.tsx index 7ed8c826..b80ca4fc 100644 --- a/packages/reshaped/src/components/Modal/Modal.tsx +++ b/packages/reshaped/src/components/Modal/Modal.tsx @@ -1,57 +1,18 @@ "use client"; -import { - useCallback, - createContext, - useContext, - useEffect, - useMemo, - useState, - useRef, - type FC, -} from "react"; +import { createContext, useContext, useEffect, useMemo, useState, useRef, type FC } from "react"; import { classNames } from "@reshaped/utilities"; -import { enableScroll, disableScroll } from "@reshaped/utilities/internal"; import Overlay, { type OverlayInstance } from "@/components/Overlay"; import Text from "@/components/Text"; import useElementId from "@/hooks/useElementId"; -import useHandlerRef from "@/hooks/useHandlerRef"; -import useIsomorphicLayoutEffect from "@/hooks/useIsomorphicLayoutEffect"; import useResponsiveClientValue from "@/hooks/useResponsiveClientValue"; -import { resolveSpringTransition } from "@/utilities/animation"; import { responsiveClassNames, responsiveVariables } from "@/utilities/props"; import { resolveMixin } from "@/styles/mixin"; import type * as T from "./Modal.types"; +import useModalDrag from "./useModalDrag"; import s from "./Modal.module.css"; -const DRAG_THRESHOLD = 32; -const DRAG_OPPOSITE_THRESHOLD = 100; -const DRAG_EDGE_BOUNDARY = 32; -const DRAG_SCROLL_LOCK_THRESHOLD = 8; -/** Velocity in px/ms closing the modal on release regardless of the dragged distance */ -const DRAG_VELOCITY_THRESHOLD = 0.4; -/** Time window in ms used for measuring the release velocity */ -const DRAG_VELOCITY_WINDOW = 100; -/** Velocity in px/ms passed to the release spring is capped to keep the animation duration reasonable */ -const DRAG_VELOCITY_LIMIT = 3; -/** Fallback release duration range in ms for browsers without linear() easing support */ -const DRAG_RELEASE_MIN_DURATION = 100; -const DRAG_RELEASE_MAX_DURATION = 300; - -/** - * Distance in px the modal is currently rendered at from its open position, towards the closing direction. - * Resolved from the transform so a gesture can pick up an animation that is still in flight, - * the dragging class disables transitions and would otherwise snap the modal to the animation target - */ -const getRenderedOffset = (el: HTMLElement, args: { inline: boolean; closingSign: number }) => { - const { transform } = window.getComputedStyle(el); - if (!transform || transform === "none") return 0; - - const matrix = new DOMMatrixReadOnly(transform); - return Math.max(0, (args.inline ? matrix.m41 : matrix.m42) * args.closingSign); -}; - const Context = createContext({ id: "", titleMounted: false, @@ -116,112 +77,25 @@ const Modal: FC = (props) => { className, attributes, } = props; - const onCloseRef = useHandlerRef(onClose); const id = useElementId(); const clientPosition = useResponsiveClientValue(position)!; const [titleMounted, setTitleMounted] = useState(false); const [subtitleMounted, setSubtitleMounted] = useState(false); - const [dragging, setDragging] = useState(false); const internalRootRef = useRef(null); const rootRef = attributes?.ref || internalRootRef; - const dragStartCoordinatesRef = useRef({ x: 0, y: 0 }); - const dragLastCoordinateRef = useRef(0); - const dragDistanceRef = useRef(0); - const dragOffsetRef = useRef(0); - const dragFrameRef = useRef(null); - const dragPendingDistanceRef = useRef(0); - const dragSizeRef = useRef(1); - const dragSamplesRef = useRef<{ time: number; coordinate: number }[]>([]); - const dragSeedOffsetRef = useRef(0); - const dragReleasedRef = useRef(false); const overlayRef = useRef(null); - const dragDirectionRef = useRef(0); - const dragShouldCloseRef = useRef(false); - // Closing direction on the drag axis, start drawers close towards the negative coordinates - const dragClosingSign = clientPosition === "start" ? -1 : 1; const mixinStyles = resolveMixin({ padding }); const shouldBeContained = containerRef && contained !== false; - - const setDragStyles = useCallback( - (distance: number) => { - const rootEl = rootRef.current; - if (!rootEl) return; - - const seedOffset = dragSeedOffsetRef.current; - // Positive when dragged towards the closing direction - const closingDistance = distance * dragClosingSign; - // A gesture picking up an animation starts on an already moving modal, so it skips the dead zone - const threshold = seedOffset ? 0 : DRAG_THRESHOLD; - const dragOffset = Math.max(0, seedOffset + closingDistance - threshold); - - dragOffsetRef.current = dragOffset; - rootEl.style.setProperty("--rs-modal-drag", `${dragOffset * dragClosingSign}px`); - - if (!transparentOverlay && overlayRef.current) { - const progress = Math.min( - 1, - Math.max(0, seedOffset + closingDistance) / dragSizeRef.current - ); - const opacity = Math.max(0, 1 - progress * 0.5); - overlayRef.current.setOpacity(opacity); - } - }, - [dragClosingSign, overlayRef, transparentOverlay, rootRef] - ); - - /** - * Continues the release animation from the current gesture velocity, - * velocity is passed in px/ms and is positive when moving towards the closing direction - */ - const setReleaseTransition = useCallback( - (args: { closing: boolean; velocity: number }) => { - const rootEl = rootRef.current; - if (!rootEl) return; - - const { closing, velocity } = args; - const target = closing ? dragSizeRef.current : 0; - // Both values are measured on the same axis, positive towards the closing direction - const displacement = dragOffsetRef.current - target; - const cappedVelocity = - Math.sign(velocity) * Math.min(Math.abs(velocity), DRAG_VELOCITY_LIMIT); - - // No transition will run without a displacement, so there is nothing to override - if (!displacement) return; - - const spring = resolveSpringTransition({ displacement, velocity: cappedVelocity * 1000 }); - - if (spring) { - rootEl.style.setProperty("transition-duration", `${Math.round(spring.duration)}ms`); - rootEl.style.setProperty("transition-timing-function", spring.easing); - return; - } - - const duration = Math.abs(displacement) / Math.max(Math.abs(velocity), 0.001); - rootEl.style.setProperty( - "transition-duration", - `${Math.round(Math.min(Math.max(duration, DRAG_RELEASE_MIN_DURATION), DRAG_RELEASE_MAX_DURATION))}ms` - ); - rootEl.style.setProperty("transition-timing-function", "var(--rs-easing-decelerate)"); - }, - [rootRef] - ); - - const resetReleaseTransition = useCallback(() => { - rootRef.current?.style.removeProperty("transition-duration"); - rootRef.current?.style.removeProperty("transition-timing-function"); - }, [rootRef]); - - const getReleaseVelocity = useCallback((time: number) => { - const samples = dragSamplesRef.current.filter( - (sample) => time - sample.time <= DRAG_VELOCITY_WINDOW - ); - const first = samples[0]; - const last = samples[samples.length - 1]; - - if (!first || !last || first === last) return 0; - - return (last.coordinate - first.coordinate) / (last.time - first.time); - }, []); + const { dragging, handleTouchStart, handleTransitionEnd } = useModalDrag({ + position: clientPosition, + rootRef, + overlayRef, + active, + containerRef, + transparentOverlay, + disabled: disableSwipeGesture, + onClose, + }); const value = useMemo( () => ({ @@ -234,205 +108,6 @@ const Modal: FC = (props) => { [id, subtitleMounted, titleMounted] ); - const resetDragData = useCallback(() => { - dragDistanceRef.current = 0; - dragPendingDistanceRef.current = 0; - dragStartCoordinatesRef.current = { x: 0, y: 0 }; - dragLastCoordinateRef.current = 0; - dragDirectionRef.current = 0; - dragShouldCloseRef.current = false; - dragSamplesRef.current = []; - dragSeedOffsetRef.current = 0; - setDragStyles(0); - }, [setDragStyles]); - - const unlockDragScroll = useCallback(() => { - rootRef.current?.style.removeProperty("overflow"); - }, [rootRef]); - - const lockDragScroll = useCallback(() => { - rootRef.current?.style.setProperty("overflow", "hidden"); - }, [rootRef]); - - const handleDragStart = (e: React.TouchEvent) => { - if (disableSwipeGesture) return; - dragShouldCloseRef.current = false; - - // Prevent swipe to close from happening when user is working with text selection - if (window.getSelection()?.toString()) return; - - let currentEl = e.target as HTMLElement | null; - const rootEl = rootRef.current; - const isInline = ["start", "end"].includes(clientPosition); - - while (currentEl && (currentEl === rootEl || rootEl?.contains(currentEl))) { - // Prioritize scrolling over modal swiping - if (currentEl.scrollTop !== 0 || currentEl.scrollLeft !== 0) return; - // Start dragging only when starting on static elements - if (currentEl.matches("input,textarea")) return; - - currentEl = currentEl ? currentEl.parentElement : null; - } - - // Prevent the drag handling when browser is trying to navigate to a previous page - if (clientPosition === "start" && e.targetTouches[0].clientX < DRAG_EDGE_BOUNDARY) return; - - if (rootEl) { - dragSizeRef.current = Math.max(1, isInline ? rootEl.clientWidth : rootEl.clientHeight); - } - - // Measured before anything is reset, a transition in flight resolves to its current position - const seedOffset = - active && rootEl - ? getRenderedOffset(rootEl, { inline: isInline, closingSign: dragClosingSign }) - : 0; - - // The previous gesture can be left unresolved, e.g. when the modal stays open after a drag close - resetDragData(); - resetReleaseTransition(); - - dragReleasedRef.current = false; - dragSeedOffsetRef.current = seedOffset; - setDragStyles(0); - - if (containerRef?.current) disableScroll(); - setDragging(true); - }; - - const handleTransitionEnd = (e: React.TransitionEvent) => { - if (e.propertyName !== "transform") return; - if (e.currentTarget !== e.target) return; - - resetReleaseTransition(); - - // Once modal is closed - reset its drag data - if (active) return; - resetDragData(); - unlockDragScroll(); - }; - - // Modal returns to its open position after the dragging class is removed, - // so the transition is applied to the reset instead of snapping back - useIsomorphicLayoutEffect(() => { - if (dragging || !dragReleasedRef.current) return; - dragReleasedRef.current = false; - resetDragData(); - }, [dragging, resetDragData]); - - useEffect(() => { - if (!dragging) return; - - const handleDragEnd = (e: TouchEvent) => { - // Positive when moving towards the closing direction - const velocity = getReleaseVelocity(e.timeStamp) * dragClosingSign; - const closingDistance = dragDistanceRef.current * dragClosingSign; - const flicked = velocity > DRAG_VELOCITY_THRESHOLD; - const dragged = closingDistance > DRAG_THRESHOLD && velocity >= 0; - dragShouldCloseRef.current = flicked || dragged; - - // Apply the pending frame before the release so the transition starts from the current position - if (dragFrameRef.current !== null) { - window.cancelAnimationFrame(dragFrameRef.current); - dragFrameRef.current = null; - setDragStyles(dragPendingDistanceRef.current); - } - - setReleaseTransition({ closing: dragShouldCloseRef.current, velocity }); - - if (containerRef?.current) enableScroll(); - if (!dragShouldCloseRef.current) unlockDragScroll(); - setDragging(false); - - if (dragShouldCloseRef.current) { - onCloseRef.current?.({ reason: "drag" }); - } else { - dragReleasedRef.current = true; - } - }; - - const handleDrag = (e: TouchEvent) => { - if (!dragging || clientPosition === "center") return; - if (rootRef.current?.scrollTop !== 0 || rootRef.current?.scrollLeft !== 0) return; - - const target = e.targetTouches[0]; - const coordinate = { x: target.clientX, y: target.clientY }; - const key = clientPosition === "bottom" ? "y" : "x"; - const oppositeKey = clientPosition === "bottom" ? "x" : "y"; - - // Save the initial coordinates - if (!dragStartCoordinatesRef.current[key]) { - dragStartCoordinatesRef.current = coordinate; - dragLastCoordinateRef.current = coordinate[key]; - } - - const next = Math.abs(coordinate[key] - dragStartCoordinatesRef.current[key]); - const nextPerpendicular = Math.abs( - coordinate[oppositeKey] - dragStartCoordinatesRef.current[oppositeKey] - ); - - // For start/end drawers - ignore the swiping - // If user is scrolling vertically more than swiping - if ( - position !== "bottom" && - (next < nextPerpendicular || nextPerpendicular > DRAG_OPPOSITE_THRESHOLD) - ) { - dragLastCoordinateRef.current = coordinate[key]; - return; - } - - dragDirectionRef.current = coordinate[key] - dragLastCoordinateRef.current; - dragLastCoordinateRef.current = coordinate[key]; - - const isClosingDirection = dragDirectionRef.current * dragClosingSign > 0; - - if (next > DRAG_SCROLL_LOCK_THRESHOLD && isClosingDirection) { - lockDragScroll(); - } - - // Clamped so the modal can't be dragged past its open position - const nextDistance = (dragDistanceRef.current + dragDirectionRef.current) * dragClosingSign; - dragDistanceRef.current = - Math.max(-dragSeedOffsetRef.current, nextDistance) * dragClosingSign; - dragPendingDistanceRef.current = dragDistanceRef.current; - - dragSamplesRef.current.push({ time: e.timeStamp, coordinate: coordinate[key] }); - if (dragSamplesRef.current.length > 10) dragSamplesRef.current.shift(); - - if (dragFrameRef.current === null) { - dragFrameRef.current = window.requestAnimationFrame(() => { - setDragStyles(dragPendingDistanceRef.current); - dragFrameRef.current = null; - }); - } - }; - - document.addEventListener("touchmove", handleDrag, { passive: true }); - document.addEventListener("touchend", handleDragEnd, { passive: true }); - - return () => { - if (dragFrameRef.current !== null) { - window.cancelAnimationFrame(dragFrameRef.current); - dragFrameRef.current = null; - } - if (!dragShouldCloseRef.current) unlockDragScroll(); - document.removeEventListener("touchmove", handleDrag); - document.removeEventListener("touchend", handleDragEnd); - }; - }, [ - dragging, - clientPosition, - dragClosingSign, - onCloseRef, - position, - rootRef, - containerRef, - setDragStyles, - setReleaseTransition, - getReleaseVelocity, - lockDragScroll, - unlockDragScroll, - ]); - return ( = (props) => { contained={contained} containerRef={containerRef} attributes={{ - onTouchStart: handleDragStart, + onTouchStart: handleTouchStart, }} > {({ active }) => { @@ -465,7 +140,6 @@ const Modal: FC = (props) => { return ( - {}
; + position?: G.Responsive; /** Component size, literal css value */ size?: G.Responsive; /** Component padding, unit token multiplier */ diff --git a/packages/reshaped/src/components/Modal/useModalDrag.ts b/packages/reshaped/src/components/Modal/useModalDrag.ts new file mode 100644 index 00000000..e5f74808 --- /dev/null +++ b/packages/reshaped/src/components/Modal/useModalDrag.ts @@ -0,0 +1,331 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { enableScroll, disableScroll } from "@reshaped/utilities/internal"; + +import type { OverlayInstance } from "@/components/Overlay"; +import useHandlerRef from "@/hooks/useHandlerRef"; +import useIsomorphicLayoutEffect from "@/hooks/useIsomorphicLayoutEffect"; +import { resolveSpringTransition } from "@/utilities/animation"; +import type * as T from "./Modal.types"; + +/** Distance in px the modal has to be dragged before it starts following the finger */ +const THRESHOLD = 32; +/** Distance in px on the axis perpendicular to the drag axis cancelling the gesture */ +const OPPOSITE_THRESHOLD = 100; +/** Screen edge zone in px reserved for the browser back gesture */ +const EDGE_BOUNDARY = 32; +/** Distance in px after which the modal content stops scrolling for the rest of the gesture */ +const SCROLL_LOCK_THRESHOLD = 8; +/** Velocity in px/ms closing the modal on release regardless of the dragged distance */ +const VELOCITY_THRESHOLD = 0.4; +/** Time window in ms used for measuring the release velocity */ +const VELOCITY_WINDOW = 100; +const VELOCITY_SAMPLES = 10; + +/** + * Gesture state, all distances are measured on the drag axis + * and are positive towards the direction the modal closes in + */ +type State = { + /** Coordinate on the drag axis the gesture started from */ + origin: number; + /** Coordinate on the perpendicular axis the gesture started from */ + oppositeOrigin: number; + /** Coordinate on the drag axis from the previous move, used for resolving the direction */ + previous: number; + /** Offset the gesture started from, above zero when it picked up an animation in flight */ + seed: number; + /** Distance dragged since the gesture started, clamped at the open position */ + distance: number; + /** Offset the modal is currently rendered at, resolved from the seed and the distance */ + offset: number; + /** Modal size on the drag axis in px, used as the closing target */ + size: number; + /** Recent coordinates on the drag axis, used for measuring the release velocity */ + samples: { time: number; coordinate: number }[]; + /** Gesture is released and the modal is closing */ + closing: boolean; + /** Gesture is released and the modal is animating back to its open position */ + returning: boolean; +}; + +const getInitialState = (): State => ({ + origin: 0, + oppositeOrigin: 0, + previous: 0, + seed: 0, + distance: 0, + offset: 0, + size: 1, + samples: [], + closing: false, + returning: false, +}); + +/** + * Distance in px the modal is currently rendered at from its open position, towards the closing direction. + * Resolved from the transform so a gesture can pick up an animation that is still in flight, + * the dragging class disables transitions and would otherwise snap the modal to the animation target + */ +const getRenderedOffset = (el: HTMLElement, args: { axis: "x" | "y"; closingSign: number }) => { + const { transform } = window.getComputedStyle(el); + if (!transform || transform === "none") return 0; + + const matrix = new DOMMatrixReadOnly(transform); + return Math.max(0, (args.axis === "y" ? matrix.m42 : matrix.m41) * args.closingSign); +}; + +/** Velocity in px/ms measured over the most recent samples, signed on the drag axis */ +const getVelocity = (state: State, time: number) => { + const samples = state.samples.filter((sample) => time - sample.time <= VELOCITY_WINDOW); + const first = samples[0]; + const last = samples[samples.length - 1]; + + if (!first || !last || first === last) return 0; + + return (last.coordinate - first.coordinate) / (last.time - first.time); +}; + +/** + * Swipe gesture closing the modal, moving it with the finger and continuing + * the release animation from the velocity the gesture ended with + */ +const useModalDrag = (args: { + position: T.Position; + rootRef: React.RefObject; + overlayRef: React.RefObject; + active?: boolean; + disabled?: boolean; + transparentOverlay?: boolean; + containerRef?: T.Props["containerRef"]; + onClose?: T.Props["onClose"]; +}) => { + const { position, rootRef, overlayRef, active, disabled, transparentOverlay, containerRef } = + args; + const onCloseRef = useHandlerRef(args.onClose); + const [dragging, setDragging] = useState(false); + const stateRef = useRef(getInitialState()); + const frameRef = useRef(null); + const axis: "x" | "y" = position === "bottom" ? "y" : "x"; + // Start drawers close towards the negative coordinates + const closingSign = position === "start" ? -1 : 1; + + const render = useCallback(() => { + const rootEl = rootRef.current; + const state = stateRef.current; + if (!rootEl) return; + + // A gesture picking up an animation starts on an already moving modal, so it skips the dead zone + const threshold = state.seed ? 0 : THRESHOLD; + const closingDistance = state.seed + state.distance; + + state.offset = Math.max(0, closingDistance - threshold); + rootEl.style.setProperty("--rs-modal-drag", `${state.offset * closingSign}px`); + + if (transparentOverlay) return; + + const progress = Math.min(1, closingDistance / state.size); + overlayRef.current?.setOpacity(1 - progress * 0.5); + }, [closingSign, transparentOverlay, rootRef, overlayRef]); + + const reset = useCallback(() => { + stateRef.current = getInitialState(); + render(); + }, [render]); + + const setScrollLock = useCallback( + (locked: boolean) => { + const style = rootRef.current?.style; + + if (locked) style?.setProperty("overflow", "hidden"); + else style?.removeProperty("overflow"); + }, + [rootRef] + ); + + /** + * Continues the release animation from the current gesture velocity, + * velocity is passed in px/ms and is positive when moving towards the closing direction + */ + const setReleaseTransition = useCallback( + (releaseArgs: { closing: boolean; velocity: number }) => { + const rootEl = rootRef.current; + const { offset, size } = stateRef.current; + const displacement = offset - (releaseArgs.closing ? size : 0); + + // Without a displacement no transition will run and the override would stay applied + if (!rootEl || !displacement) return; + + const { duration, easing } = resolveSpringTransition({ + displacement, + velocity: releaseArgs.velocity, + }); + + rootEl.style.setProperty("transition-duration", `${Math.round(duration)}ms`); + rootEl.style.setProperty("transition-timing-function", easing); + }, + [rootRef] + ); + + const resetReleaseTransition = useCallback(() => { + const style = rootRef.current?.style; + + style?.removeProperty("transition-duration"); + style?.removeProperty("transition-timing-function"); + }, [rootRef]); + + const handleTouchStart = (e: React.TouchEvent) => { + if (disabled || position === "center") return; + // Prevent swipe to close from happening when user is working with text selection + if (window.getSelection()?.toString()) return; + + const rootEl = rootRef.current; + const touch = e.targetTouches[0]; + let currentEl = e.target as HTMLElement | null; + + while (currentEl && (currentEl === rootEl || rootEl?.contains(currentEl))) { + // Prioritize scrolling over modal swiping + if (currentEl.scrollTop !== 0 || currentEl.scrollLeft !== 0) return; + // Start dragging only when starting on static elements + if (currentEl.matches("input,textarea")) return; + + currentEl = currentEl.parentElement; + } + + // Prevent the drag handling when browser is trying to navigate to a previous page + if (position === "start" && touch.clientX < EDGE_BOUNDARY) return; + + // Measured before the state is replaced, a transition in flight resolves to its current position + const seed = active && rootEl ? getRenderedOffset(rootEl, { axis, closingSign }) : 0; + const origin = axis === "y" ? touch.clientY : touch.clientX; + + // The previous gesture can be left unresolved, e.g. when the modal stays open after a drag close + stateRef.current = { + ...getInitialState(), + origin, + oppositeOrigin: axis === "y" ? touch.clientX : touch.clientY, + previous: origin, + seed, + size: rootEl ? Math.max(1, axis === "y" ? rootEl.clientHeight : rootEl.clientWidth) : 1, + }; + + resetReleaseTransition(); + render(); + + if (containerRef?.current) disableScroll(); + setDragging(true); + }; + + const handleTransitionEnd = (e: React.TransitionEvent) => { + if (e.propertyName !== "transform") return; + if (e.currentTarget !== e.target) return; + + resetReleaseTransition(); + + // Once modal is closed - reset its drag data + if (active) return; + reset(); + setScrollLock(false); + }; + + // Modal returns to its open position after the dragging class is removed, + // so the transition is applied to the reset instead of snapping back + useIsomorphicLayoutEffect(() => { + if (dragging || !stateRef.current.returning) return; + reset(); + }, [dragging, reset]); + + useEffect(() => { + if (!dragging) return; + + const handleTouchMove = (e: TouchEvent) => { + const state = stateRef.current; + const rootEl = rootRef.current; + if (rootEl?.scrollTop !== 0 || rootEl?.scrollLeft !== 0) return; + + const touch = e.targetTouches[0]; + const coordinate = axis === "y" ? touch.clientY : touch.clientX; + const oppositeCoordinate = axis === "y" ? touch.clientX : touch.clientY; + const travel = Math.abs(coordinate - state.origin); + const oppositeTravel = Math.abs(oppositeCoordinate - state.oppositeOrigin); + // Positive when moving towards the closing direction + const delta = (coordinate - state.previous) * closingSign; + + state.previous = coordinate; + + // For start/end drawers - ignore the swiping + // If user is scrolling vertically more than swiping + if (axis === "x" && (travel < oppositeTravel || oppositeTravel > OPPOSITE_THRESHOLD)) { + return; + } + + if (travel > SCROLL_LOCK_THRESHOLD && delta > 0) setScrollLock(true); + + // Clamped so the modal can't be dragged past its open position + state.distance = Math.max(-state.seed, state.distance + delta); + state.samples.push({ time: e.timeStamp, coordinate }); + if (state.samples.length > VELOCITY_SAMPLES) state.samples.shift(); + + if (frameRef.current !== null) return; + frameRef.current = window.requestAnimationFrame(() => { + frameRef.current = null; + render(); + }); + }; + + const handleTouchEnd = (e: TouchEvent) => { + const state = stateRef.current; + // Positive when moving towards the closing direction + const velocity = getVelocity(state, e.timeStamp) * closingSign; + const flicked = velocity > VELOCITY_THRESHOLD; + const dragged = state.distance > THRESHOLD && velocity >= 0; + + state.closing = flicked || dragged; + state.returning = !state.closing; + + // Apply the pending frame before the release so the transition starts from the current position + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + render(); + } + + setReleaseTransition({ closing: state.closing, velocity }); + + if (containerRef?.current) enableScroll(); + if (state.returning) setScrollLock(false); + setDragging(false); + + if (state.closing) onCloseRef.current?.({ reason: "drag" }); + }; + + document.addEventListener("touchmove", handleTouchMove, { passive: true }); + document.addEventListener("touchend", handleTouchEnd, { passive: true }); + + return () => { + if (frameRef.current !== null) { + window.cancelAnimationFrame(frameRef.current); + frameRef.current = null; + } + + if (!stateRef.current.closing) setScrollLock(false); + document.removeEventListener("touchmove", handleTouchMove); + document.removeEventListener("touchend", handleTouchEnd); + }; + }, [ + dragging, + axis, + closingSign, + containerRef, + rootRef, + onCloseRef, + render, + setScrollLock, + setReleaseTransition, + ]); + + return { dragging, handleTouchStart, handleTransitionEnd }; +}; + +export default useModalDrag; diff --git a/packages/reshaped/src/utilities/animation.ts b/packages/reshaped/src/utilities/animation.ts index 1c408630..b4a8d9cd 100644 --- a/packages/reshaped/src/utilities/animation.ts +++ b/packages/reshaped/src/utilities/animation.ts @@ -28,10 +28,17 @@ export const checkTransitions = () => { */ const SPRING_OMEGA = 20; const SPRING_SAMPLE_RATE = 120; -const SPRING_MAX_DURATION = 0.6; -const SPRING_MIN_DURATION = 0.05; +const SPRING_MAX_DURATION = 600; +const SPRING_MIN_DURATION = 50; const SPRING_REST_DISTANCE = 0.5; const SPRING_REST_VELOCITY = 20; +/** Velocity is capped so a fast flick doesn't resolve into an unreasonably long easing curve */ +const SPRING_MAX_VELOCITY = 3; +/** Duration range used when the browser doesn't support linear() easing */ +const FALLBACK_MIN_DURATION = 100; +const FALLBACK_MAX_DURATION = 300; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); let supportsLinearEasing: boolean | null = null; @@ -47,46 +54,55 @@ const checkLinearEasing = () => { /** * Resolves a critically damped spring into a css transition duration and a linear() easing, * so the release animation continues from the current velocity of the gesture instead of restarting from zero. + * Browsers without linear() easing support fall back to a decelerating transition scaled by the velocity. * - * @param displacement - current position relative to the target position in px - * @param velocity - current velocity in px/s measured on the same axis as the displacement - * @returns null when the browser doesn't support linear() easing or there is nothing to animate + * @param displacement - current position relative to the target position, in px + * @param velocity - current velocity in px/ms, measured on the same axis as the displacement + * and positive when moving away from the target position + * @returns transition duration in ms and its timing function */ -export const resolveSpringTransition = (args: { - displacement: number; - velocity: number; -}): { duration: number; easing: string } | null => { - const { displacement, velocity } = args; +export const resolveSpringTransition = (args: { displacement: number; velocity: number }) => { + const { displacement } = args; + const velocity = clamp(args.velocity, -SPRING_MAX_VELOCITY, SPRING_MAX_VELOCITY); + + if (!displacement) return { duration: 0, easing: "linear" }; - if (!displacement || !checkLinearEasing()) return null; + if (!checkLinearEasing()) { + const duration = Math.abs(displacement) / Math.max(Math.abs(velocity), 0.001); - const step = 1 / SPRING_SAMPLE_RATE; - // Position over time: (a + b * t) * e^(-omega * t) + return { + duration: clamp(duration, FALLBACK_MIN_DURATION, FALLBACK_MAX_DURATION), + easing: "var(--rs-easing-decelerate)", + }; + } + + const step = 1000 / SPRING_SAMPLE_RATE; + // Position over time: (a + b * t) * e^(-omega * t), with the time in seconds const a = displacement; - const b = velocity + SPRING_OMEGA * displacement; + const b = velocity * 1000 + SPRING_OMEGA * displacement; const values: number[] = []; - let time = 0; + let duration = 0; while (true) { + const time = duration / 1000; const decay = Math.exp(-SPRING_OMEGA * time); const position = (a + b * time) * decay; const speed = (b - SPRING_OMEGA * (a + b * time)) * decay; - - values.push(1 - position / displacement); - const settled = Math.abs(position) < SPRING_REST_DISTANCE && Math.abs(speed) < SPRING_REST_VELOCITY; - if (settled && time >= SPRING_MIN_DURATION) break; - if (time >= SPRING_MAX_DURATION) break; + values.push(1 - position / displacement); + + if (duration >= SPRING_MAX_DURATION) break; + if (settled && duration >= SPRING_MIN_DURATION) break; - time += step; + duration += step; } values[values.length - 1] = 1; return { - duration: time * 1000, + duration, easing: `linear(${values.map((value) => value.toFixed(4)).join(", ")})`, }; }; From bc59537d40ecbb6fad1a9a1b9fa76265623703f7 Mon Sep 17 00:00:00 2001 From: Dmitry Belyaev Date: Sat, 12 Sep 2026 20:22:04 +0200 Subject: [PATCH 4/4] ci --- .github/workflows/pull-request.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 12acd660..1d8180f8 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -119,16 +119,6 @@ jobs: - name: Lint CSS and JS code run: pnpm lint - lint-pr-title: - name: Validate PR title - runs-on: ubuntu-latest - permissions: - pull-requests: read - steps: - - uses: amannn/action-semantic-pull-request@v6 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - build: runs-on: ubuntu-latest needs: setup