From a1ee9035de386ff9b25677c5ff845a86b82baabb Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:20:05 +0100 Subject: [PATCH] fix: scroll without gliding when reduce motion is on --- components/settings/settings-app.tsx | 3 +- components/settings/settings-section.tsx | 3 +- components/tour/tour-overlay.tsx | 7 ++- hooks/use-scroll-window.ts | 7 +-- lib/__tests__/reduced-motion.test.ts | 64 ++++++++++++++++++++++++ lib/reduced-motion.ts | 22 ++++++++ 6 files changed, 97 insertions(+), 9 deletions(-) create mode 100644 lib/__tests__/reduced-motion.test.ts create mode 100644 lib/reduced-motion.ts diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 6b0bbd080..270baf3ef 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -103,6 +103,7 @@ import { useLiteLinkSegments } from '@/hooks/use-lite-link-segments'; import { useDocumentTitle } from '@/hooks/use-document-title'; import { MvTopBar } from "@/components/layout/mv-top-bar"; import { useMountainViewShell } from "@/hooks/use-mountain-view-shell"; +import { scrollBehavior } from '@/lib/reduced-motion'; type Tab = SettingsSearchTab; @@ -602,7 +603,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps = } return; } - el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.scrollIntoView({ behavior: scrollBehavior(), block: 'center' }); // Remove + reflow + add restarts the CSS animation if the class was // already present (re-clicking the same sub-result). el.classList.remove('settings-search-highlight'); diff --git a/components/settings/settings-section.tsx b/components/settings/settings-section.tsx index c5ed355c2..3b490d37c 100644 --- a/components/settings/settings-section.tsx +++ b/components/settings/settings-section.tsx @@ -7,6 +7,7 @@ import { cn } from '@/lib/utils'; import { Button, TOUCH_HIT_AREA } from '@/components/ui/button'; import { TermInfo, type GlossaryId } from '@/components/ui/term'; import { useSettingsStore } from '@/stores/settings-store'; +import { scrollBehavior } from '@/lib/reduced-motion'; /** * The Advanced switch. Rows and sections marked `advanced` render nothing @@ -106,7 +107,7 @@ const highlightTimers = new WeakMap>( export function scrollToSettingsSection(id: string, root: ParentNode = document): boolean { const el = root.querySelector(`[data-settings-section="${id.replace(/"/g, '\\"')}"]`); if (!el) return false; - el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + el.scrollIntoView({ behavior: scrollBehavior(), block: 'start' }); // Remove + reflow + add restarts the animation on a second click. clearTimeout(highlightTimers.get(el)); el.classList.remove('settings-search-highlight'); diff --git a/components/tour/tour-overlay.tsx b/components/tour/tour-overlay.tsx index db2a5fbbf..4c1594616 100644 --- a/components/tour/tour-overlay.tsx +++ b/components/tour/tour-overlay.tsx @@ -12,6 +12,7 @@ import { isDocumentRTL } from "@/i18n/direction"; import { findTourTarget, isTourStepOnPage } from "./tour-steps"; import { usePathname } from "@/i18n/navigation"; import { useIsMobile } from "@/hooks/use-media-query"; +import { prefersReducedMotion, scrollBehavior } from "@/lib/reduced-motion"; export { findTourTarget }; @@ -173,7 +174,7 @@ export function TourOverlay() { if (!onPageRef.current) return false; const el = findTourTarget(step.target); if (el) { - el.scrollIntoView({ behavior: "smooth", block: "nearest" }); + el.scrollIntoView({ behavior: scrollBehavior(), block: "nearest" }); // Delay after scroll for layout to settle pendingTimerRef.current = setTimeout(() => { if (cancelled) return; @@ -292,9 +293,7 @@ export function TourOverlay() { const isFirst = currentStep === 0; const isInteractive = step.interactive; - const reducedMotion = - typeof window !== "undefined" && - window.matchMedia("(prefers-reduced-motion: reduce)").matches; + const reducedMotion = typeof window !== "undefined" && prefersReducedMotion(); const transitionStyle = reducedMotion ? "none" : "all 300ms ease"; diff --git a/hooks/use-scroll-window.ts b/hooks/use-scroll-window.ts index 6224148df..acc32a9af 100644 --- a/hooks/use-scroll-window.ts +++ b/hooks/use-scroll-window.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react"; +import { scrollBehavior } from "@/lib/reduced-motion"; export type ScrollAxis = "vertical" | "horizontal"; export type ScrollWindowSide = "start" | "end"; @@ -19,13 +20,13 @@ export function setScrollStart(el: HTMLElement, axis: ScrollAxis, value: number) el.scrollLeft = getComputedStyle(el).direction === "rtl" ? -value : value; } -/** Smoothly scrolls to a logical start offset (RTL-aware). */ +/** Smoothly scrolls to a logical start offset (RTL-aware), at once when motion is reduced. */ export function scrollToStart(el: HTMLElement, axis: ScrollAxis, value: number): void { if (axis === "vertical") { - el.scrollTo({ top: value, behavior: "smooth" }); + el.scrollTo({ top: value, behavior: scrollBehavior() }); return; } - el.scrollTo({ left: getComputedStyle(el).direction === "rtl" ? -value : value, behavior: "smooth" }); + el.scrollTo({ left: getComputedStyle(el).direction === "rtl" ? -value : value, behavior: scrollBehavior() }); } function scrollSize(el: HTMLElement, axis: ScrollAxis): number { diff --git a/lib/__tests__/reduced-motion.test.ts b/lib/__tests__/reduced-motion.test.ts new file mode 100644 index 000000000..57fabf454 --- /dev/null +++ b/lib/__tests__/reduced-motion.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { prefersReducedMotion, scrollBehavior } from '../reduced-motion'; +import { scrollToSettingsSection } from '@/components/settings/settings-section'; +import { scrollToStart } from '@/hooks/use-scroll-window'; + +function systemPrefers(reduce: boolean) { + vi.stubGlobal('matchMedia', (query: string) => ({ + matches: reduce && query === '(prefers-reduced-motion: reduce)', + media: query, + addEventListener() {}, + removeEventListener() {}, + })); +} + +afterEach(() => { + document.documentElement.removeAttribute('data-motion'); + vi.unstubAllGlobals(); + document.body.innerHTML = ''; +}); + +describe('prefersReducedMotion', () => { + it('follows the operating system while the setting is System', () => { + systemPrefers(true); + expect(prefersReducedMotion()).toBe(true); + systemPrefers(false); + expect(prefersReducedMotion()).toBe(false); + }); + + it('reduces motion when the setting is On, whatever the system says', () => { + systemPrefers(false); + document.documentElement.setAttribute('data-motion', 'reduce'); + expect(prefersReducedMotion()).toBe(true); + expect(scrollBehavior()).toBe('auto'); + }); + + it('keeps motion when the setting is Off, whatever the system says', () => { + systemPrefers(true); + document.documentElement.setAttribute('data-motion', 'full'); + expect(prefersReducedMotion()).toBe(false); + expect(scrollBehavior()).toBe('smooth'); + }); +}); + +describe('scripted scrolling', () => { + it('jumps to a settings section instead of gliding when motion is reduced', () => { + systemPrefers(true); + document.body.innerHTML = '
'; + const el = document.querySelector('[data-settings-section="fonts"]')!; + el.scrollIntoView = vi.fn(); + expect(scrollToSettingsSection('fonts')).toBe(true); + expect(el.scrollIntoView).toHaveBeenCalledWith(expect.objectContaining({ behavior: 'auto' })); + }); + + it('still glides to a calendar day when motion is not reduced', () => { + systemPrefers(false); + const el = document.createElement('div'); + el.scrollTo = vi.fn() as typeof el.scrollTo; + scrollToStart(el, 'vertical', 120); + expect(el.scrollTo).toHaveBeenCalledWith({ top: 120, behavior: 'smooth' }); + document.documentElement.setAttribute('data-motion', 'reduce'); + scrollToStart(el, 'vertical', 40); + expect(el.scrollTo).toHaveBeenLastCalledWith({ top: 40, behavior: 'auto' }); + }); +}); diff --git a/lib/reduced-motion.ts b/lib/reduced-motion.ts new file mode 100644 index 000000000..6684a0266 --- /dev/null +++ b/lib/reduced-motion.ts @@ -0,0 +1,22 @@ +/** + * Motion that scripts start themselves (smooth scrolling, animated + * transitions set from JS) has to follow Reduce motion, which CSS alone can't + * reach. + * + * Settings > Accessibility > Reduce motion writes data-motion on + * ("reduce" for On, "full" for Off, nothing for System; see + * applyAccessibilityTo()), so the effective value is the attribute when it is + * there and the operating system's preference otherwise. + */ +export function prefersReducedMotion(root: HTMLElement | null = typeof document !== 'undefined' ? document.documentElement : null): boolean { + const choice = root?.getAttribute('data-motion'); + if (choice === 'reduce') return true; + if (choice === 'full') return false; + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false; + return window.matchMedia('(prefers-reduced-motion: reduce)').matches; +} + +/** `behavior` for scrollIntoView/scrollTo: smooth unless motion is reduced. */ +export function scrollBehavior(): ScrollBehavior { + return prefersReducedMotion() ? 'auto' : 'smooth'; +}