React provider architecture, context shape, reduced motion hook, and AudioContext lifecycle management.
The SensoryUIProvider is a React context provider that wraps the entire application. It acts as the global configuration hub for the sound engine, managing whether sounds are enabled, what volume to use, and providing the playSound function to any sensory-ui component deep in the tree.
File: components/ui/sensory-ui/config/provider.tsx
Without a provider, each sensory-ui component would need to independently read the config, check accessibility preferences, and call the engine. This creates several problems:
AudioContext alive across page transitions, avoiding re-initialization cost or audio gaps mid-navigation.AudioContext per page. The provider ensures only one is ever created.AudioContext on hot module replacement, preventing stale state.// components/ui/sensory-ui/config/provider.tsx
import { createContext, useContext } from "react";
import type { SoundRole } from "./sound-roles";
import type { PlaySoundOptions, SoundPlayback } from "./engine";
export interface SensoryUIContextValue {
/**
* Play a sound by its semantic role.
* Resolves the file path, applies master volume, checks accessibility flags.
* Returns a SoundPlayback handle (with .stop()) or null if sound is suppressed.
*/
playSound: (
role: SoundRole,
options?: PlaySoundOptions,
) => Promise<SoundPlayback | null>;
/**
* Whether the sound system is globally enabled.
* Reflects both sensory.config.js `enabled` flag and the reduced-motion preference.
*/
enabled: boolean;
/**
* Master volume level (0–1). Sourced from sensory.config.js.
*/
volume: number;
/**
* Programmatically mute/unmute all sounds without changing the config.
*/
setMuted: (muted: boolean) => void;
/**
* Whether the user has activated the mute toggle.
*/
muted: boolean;
/**
* Whether the sound engine is currently suppressing audio due to
* motion-reduction settings.
*/
reducedMotion: boolean;
}
export const SensoryUIContext = createContext<SensoryUIContextValue | null>(
null,
);Always use the useSensoryUI() hook to access the context. It provides a
proper error message if called outside the provider.
export function useSensoryUI(): SensoryUIContextValue {
const ctx = useContext(SensoryUIContext);
if (!ctx) {
throw new Error(
"[sensory-ui] useSensoryUI must be called inside <SensoryUIProvider>",
);
}
return ctx;
}"use client"; // Required for Next.js App Router
import * as React from "react";
import {
closeAudioContext,
playSound as enginePlaySound,
} from "./engine";
import {
defaultConfig,
mergeConfig,
resolveRole,
} from "./config";
interface SensoryUIProviderProps {
children: React.ReactNode;
/** Override config programmatically (e.g., in Storybook or tests). */
config?: Partial<SensoryUIConfig>;
}
export function SensoryUIProvider({
children,
config: configOverride,
}: SensoryUIProviderProps) {
const config = React.useMemo(
() => mergeConfig(configOverride ?? {}),
[JSON.stringify(configOverride)],
);
const [muted, setMuted] = React.useState(false);
const reducedMotion = useReducedMotion(config.reducedMotion);
const shouldPlay = config.enabled && !muted && !reducedMotion;
const playSound = React.useCallback(
async (role: SoundRole, options: PlaySoundOptions = {}) => {
if (typeof window === "undefined") return null;
if (!shouldPlay) return null;
const source = resolveRole(role, config);
if (!source) return null;
const finalVolume = (options.volume ?? 1) * config.volume;
try {
return await enginePlaySound(source, {
...options,
volume: finalVolume,
});
} catch (err) {
if (process.env.NODE_ENV === "development") {
console.warn("[sensory-ui] Failed to play sound:", role, err);
}
return null;
}
},
[shouldPlay, config],
);
// Clean up AudioContext on unmount in development
React.useEffect(() => {
if (process.env.NODE_ENV !== "development") return;
return () => { void closeAudioContext(); };
}, []);
const value = React.useMemo(
() => ({
playSound,
enabled: shouldPlay,
volume: config.volume,
muted,
setMuted,
reducedMotion,
}),
[playSound, shouldPlay, config.volume, muted, reducedMotion],
);
return (
<SensoryUIContext.Provider value={value}>
{children}
</SensoryUIContext.Provider>
);
}The provider reads prefers-reduced-motion as a media query. The behaviour is governed by the reducedMotion field in sensory.config.js:
function useReducedMotion(pref: SensoryUIConfig["reducedMotion"]): boolean {
const [matches, setMatches] = React.useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
});
React.useEffect(() => {
if (typeof window === "undefined") return;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
if (pref === "force-off") return true; // Always suppress
if (pref === "force-on") return false; // Always play, ignore preference
return matches; // "inherit": respect OS setting
}| Value | Behaviour |
|---|---|
"inherit" (default) | Respects the OS/browser prefers-reduced-motion setting |
"force-on" | Always plays sounds, ignores the user's motion preference |
"force-off" | Always suppresses sounds, regardless of user preference |
app/layout.tsx)import { SensoryUIProvider } from "@/components/ui/sensory-ui/config/provider";
import type { ReactNode } from "react";
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<SensoryUIProvider>{children}</SensoryUIProvider>
</body>
</html>
);
}pages/_app.tsx)import { SensoryUIProvider } from "@/components/ui/sensory-ui/config/provider";
import type { AppProps } from "next/app";
export default function App({ Component, pageProps }: AppProps) {
return (
<SensoryUIProvider>
<Component {...pageProps} />
</SensoryUIProvider>
);
}The provider is a client component. Your root layout can remain a server component, as Next.js allows client components to receive and render server component children transparently.
"use client" Is RequiredThe provider uses:
useState: to manage the muted stateuseEffect: to watch prefers-reduced-motion changes and clean up on unmountuseCallback / useMemo: to stabilise context valuesAll of these require a client boundary. Every sensory-ui module carries its own "use client" directive (the provider, the usePlaySound hook, and each of the 24 component files), so any file that imports useSensoryUI is a client component.
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | None | Required. The entire application tree. |
config | Partial<SensoryUIConfig> | undefined | Optional runtime config overrides, merged on top of sensory.config.js. |
The provider catches audio errors internally and logs them in development. The UI is never affected by a failed audio playback. A failed decodeAudioData or ctx.resume() is swallowed with a console.warn.
Pass a config override to disable sounds or mock them:
// In a test or Storybook story
<SensoryUIProvider config={{ enabled: false }}>
<YourComponent />
</SensoryUIProvider>