fix(motion): respect prefers-reduced-motion across smooth scrolls

Programmatic `scrollIntoView` / `scrollTo` calls in the settings shell,
admin shell, and coach message thread ignored the user's reduced-motion
preference — a WCAG 2.3.3 violation. Add a single `scrollBehaviorForUser`
helper in `src/lib/motion.ts` that returns `"auto"` when SSR or when
`(prefers-reduced-motion: reduce)` matches, and `"smooth"` otherwise.
Wire it through the three call-sites; tests cover all three branches.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 21:45:58 +02:00
1 parent 5902127725
commit d9fe0e5487
5 files changed
+66 -3

No files matched your search

+3 -1
View File
@@ -35,6 +35,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useTranslations } from "@/lib/i18n/context";
import { isAdminSectionSlug, type AdminSectionSlug } from "./section-slugs";
@@ -183,7 +184,8 @@ export function AdminShell({ active, children }: AdminShellProps) {
// and the first one or two chips were unreachable on narrow
// viewports.
inline: "start",
behavior: "smooth",
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
behavior: scrollBehaviorForUser(),
});
}, [activeSlug]);
@@ -12,6 +12,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useTranslations } from "@/lib/i18n/context";
import { stripChartTokens } from "@/lib/insights/chart-tokens";
import { useAuth } from "@/hooks/use-auth";
@@ -194,7 +195,8 @@ export function MessageThread({
const el = scrollerRef.current;
if (!el) return;
if (wasPinnedRef.current) {
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
el.scrollTo({ top: el.scrollHeight, behavior: scrollBehaviorForUser() });
}
}, [messages.length, streaming?.content, optimisticUser?.localId]);
+3 -1
View File
@@ -31,6 +31,7 @@ import {
} from "lucide-react";
import { cn } from "@/lib/utils";
import { scrollBehaviorForUser } from "@/lib/motion";
import { useTranslations } from "@/lib/i18n/context";
import {
SETTINGS_SECTION_SLUGS,
@@ -169,7 +170,8 @@ export function SettingsShell({ active, children }: SettingsShellProps) {
// and the first one or two chips were unreachable on narrow
// viewports.
inline: "start",
behavior: "smooth",
// v1.4.43 W5-H5 — respect `prefers-reduced-motion`.
behavior: scrollBehaviorForUser(),
});
}, [activeSlug]);
+44
View File
@@ -0,0 +1,44 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { scrollBehaviorForUser } from "@/lib/motion";
// The vitest config runs unit tests under Node (no jsdom). The helper
// only touches `window.matchMedia`, so we mount a minimal stub via
// `vi.stubGlobal` and tear it down after each case.
function mountWindow(matchesReducedMotion: boolean) {
vi.stubGlobal("window", {
matchMedia: (query: string) => ({
matches:
matchesReducedMotion && query === "(prefers-reduced-motion: reduce)",
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
describe("scrollBehaviorForUser", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns `auto` when window is undefined (SSR)", () => {
vi.stubGlobal("window", undefined);
expect(scrollBehaviorForUser()).toBe("auto");
});
it("returns `auto` when prefers-reduced-motion matches", () => {
mountWindow(true);
expect(scrollBehaviorForUser()).toBe("auto");
});
it("returns `smooth` when prefers-reduced-motion does not match", () => {
mountWindow(false);
expect(scrollBehaviorForUser()).toBe("smooth");
});
});
+13
View File
@@ -0,0 +1,13 @@
// v1.4.43 W5-H5 — single source of truth for `behavior` on the four
// programmatic `scrollIntoView` / `scrollTo` call-sites in the app.
// `prefers-reduced-motion: reduce` users were previously animated against
// their OS-level preference, which is a WCAG 2.3.3 violation. The helper
// returns `"auto"` whenever a window is unavailable (SSR) or when the
// reduced-motion media query matches.
export function scrollBehaviorForUser(): ScrollBehavior {
if (typeof window === "undefined") return "auto";
return window.matchMedia("(prefers-reduced-motion: reduce)").matches
? "auto"
: "smooth";
}