From e98adf5b08650a26bf69646dd81fd18dc698f055 Mon Sep 17 00:00:00 2001 From: erikgh Date: Tue, 21 Jul 2026 11:31:27 -0600 Subject: [PATCH] feat: compose page + Framer Motion animations + reduced-motion support --- app/page.tsx | 2 +- components/sections/Cierre.tsx | 5 +++-- components/sections/Disciplinas.tsx | 11 ++++++----- components/sections/Estratega.tsx | 5 +++-- components/sections/FraseAncla.tsx | 4 +++- components/sections/Hero.tsx | 5 +++-- components/sections/Mecanismo.tsx | 9 +++++---- components/sections/ParaQuien.tsx | 5 +++-- lib/motion.ts | 21 +++++++++++++++++++++ vitest.setup.ts | 17 +++++++++++++++++ 10 files changed, 65 insertions(+), 19 deletions(-) diff --git a/app/page.tsx b/app/page.tsx index b79f97b..3af79ff 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -9,7 +9,7 @@ import { Cierre } from '@/components/sections/Cierre'; export default function Home() { return (
- + {/* dueña puede cambiar a "hormozi" */} diff --git a/components/sections/Cierre.tsx b/components/sections/Cierre.tsx index cca13a4..fdfa9ae 100644 --- a/components/sections/Cierre.tsx +++ b/components/sections/Cierre.tsx @@ -4,14 +4,15 @@ import { Container } from '@/components/primitives/Container'; import { CTA } from '@/components/primitives/CTA'; import { Wordmark } from '@/components/primitives/Wordmark'; import { cierre } from '@/lib/content'; -import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion'; +import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; export function Cierre() { + const reduced = usePrefersReducedMotion(); const { whatsapp, email, linkedin } = cierre.contact; return (
- - {disciplinas.eyebrow} - {disciplinas.title} - {disciplinas.lead} - {disciplinas.items.map((item) => ( diff --git a/components/sections/Estratega.tsx b/components/sections/Estratega.tsx index 667339b..e9d2207 100644 --- a/components/sections/Estratega.tsx +++ b/components/sections/Estratega.tsx @@ -2,13 +2,14 @@ import { motion } from 'framer-motion'; import { Container } from '@/components/primitives/Container'; import { estratega } from '@/lib/content'; -import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion'; +import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; export function Estratega() { + const reduced = usePrefersReducedMotion(); return (
- {/* Portrait — placeholder gradient until dueña provides a photo */} @@ -14,6 +15,7 @@ export function FraseAncla() { initial="hidden" whileInView="visible" viewport={viewportOnce} + {...motionProps(reduced)} className="font-serif italic text-2xl md:text-4xl font-bold leading-snug text-ink" > “ diff --git a/components/sections/Hero.tsx b/components/sections/Hero.tsx index 260841f..7ba3c95 100644 --- a/components/sections/Hero.tsx +++ b/components/sections/Hero.tsx @@ -5,11 +5,12 @@ import { Wordmark } from '@/components/primitives/Wordmark'; import { CTA } from '@/components/primitives/CTA'; import { Container } from '@/components/primitives/Container'; import { hero } from '@/lib/content'; -import { easeOutExpo, fadeInUp, staggerContainer } from '@/lib/motion'; +import { easeOutExpo, fadeInUp, staggerContainer, usePrefersReducedMotion, motionProps } from '@/lib/motion'; type Variant = 'hormozi' | 'editorial'; export function Hero({ variant = 'editorial' }: { variant?: Variant }) { + const reduced = usePrefersReducedMotion(); const isHormozi = variant === 'hormozi'; const headlineBase = isHormozi ? 'font-sans font-black uppercase tracking-tight' @@ -19,7 +20,7 @@ export function Hero({ variant = 'editorial' }: { variant?: Variant }) { return (
- + {hero.eyebrow} diff --git a/components/sections/Mecanismo.tsx b/components/sections/Mecanismo.tsx index 16723be..5f430e5 100644 --- a/components/sections/Mecanismo.tsx +++ b/components/sections/Mecanismo.tsx @@ -3,17 +3,18 @@ import { motion } from 'framer-motion'; import { Container } from '@/components/primitives/Container'; import { mecanismo } from '@/lib/content'; -import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion'; +import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; export function Mecanismo() { + const reduced = usePrefersReducedMotion(); return (
- {mecanismo.eyebrow} - {mecanismo.title} - {mecanismo.steps.map((step) => ( diff --git a/components/sections/ParaQuien.tsx b/components/sections/ParaQuien.tsx index bb96a0c..6646b50 100644 --- a/components/sections/ParaQuien.tsx +++ b/components/sections/ParaQuien.tsx @@ -3,13 +3,14 @@ import { motion } from 'framer-motion'; import { Container } from '@/components/primitives/Container'; import { paraQuien } from '@/lib/content'; -import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion'; +import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; export function ParaQuien() { + const reduced = usePrefersReducedMotion(); return (
- + {paraQuien.title} diff --git a/lib/motion.ts b/lib/motion.ts index 560c0bb..a8b3ae0 100644 --- a/lib/motion.ts +++ b/lib/motion.ts @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import type { Variants } from 'framer-motion'; // EaseOutExpo — smooth editorial deceleration @@ -19,3 +20,23 @@ export const staggerContainer: Variants = { }; export const viewportOnce = { once: true, amount: 0.3 } as const; + +// Hook: returns true if user prefers reduced motion. Components use this to +// disable Framer Motion animations (per spec, a11y requirement). +export function usePrefersReducedMotion() { + const [reduced, setReduced] = useState(false); + useEffect(() => { + const mq = window.matchMedia('(prefers-reduced-motion: reduce)'); + setReduced(mq.matches); + const handler = () => setReduced(mq.matches); + mq.addEventListener('change', handler); + return () => mq.removeEventListener('change', handler); + }, []); + return reduced; +} + +// Pass to as motion-reduced guard. When reduced, variants resolve to visible immediately. +export const motionProps = (reduced: boolean) => + reduced + ? { initial: 'visible' as const, animate: 'visible' as const, whileInView: undefined, viewport: undefined } + : {}; diff --git a/vitest.setup.ts b/vitest.setup.ts index d4fb523..6ddd8ec 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -17,3 +17,20 @@ if (typeof window !== 'undefined' && !('IntersectionObserver' in window)) { window.IntersectionObserver = IntersectionObserverStub; global.IntersectionObserver = IntersectionObserverStub; } + +// jsdom lacks matchMedia; usePrefersReducedMotion() calls it in useEffect. +// Note: vitest's jsdom environment pre-defines `matchMedia` as an own property +// set to `undefined`, so `'matchMedia' in window` is true even though it isn't +// a function — guard on typeof instead of `in`. +if (typeof window !== 'undefined' && typeof window.matchMedia !== 'function') { + window.matchMedia = (query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }) as unknown as MediaQueryList; +}