diff --git a/src/components/effects/FlowScrollGrid.tsx b/src/components/effects/FlowScrollGrid.tsx index 1292ec3..806fca9 100644 --- a/src/components/effects/FlowScrollGrid.tsx +++ b/src/components/effects/FlowScrollGrid.tsx @@ -4,6 +4,8 @@ import { motion, type MotionValue, useScroll, useTransform } from "motion/react"; import { type ReactNode, type RefObject, useLayoutEffect, useRef, useState } from "react"; +import { toKeyframeOffsets } from "./keyframeOffsets"; + const GAP_PX = 16; function FlowScrollCell({ @@ -33,14 +35,14 @@ function FlowScrollCell({ const exitAnimation = nextRow / totalRows + scrollRangePerRow * 2; const offsetToAdd = (scrollRangePerRow / totalItems) * (currentRow + 2); - const range = [ + const range = toKeyframeOffsets([ 0, entryAnimation - offsetToAdd, currPosition - offsetToAdd, currPosition - offsetToAdd, exitAnimation - offsetToAdd, 1, - ]; + ]); const scale = useTransform(scrollYProgress, range, [0.5, 0.5, 1, 1, 0.5, 0.5]); const isLeft = index % ITEMS_PER_ROW === 0; diff --git a/src/components/effects/keyframeOffsets.test.ts b/src/components/effects/keyframeOffsets.test.ts new file mode 100644 index 0000000..a0a2af5 --- /dev/null +++ b/src/components/effects/keyframeOffsets.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { toKeyframeOffsets } from "./keyframeOffsets"; + +describe("toKeyframeOffsets", () => { + it("clamps into [0, 1] and never decreases", () => { + expect(toKeyframeOffsets([0, -0.6, -0.1, -0.1, 0.9, 1])).toEqual([0, 0, 0, 0, 0.9, 1]); + expect(toKeyframeOffsets([0, 0.2, 0.5, 0.5, 1.4, 1])).toEqual([0, 0.2, 0.5, 0.5, 1, 1]); + }); + + it("leaves a valid range untouched", () => { + expect(toKeyframeOffsets([0, 0.1, 0.3, 0.3, 0.7, 1])).toEqual([0, 0.1, 0.3, 0.3, 0.7, 1]); + }); +}); diff --git a/src/components/effects/keyframeOffsets.ts b/src/components/effects/keyframeOffsets.ts new file mode 100644 index 0000000..e326d4d --- /dev/null +++ b/src/components/effects/keyframeOffsets.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Ethan Morisset +// SPDX-License-Identifier: BUSL-1.1 + +/** + * Clamp a scroll range into [0, 1] and make it non-decreasing. On WebKit with + * ScrollTimeline support, motion hands useTransform's input range to WAAPI as + * keyframe offsets, which throw a TypeError when out of range or unsorted — + * and that error unmounts the whole app. + */ +export function toKeyframeOffsets(range: number[]): number[] { + let floor = 0; + return range.map((v) => { + floor = Math.max(floor, Math.min(1, v)); + return floor; + }); +}