fix: scroll without gliding when reduce motion is on

This commit is contained in:
Linus Rath
2026-10-29 19:38:45 +01:00
parent 7525fe18e4
commit a1ee9035de
6 changed files with 97 additions and 9 deletions
+2 -1
View File
@@ -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');
+2 -1
View File
@@ -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<HTMLElement, ReturnType<typeof setTimeout>>(
export function scrollToSettingsSection(id: string, root: ParentNode = document): boolean {
const el = root.querySelector<HTMLElement>(`[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');
+3 -4
View File
@@ -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";
+4 -3
View File
@@ -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 {
+64
View File
@@ -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 = '<div data-settings-section="fonts"></div>';
const el = document.querySelector<HTMLElement>('[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' });
});
});
+22
View File
@@ -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 <html>
* ("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';
}