diff --git a/.gitignore b/.gitignore
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ .docusaurus/
.vercel
.env*.local
.worktrees/
+next-env.d.ts
diff --git a/packages/docs/next-env.d.ts b/packages/docs/next-env.d.ts
deleted file mode 100644
--- a/packages/docs/next-env.d.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-///
-///
-import "./.next/dev/types/routes.d.ts";
-
-// NOTE: This file should not be edited
-// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/docs/src/app/global.css b/packages/docs/src/app/global.css
--- a/packages/docs/src/app/global.css
+++ b/packages/docs/src/app/global.css
@@ -5,6 +5,7 @@ @import "@happyview/design-system/globals.css";
@import "@happyview/design-system/vaporwave.css";
@source "../../node_modules/fumadocs-ui/dist/**/*.js";
+@source "../../node_modules/fumadocs-ui/css/**/*.css";
/* --- Layout --- */
diff --git a/packages/docs/src/app/layout.tsx b/packages/docs/src/app/layout.tsx
--- a/packages/docs/src/app/layout.tsx
+++ b/packages/docs/src/app/layout.tsx
@@ -3,6 +3,7 @@ import { RootProvider } from 'fumadocs-ui/provider/next';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { source } from '@/lib/source';
import { fontVariables } from '@/lib/fonts';
+import { ReducedMotionProvider } from '@/lib/reduced-motion';
import { SidebarFooter } from '@/components/sidebar-footer';
import type { ReactNode } from 'react';
@@ -26,7 +27,8 @@ return (
-
+
{children}
-
+
+
diff --git a/packages/docs/src/components/sidebar-footer.tsx b/packages/docs/src/components/sidebar-footer.tsx
--- a/packages/docs/src/components/sidebar-footer.tsx
+++ b/packages/docs/src/components/sidebar-footer.tsx
@@ -2,12 +2,45 @@ 'use client';
import { SidebarCollapseTrigger } from 'fumadocs-ui/components/sidebar/base';
import { ThemeSwitch } from 'fumadocs-ui/layouts/shared/slots/theme-switch';
-import { SidebarIcon } from 'lucide-react';
+import { SidebarIcon, Zap, ZapOff } from 'lucide-react';
+import { useReducedMotion } from '@/lib/reduced-motion';
export function SidebarFooter() {
+ const { reducedMotion, override, setOverride } = useReducedMotion();
+
+ function cycleMotion() {
+ if (override === null) {
+ setOverride(!reducedMotion);
+ } else {
+ setOverride(null);
+ }
+ }
+
return (
+
diff --git a/packages/docs/src/components/vaporwave-grid.tsx b/packages/docs/src/components/vaporwave-grid.tsx
--- a/packages/docs/src/components/vaporwave-grid.tsx
+++ b/packages/docs/src/components/vaporwave-grid.tsx
@@ -1,18 +1,12 @@
'use client';
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef } from 'react';
+import { useReducedMotion } from '@/lib/reduced-motion';
export function VaporwaveGrid() {
const canvasRef = useRef(null);
- const [reducedMotion, setReducedMotion] = useState(false);
-
- useEffect(() => {
- const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
- setReducedMotion(mq.matches);
- const onChange = () => setReducedMotion(mq.matches);
- mq.addEventListener('change', onChange);
- return () => mq.removeEventListener('change', onChange);
- }, []);
+ const logoRef = useRef(null);
+ const { reducedMotion } = useReducedMotion();
useEffect(() => {
const canvas = canvasRef.current;
@@ -21,8 +15,11 @@
const ctx = canvas.getContext('2d');
if (!ctx) return;
- const logo = new Image();
- logo.src = '/img/face.webp';
+ if (!logoRef.current) {
+ logoRef.current = new Image();
+ logoRef.current.src = '/img/face.webp';
+ }
+ const logo = logoRef.current;
let animationId: number;
let w = 0;
@@ -39,8 +36,6 @@ ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
}
resize();
- const observer = new ResizeObserver(resize);
- observer.observe(canvas);
function render(opts: {
elapsed: number;
@@ -219,16 +214,22 @@ ctx!.fillRect(0, horizonY - 4, w, 16);
}
if (reducedMotion) {
- function drawStatic() {
- if (logo.complete && logo.naturalWidth > 0) {
- render({ elapsed: 0, faceReveal: 1, scrollOffset: 0, glitch: false });
- } else {
- logo.addEventListener('load', drawStatic, { once: true });
- }
- }
+ const drawStatic = () => render({ elapsed: 0, faceReveal: 1, scrollOffset: 0, glitch: false });
+ const observer = new ResizeObserver(() => { resize(); drawStatic(); });
+ observer.observe(canvas);
drawStatic();
+ if (!logo.complete || logo.naturalWidth === 0) {
+ logo.addEventListener('load', drawStatic, { once: true });
+ return () => {
+ logo.removeEventListener('load', drawStatic);
+ observer.disconnect();
+ };
+ }
return () => observer.disconnect();
}
+
+ const observer = new ResizeObserver(resize);
+ observer.observe(canvas);
function draw(time: number) {
const elapsed = time / 1000;
diff --git a/packages/docs/src/lib/reduced-motion.tsx b/packages/docs/src/lib/reduced-motion.tsx
new file mode 100644
--- /dev/null
+++ b/packages/docs/src/lib/reduced-motion.tsx
@@ -0,0 +1,56 @@
+'use client';
+
+import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
+
+type ReducedMotionContextValue = {
+ reducedMotion: boolean;
+ override: boolean | null;
+ setOverride: (value: boolean | null) => void;
+};
+
+const ReducedMotionContext = createContext({
+ reducedMotion: false,
+ override: null,
+ setOverride: () => {},
+});
+
+const STORAGE_KEY = 'happyview-reduced-motion';
+
+export function ReducedMotionProvider({ children }: { children: ReactNode }) {
+ const [systemPreference, setSystemPreference] = useState(false);
+ const [override, setOverrideState] = useState(null);
+
+ useEffect(() => {
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
+ setSystemPreference(mq.matches);
+ const onChange = () => setSystemPreference(mq.matches);
+ mq.addEventListener('change', onChange);
+ return () => mq.removeEventListener('change', onChange);
+ }, []);
+
+ useEffect(() => {
+ const stored = localStorage.getItem(STORAGE_KEY);
+ if (stored !== null) setOverrideState(stored === 'true');
+ }, []);
+
+ function setOverride(value: boolean | null) {
+ setOverrideState(value);
+ if (value === null) {
+ localStorage.removeItem(STORAGE_KEY);
+ } else {
+ localStorage.setItem(STORAGE_KEY, String(value));
+ }
+ }
+
+ const reducedMotion = override ?? systemPreference;
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useReducedMotion() {
+ return useContext(ReducedMotionContext);
+}