From d36b7ee2ea1f79e2b355bcc61f2cf29c2716fce7 Mon Sep 17 00:00:00 2001 From: xx7412421-cloud Date: Sun, 21 Jun 2026 21:11:40 +0800 Subject: [PATCH] fix: respect reduced motion preference --- app/globals.css | 20 +++++++++ components/landing/hero.tsx | 65 +++++++++++++++++++-------- components/landing/marquee.tsx | 10 ++++- components/landing/motion.tsx | 70 ++++++++++++++++++++++++----- components/landing/reveal.tsx | 32 ++++++++++--- components/landing/text-effects.tsx | 45 +++++++++++++++---- 6 files changed, 199 insertions(+), 43 deletions(-) diff --git a/app/globals.css b/app/globals.css index 86c5a53..31b3e24 100644 --- a/app/globals.css +++ b/app/globals.css @@ -78,3 +78,23 @@ body { background-position: 200% center; } } + +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + + *, + *::before, + *::after { + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + transition-delay: 0ms !important; + } + + .gradient-text-animated { + animation: none; + background-position: 0% center; + } +} diff --git a/components/landing/hero.tsx b/components/landing/hero.tsx index fb18182..13cf3ba 100644 --- a/components/landing/hero.tsx +++ b/components/landing/hero.tsx @@ -2,9 +2,10 @@ import { ArrowRight } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { MagneticWrap } from "@/components/landing/motion"; +import { MagneticWrap, usePrefersReducedMotion } from "@/components/landing/motion"; import { ScorePreview } from "@/components/landing/score-preview"; import { AnimatedCounter, AnimatedGradientText } from "@/components/landing/text-effects"; +import { cn } from "@/lib/utils"; import { getDappUrl } from "@/lib/site"; const fadeUp = (delay: number) => @@ -14,6 +15,14 @@ const fadeUp = (delay: number) => }); export function Hero() { + const prefersReducedMotion = usePrefersReducedMotion(); + const entranceStyle = (delay: number) => + prefersReducedMotion ? undefined : fadeUp(delay); + const fadeInClass = prefersReducedMotion ? "opacity-100" : "animate-fade-in opacity-0"; + const slideUpClass = prefersReducedMotion + ? "opacity-100" + : "animate-slide-up opacity-0"; + return (
@@ -29,10 +38,18 @@ export function Hero() {
- + Zero-Knowledge ยท On-chain only @@ -41,16 +58,16 @@ export function Hero() {

YOUR WALLET IS YOUR CREDIT. @@ -58,8 +75,11 @@ export function Hero() {

All-in-one portable credit scoring for Stellar. Discover your score, access better rates, and activate your on-chain reputation @@ -67,16 +87,22 @@ export function Hero() {

No banks. No forms. No gatekeepers. Built from real on-chain proof, not paperwork.

0-850
@@ -142,8 +171,8 @@ export function Hero() {
-
-
+
+
diff --git a/components/landing/marquee.tsx b/components/landing/marquee.tsx index aeec25b..e0c6e21 100644 --- a/components/landing/marquee.tsx +++ b/components/landing/marquee.tsx @@ -1,5 +1,8 @@ "use client"; +import { usePrefersReducedMotion } from "@/components/landing/motion"; +import { cn } from "@/lib/utils"; + const ROW_A = [ "NO EMAIL REQUIRED", "NO CUSTODY", @@ -21,10 +24,15 @@ function MarqueeRow({ reverse?: boolean; }) { const doubled = [...items, ...items]; + const prefersReducedMotion = usePrefersReducedMotion(); return (
{doubled.map((item, i) => ( { + const query = window.matchMedia("(prefers-reduced-motion: reduce)"); + const updatePreference = () => setPrefersReducedMotion(query.matches); + + updatePreference(); + query.addEventListener("change", updatePreference); + return () => query.removeEventListener("change", updatePreference); + }, []); + + return prefersReducedMotion; +} + interface MouseParallaxBgProps { className?: string; } export function MouseParallaxBg({ className }: MouseParallaxBgProps) { const ref = useRef(null); + const prefersReducedMotion = usePrefersReducedMotion(); useEffect(() => { const el = ref.current; if (!el) return; + el.style.setProperty("--mx", "0"); + el.style.setProperty("--my", "0"); + + if (prefersReducedMotion) return; + function onMove(e: MouseEvent) { if (!el) return; const x = (e.clientX / window.innerWidth - 0.5) * 2; @@ -24,7 +45,7 @@ export function MouseParallaxBg({ className }: MouseParallaxBgProps) { window.addEventListener("mousemove", onMove, { passive: true }); return () => window.removeEventListener("mousemove", onMove); - }, []); + }, [prefersReducedMotion]); return (
- +
); @@ -80,7 +116,9 @@ function DiagonalSlash() { ); } -function FloatingParticles() { +function FloatingParticles({ disabled }: { disabled?: boolean }) { + if (disabled) return null; + const particles = Array.from({ length: 12 }, (_, i) => ({ id: i, left: `${(i * 17 + 7) % 100}%`, @@ -117,8 +155,10 @@ interface TiltCardProps { export function TiltCard({ children, className }: TiltCardProps) { const ref = useRef(null); + const prefersReducedMotion = usePrefersReducedMotion(); function handleMove(e: React.MouseEvent) { + if (prefersReducedMotion) return; const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); @@ -139,7 +179,11 @@ export function TiltCard({ children, className }: TiltCardProps) { ref={ref} onMouseMove={handleMove} onMouseLeave={handleLeave} - className={cn("transition-transform duration-200 ease-out will-change-transform", className)} + className={cn( + !prefersReducedMotion && + "transition-transform duration-200 ease-out will-change-transform", + className + )} > {children}
@@ -158,8 +202,10 @@ export function MagneticWrap({ strength = 0.25, }: MagneticWrapProps) { const ref = useRef(null); + const prefersReducedMotion = usePrefersReducedMotion(); function handleMove(e: React.MouseEvent) { + if (prefersReducedMotion) return; const el = ref.current; if (!el) return; const rect = el.getBoundingClientRect(); @@ -179,7 +225,11 @@ export function MagneticWrap({ ref={ref} onMouseMove={handleMove} onMouseLeave={handleLeave} - className={cn("inline-block transition-transform duration-300 ease-out", className)} + className={cn( + "inline-block", + !prefersReducedMotion && "transition-transform duration-300 ease-out", + className + )} > {children}
diff --git a/components/landing/reveal.tsx b/components/landing/reveal.tsx index 37b2c85..82c4e33 100644 --- a/components/landing/reveal.tsx +++ b/components/landing/reveal.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef, useState, type ReactNode } from "react"; +import { usePrefersReducedMotion } from "@/components/landing/motion"; import { cn } from "@/lib/utils"; type RevealVariant = "up" | "down" | "left" | "right" | "scale" | "blur"; @@ -38,8 +39,14 @@ export function Reveal({ }: RevealProps) { const ref = useRef(null); const [visible, setVisible] = useState(false); + const prefersReducedMotion = usePrefersReducedMotion(); useEffect(() => { + if (prefersReducedMotion) { + setVisible(true); + return; + } + const el = ref.current; if (!el) return; @@ -55,17 +62,21 @@ export function Reveal({ observer.observe(el); return () => observer.disconnect(); - }, []); + }, [prefersReducedMotion]); + + const isVisible = visible || prefersReducedMotion; return (
{children}
@@ -83,8 +94,14 @@ export function StaggerChildren({ }) { const ref = useRef(null); const [visible, setVisible] = useState(false); + const prefersReducedMotion = usePrefersReducedMotion(); useEffect(() => { + if (prefersReducedMotion) { + setVisible(true); + return; + } + const el = ref.current; if (!el) return; @@ -100,7 +117,9 @@ export function StaggerChildren({ observer.observe(el); return () => observer.disconnect(); - }, []); + }, [prefersReducedMotion]); + + const isVisible = visible || prefersReducedMotion; return (
@@ -110,12 +129,13 @@ export function StaggerChildren({ key={i} className={cn( "transition-all duration-700 ease-out", - visible + isVisible ? "opacity-100 translate-y-0" : "opacity-0 translate-y-6" )} style={{ - transitionDelay: visible ? `${i * staggerMs}ms` : "0ms", + transitionDelay: + isVisible && !prefersReducedMotion ? `${i * staggerMs}ms` : "0ms", }} > {child} diff --git a/components/landing/text-effects.tsx b/components/landing/text-effects.tsx index 325e5e7..939580b 100644 --- a/components/landing/text-effects.tsx +++ b/components/landing/text-effects.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { cn } from "@/lib/utils"; +import { usePrefersReducedMotion } from "@/components/landing/motion"; interface AnimatedCounterProps { value: number; @@ -21,8 +22,15 @@ export function AnimatedCounter({ const ref = useRef(null); const [display, setDisplay] = useState(0); const [started, setStarted] = useState(false); + const prefersReducedMotion = usePrefersReducedMotion(); useEffect(() => { + if (prefersReducedMotion) { + setDisplay(value); + setStarted(true); + return; + } + const el = ref.current; if (!el) return; @@ -38,9 +46,14 @@ export function AnimatedCounter({ observer.observe(el); return () => observer.disconnect(); - }, []); + }, [prefersReducedMotion, value]); useEffect(() => { + if (prefersReducedMotion) { + setDisplay(value); + return; + } + if (!started) return; let frame: number; @@ -55,7 +68,7 @@ export function AnimatedCounter({ frame = requestAnimationFrame(tick); return () => cancelAnimationFrame(frame); - }, [started, value, duration]); + }, [prefersReducedMotion, started, value, duration]); return ( @@ -79,19 +92,26 @@ export function StaggerLines({ lineClassName, delayStep = 100, }: StaggerLinesProps) { + const prefersReducedMotion = usePrefersReducedMotion(); + return ( {lines.map((line, i) => ( {line} @@ -108,7 +128,16 @@ export function AnimatedGradientText({ children: React.ReactNode; className?: string; }) { + const prefersReducedMotion = usePrefersReducedMotion(); + return ( - {children} + + {children} + ); }