diff --git a/.changeset/modal-drag-physics.md b/.changeset/modal-drag-physics.md new file mode 100644 index 00000000..853e1274 --- /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. 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/.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 diff --git a/packages/reshaped/src/components/Modal/Modal.tsx b/packages/reshaped/src/components/Modal/Modal.tsx index 8c57ddb6..b80ca4fc 100644 --- a/packages/reshaped/src/components/Modal/Modal.tsx +++ b/packages/reshaped/src/components/Modal/Modal.tsx @@ -1,33 +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 useResponsiveClientValue from "@/hooks/useResponsiveClientValue"; 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; - const Context = createContext({ id: "", titleMounted: false, @@ -92,46 +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 dragFrameRef = useRef(null); - const dragPendingDistanceRef = useRef(0); - const dragSizeRef = useRef(1); const overlayRef = useRef(null); - const dragDirectionRef = useRef(0); - const dragShouldCloseRef = useRef(false); const mixinStyles = resolveMixin({ padding }); const shouldBeContained = containerRef && contained !== false; - - const setDragStyles = useCallback( - (distance: number) => { - const rootEl = rootRef.current; - if (!rootEl) return; - - const dragOffset = - Math.abs(distance) < DRAG_THRESHOLD - ? 0 - : distance + DRAG_THRESHOLD * (clientPosition === "start" ? 1 : -1); - - rootEl.style.setProperty("--rs-modal-drag", `${dragOffset}px`); - - if (!transparentOverlay && overlayRef.current) { - const progress = Math.min(1, Math.abs(distance) / dragSizeRef.current); - const opacity = Math.max(0, 1 - progress * 0.5); - overlayRef.current.setOpacity(opacity); - } - }, - [clientPosition, overlayRef, transparentOverlay, rootRef] - ); + const { dragging, handleTouchStart, handleTransitionEnd } = useModalDrag({ + position: clientPosition, + rootRef, + overlayRef, + active, + containerRef, + transparentOverlay, + disabled: disableSwipeGesture, + onClose, + }); const value = useMemo( () => ({ @@ -144,165 +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; - 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; - - 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) { - const isInline = ["start", "end"].includes(clientPosition); - dragSizeRef.current = Math.max(1, isInline ? rootEl.clientWidth : rootEl.clientHeight); - } - - 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; - - resetDragData(); - unlockDragScroll(); - }; - - 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; - - if (containerRef?.current) enableScroll(); - if (!dragShouldCloseRef.current) unlockDragScroll(); - setDragging(false); - - if (dragShouldCloseRef.current) { - onCloseRef.current?.({ reason: "drag" }); - } else { - resetDragData(); - } - }; - - 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 = - clientPosition === "start" ? dragDirectionRef.current < 0 : dragDirectionRef.current > 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); - - dragPendingDistanceRef.current = dragDistanceRef.current; - - 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, - onCloseRef, - position, - rootRef, - containerRef, - setDragStyles, - resetDragData, - lockDragScroll, - unlockDragScroll, - ]); - return ( = (props) => { contained={contained} containerRef={containerRef} attributes={{ - onTouchStart: handleDragStart, + onTouchStart: handleTouchStart, }} > {({ active }) => { @@ -335,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 cb3a012a..b4a8d9cd 100644 --- a/packages/reshaped/src/utilities/animation.ts +++ b/packages/reshaped/src/utilities/animation.ts @@ -21,3 +21,88 @@ 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 = 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; + +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. + * 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/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 }) => { + const { displacement } = args; + const velocity = clamp(args.velocity, -SPRING_MAX_VELOCITY, SPRING_MAX_VELOCITY); + + if (!displacement) return { duration: 0, easing: "linear" }; + + if (!checkLinearEasing()) { + const duration = Math.abs(displacement) / Math.max(Math.abs(velocity), 0.001); + + 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 * 1000 + SPRING_OMEGA * displacement; + const values: number[] = []; + 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; + const settled = + Math.abs(position) < SPRING_REST_DISTANCE && Math.abs(speed) < SPRING_REST_VELOCITY; + + values.push(1 - position / displacement); + + if (duration >= SPRING_MAX_DURATION) break; + if (settled && duration >= SPRING_MIN_DURATION) break; + + duration += step; + } + + values[values.length - 1] = 1; + + return { + duration, + easing: `linear(${values.map((value) => value.toFixed(4)).join(", ")})`, + }; +};