Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions src/hooks/useReducedMotion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* useReducedMotion
*
* Returns `true` when the user's OS/browser has
* `prefers-reduced-motion: reduce` set, and `false` otherwise.
*
* The hook subscribes to changes so the value stays in sync if the
* preference is toggled while the page is open (e.g. via system settings).
*
* Usage:
* const reducedMotion = useReducedMotion();
* // suppress transform/transition classes when true
*/

import { useEffect, useState } from "react";

/** Media query string for the reduced-motion preference. */
const QUERY = "(prefers-reduced-motion: reduce)";

export function useReducedMotion(): boolean {
// Initialise from the media query so there is no first-render flash.
const [matches, setMatches] = useState<boolean>(() => {
// window.matchMedia may be unavailable in SSR / test environments that
// do not mock it. Fall back to `false` (full-motion) in that case.
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return false;
}
return window.matchMedia(QUERY).matches;
});

useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
return;
}

const mql = window.matchMedia(QUERY);

// Keep state in sync with live changes (e.g. user toggles OS setting).
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);

// Use the modern addEventListener API where available; fall back to the
// deprecated addListener for older browsers.
if (typeof mql.addEventListener === "function") {
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mql as any).addListener(handler);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return () => (mql as any).removeListener(handler);
}
}, []);

return matches;
}
44 changes: 44 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -2179,6 +2179,50 @@ code,
color: #ffffff;
}

/*
* Reduced-motion static state — GrantFox FWC26
*
* When `prefers-reduced-motion: reduce` is set the user has expressed a
* preference for minimal animation. The `.tag-chip--no-motion` modifier is
* applied by the `ApiTagFilter` component (via the `useReducedMotion` hook)
* and here we strip out every animated property so chips jump directly to
* their final state with no easing or positional shift.
*
* Two coverage paths:
* 1. CSS-only: `@media (prefers-reduced-motion: reduce)` targets any
* `.tag-chip` element regardless of JS, providing a CSS-level safety net.
* 2. JS modifier: `.tag-chip--no-motion` is applied by the React component so
* the override is also effective in environments where the media query may
* not cascade correctly (e.g. CSS-in-JS isolation, shadow DOM).
*/
@media (prefers-reduced-motion: reduce) {
.tag-chip {
transition: none;
transform: none;
}

.tag-chip:hover,
.tag-chip:focus-visible {
transform: none;
}
}

/* JS-side modifier mirror — applied by useReducedMotion hook in ApiTagFilter. */
.tag-chip--no-motion,
.tag-chip--no-motion:hover,
.tag-chip--no-motion:focus-visible {
transition: none;
transform: none;
}

/* ApiTagFilter wrapper */
.api-tag-filter {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}

.api-card__stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
Expand Down
Loading
Loading