onChange(Array.isArray(v) ? v[0] : v)}
+ onValueChange={(v) => {
+ const next = Array.isArray(v) ? v[0] : v
+ if (next !== value) haptic("detent")
+ onChange(next)
+ }}
className="w-full"
/>
diff --git a/apps/web/src/lib/motion-preference.ts b/apps/web/src/lib/motion-preference.ts
new file mode 100644
index 00000000..be429ec8
--- /dev/null
+++ b/apps/web/src/lib/motion-preference.ts
@@ -0,0 +1,55 @@
+import { useEffect, useState } from "react"
+
+const STORAGE_KEY = "so4-reduced-motion"
+const CHANGE_EVENT = "so4:reduced-motion-change"
+
+function readPreference(): boolean {
+ return typeof window !== "undefined" && localStorage.getItem(STORAGE_KEY) === "true"
+}
+
+function applyPreference(enabled: boolean): void {
+ document.documentElement.dataset.reducedMotion = String(enabled)
+}
+
+/** Persist and broadcast the app-level motion preference. */
+export function setMotionReduction(enabled: boolean): void {
+ localStorage.setItem(STORAGE_KEY, String(enabled))
+ applyPreference(enabled)
+ window.dispatchEvent(new Event(CHANGE_EVENT))
+}
+
+/** Combines the system preference with the explicit app preference. */
+export function useReducedMotion(): boolean {
+ const [reduced, setReduced] = useState(false)
+
+ useEffect(() => {
+ const media = window.matchMedia("(prefers-reduced-motion: reduce)")
+ const sync = () => {
+ const enabled = media.matches || readPreference()
+ applyPreference(readPreference())
+ setReduced(enabled)
+ }
+ sync()
+ media.addEventListener("change", sync)
+ window.addEventListener(CHANGE_EVENT, sync)
+ return () => {
+ media.removeEventListener("change", sync)
+ window.removeEventListener(CHANGE_EVENT, sync)
+ }
+ }, [])
+
+ return reduced
+}
+
+export function useMotionReductionSetting(): readonly [boolean, (enabled: boolean) => void] {
+ const [enabled, setEnabled] = useState(readPreference)
+
+ useEffect(() => {
+ const sync = () => setEnabled(readPreference())
+ sync()
+ window.addEventListener(CHANGE_EVENT, sync)
+ return () => window.removeEventListener(CHANGE_EVENT, sync)
+ }, [])
+
+ return [enabled, setMotionReduction] as const
+}
diff --git a/apps/web/src/lib/use-reduced-motion.test.ts b/apps/web/src/lib/use-reduced-motion.test.ts
index a9b5635d..f9b3e3cb 100644
--- a/apps/web/src/lib/use-reduced-motion.test.ts
+++ b/apps/web/src/lib/use-reduced-motion.test.ts
@@ -1,7 +1,7 @@
import { act, renderHook } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-import { useReducedMotion } from "./use-reduced-motion"
+import { setMotionReduction, useReducedMotion } from "./motion-preference"
describe("useReducedMotion", () => {
let mediaQueryListMock: {
@@ -27,6 +27,8 @@ describe("useReducedMotion", () => {
afterEach(() => {
vi.restoreAllMocks()
+ localStorage.removeItem("so4-reduced-motion")
+ delete document.documentElement.dataset.reducedMotion
})
it("returns false initially (SSR-safe hydration)", () => {
@@ -93,6 +95,15 @@ describe("useReducedMotion", () => {
)
})
+ it("honours the in-app setting and exposes it to CSS", () => {
+ const { result } = renderHook(() => useReducedMotion())
+
+ act(() => setMotionReduction(true))
+
+ expect(result.current).toBe(true)
+ expect(document.documentElement.dataset.reducedMotion).toBe("true")
+ })
+
it("does not leak timers or listeners on rapid mount/unmount", () => {
const { rerender, unmount } = renderHook(() => useReducedMotion())
diff --git a/apps/web/src/lib/use-reduced-motion.ts b/apps/web/src/lib/use-reduced-motion.ts
index cfa3e257..bc7b8fa0 100644
--- a/apps/web/src/lib/use-reduced-motion.ts
+++ b/apps/web/src/lib/use-reduced-motion.ts
@@ -1,4 +1,4 @@
-import { useEffect, useState } from "react"
+export { useReducedMotion } from "./motion-preference"
/**
* Hook that detects the system's prefers-reduced-motion preference and updates
@@ -19,28 +19,3 @@ import { useEffect, useState } from "react"
* }
* }, [reducedMotion])
*/
-export function useReducedMotion(): boolean {
- // Default to false on server render to match initial hydration
- const [reducedMotion, setReducedMotion] = useState(false)
- const [mounted, setMounted] = useState(false)
-
- useEffect(() => {
- // Query the current preference
- const mediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)")
- setReducedMotion(mediaQuery.matches)
- setMounted(true)
-
- // Listen for changes to the preference
- const handleChange = (event: MediaQueryListEvent) => {
- setReducedMotion(event.matches)
- }
-
- mediaQuery.addEventListener("change", handleChange)
- return () => {
- mediaQuery.removeEventListener("change", handleChange)
- }
- }, [])
-
- // Return false until mounted to ensure hydration matches server render
- return mounted ? reducedMotion : false
-}
diff --git a/apps/web/src/shared/hooks/useTxSubmit.tsx b/apps/web/src/shared/hooks/useTxSubmit.tsx
index 7dff23a0..7c40b1be 100644
--- a/apps/web/src/shared/hooks/useTxSubmit.tsx
+++ b/apps/web/src/shared/hooks/useTxSubmit.tsx
@@ -3,6 +3,7 @@ import { toast } from "@workspace/ui/components/toast"
import { sendAndPoll } from "@/lib/tx-builder"
import { explorerTxUrl } from "@/app/config/network"
import { useTxFeedback } from "./useTxFeedback"
+import { haptic } from "@/shared/lib/haptics"
export type SubmitTxOptions = {
loadingMessage: string
@@ -46,6 +47,7 @@ export async function submitTx(
),
})
+ haptic("success")
// Execute onSuccess as a side effect after transaction confirmation,
// but don't let its errors overwrite transaction success state
@@ -64,6 +66,7 @@ export async function submitTx(
} catch (error) {
const message = options.onError?.(error) ?? "Transaction failed"
toast.error(message, { id: toastId })
+ haptic("error")
throw error
}
}
@@ -166,6 +169,7 @@ export function useTxSubmit() {
message: options.successMessage,
description: options.successDescription?.(hash),
})
+ haptic("success")
setState({ isLoading: false, txHash: hash, error: null })
submitInProgressRef.current = false
@@ -173,6 +177,7 @@ export function useTxSubmit() {
} catch (error) {
const message = options.onError?.(error) ?? "Transaction failed"
feedback.handleEvent({ type: "error", message })
+ haptic("error")
setState({
isLoading: false,
txHash: null,
diff --git a/apps/web/src/shared/lib/haptics.test.ts b/apps/web/src/shared/lib/haptics.test.ts
new file mode 100644
index 00000000..402d303d
--- /dev/null
+++ b/apps/web/src/shared/lib/haptics.test.ts
@@ -0,0 +1,29 @@
+import { afterEach, describe, expect, it, vi } from "vitest"
+
+import { haptic } from "./haptics"
+
+describe("haptic", () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ delete document.documentElement.dataset.reducedMotion
+ })
+
+ it("uses a short success vibration when supported", () => {
+ const vibrate = vi.fn()
+ Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate })
+
+ haptic("success")
+
+ expect(vibrate).toHaveBeenCalledWith(12)
+ })
+
+ it("does nothing when reduced motion is selected", () => {
+ const vibrate = vi.fn()
+ Object.defineProperty(navigator, "vibrate", { configurable: true, value: vibrate })
+ document.documentElement.dataset.reducedMotion = "true"
+
+ haptic("error")
+
+ expect(vibrate).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/web/src/shared/lib/haptics.ts b/apps/web/src/shared/lib/haptics.ts
new file mode 100644
index 00000000..c2cbf36f
--- /dev/null
+++ b/apps/web/src/shared/lib/haptics.ts
@@ -0,0 +1,18 @@
+type HapticKind = "success" | "error" | "detent"
+
+const patterns: Record
> = {
+ success: 12,
+ error: [18, 35, 18],
+ detent: 6,
+}
+
+/** Android vibration with an intentionally silent no-op on unsupported devices. */
+export function haptic(kind: HapticKind): void {
+ if (
+ typeof navigator === "undefined" ||
+ document.documentElement.dataset.reducedMotion === "true" ||
+ !navigator.vibrate
+ ) return
+
+ navigator.vibrate(patterns[kind])
+}
diff --git a/apps/web/src/ui/appearance-settings-panel.test.tsx b/apps/web/src/ui/appearance-settings-panel.test.tsx
index 1d801b71..3eb6b9d5 100644
--- a/apps/web/src/ui/appearance-settings-panel.test.tsx
+++ b/apps/web/src/ui/appearance-settings-panel.test.tsx
@@ -74,6 +74,11 @@ describe("AppearanceSettingsPanel", () => {
expect(screen.getByText("Sync with your device settings")).toBeInTheDocument()
})
+ it("provides an app-level reduce motion setting", () => {
+ renderWithTheme()
+ expect(screen.getByRole("switch", { name: /reduce motion/i })).toBeInTheDocument()
+ })
+
it("renders preview cards by default", () => {
const { container } = renderWithTheme()
// Previews are aria-hidden divs
diff --git a/apps/web/src/ui/appearance-settings-panel.tsx b/apps/web/src/ui/appearance-settings-panel.tsx
index 33566703..93d3f8f4 100644
--- a/apps/web/src/ui/appearance-settings-panel.tsx
+++ b/apps/web/src/ui/appearance-settings-panel.tsx
@@ -3,7 +3,9 @@
import * as React from "react"
import { RadioGroup, RadioGroupItem } from "@workspace/ui/components/radio-group"
import { cn } from "@workspace/ui/lib/utils"
+import { Switch } from "@workspace/ui/components/switch"
import { useTheme } from "./theme-provider"
+import { useMotionReductionSetting } from "@/lib/motion-preference"
import type {Theme} from "./theme-provider";
interface ThemeOption {
@@ -80,6 +82,7 @@ export function AppearanceSettingsPanel({
...props
}: AppearanceSettingsPanelProps) {
const { theme, setTheme, resolvedTheme } = useTheme()
+ const [reduceMotion, setReduceMotion] = useMotionReductionSetting()
const handleThemeChange = React.useCallback(
(value: string) => {
@@ -158,6 +161,13 @@ export function AppearanceSettingsPanel({
)}
+
+
+
+
Use instant or opacity-only transitions throughout the app.
+
+
+
)
}
diff --git a/apps/web/src/ui/landing/animated-title.tsx b/apps/web/src/ui/landing/animated-title.tsx
index 604b5b18..141d2e63 100644
--- a/apps/web/src/ui/landing/animated-title.tsx
+++ b/apps/web/src/ui/landing/animated-title.tsx
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react"
+import { useReducedMotion } from "@/lib/use-reduced-motion"
// SO4 markets: the hero sentence is the fixed "Trade [word] from your
// wallet" — every rotating word needs to read naturally in that slot. GMX's
@@ -25,24 +26,13 @@ export function AnimatedTitle() {
// since Playwright's animation freeze can't reliably override an inline
// `style.animation` referencing a custom property).
const [phase, setPhase] = useState<"idle" | "out" | "in">("idle")
- const [reducedMotion, setReducedMotion] = useState(
- () =>
- typeof window !== "undefined" &&
- window.matchMedia("(prefers-reduced-motion: reduce)").matches
- )
+ const reducedMotion = useReducedMotion()
const [isPageHidden, setIsPageHidden] = useState(false)
useEffect(() => {
- const query = window.matchMedia("(prefers-reduced-motion: reduce)")
- const updatePreference = () => {
- setReducedMotion(query.matches)
- if (query.matches) setPhase("idle")
- }
-
- query.addEventListener("change", updatePreference)
- return () => query.removeEventListener("change", updatePreference)
- }, [])
+ if (reducedMotion) setPhase("idle")
+ }, [reducedMotion])
useEffect(() => {
const handleVisibilityChange = () => {
diff --git a/apps/web/src/ui/landing/orderbook-preview.tsx b/apps/web/src/ui/landing/orderbook-preview.tsx
index 87784f87..3422884d 100644
--- a/apps/web/src/ui/landing/orderbook-preview.tsx
+++ b/apps/web/src/ui/landing/orderbook-preview.tsx
@@ -1,5 +1,6 @@
import { Link } from "@tanstack/react-router"
import { useEffect, useRef, useState } from "react"
+import { useReducedMotion } from "@/lib/use-reduced-motion"
/*
* Order book preview (OB-024).
@@ -26,23 +27,6 @@ const SPARK = [12, 18, 15, 22, 19, 26, 24, 31, 28, 34, 30, 37] as const
const TICK_MS = 2600
-function useReducedMotion(): boolean {
- const [reduced, setReduced] = useState(
- () =>
- typeof window !== "undefined" &&
- window.matchMedia("(prefers-reduced-motion: reduce)").matches
- )
-
- useEffect(() => {
- const query = window.matchMedia("(prefers-reduced-motion: reduce)")
- const update = () => setReduced(query.matches)
- query.addEventListener("change", update)
- return () => query.removeEventListener("change", update)
- }, [])
-
- return reduced
-}
-
/** Whether the panel is in the viewport and the page is visible. */
function usePreviewActive() {
const ref = useRef