feat: compose page + Framer Motion animations + reduced-motion support

This commit is contained in:
erikgh committed 2026-07-21 11:31:27 -06:00
1 parent 8947a11e70
commit e98adf5b08
10 files changed
+65 -19

No files matched your search

+1 -1
View File
@@ -9,7 +9,7 @@ import { Cierre } from '@/components/sections/Cierre';
export default function Home() {
return (
<main>
<Hero variant="editorial" />
<Hero variant="editorial" /> {/* dueña puede cambiar a "hormozi" */}
<FraseAncla />
<ParaQuien />
<Mecanismo />
+3 -2
View File
@@ -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 (
<section id="contacto" className="bg-bg-dark text-cream">
<Container className="py-24 md:py-32 text-center">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce}
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="flex flex-col items-center">
<motion.div variants={fadeInUp}><Wordmark size="lg" onDark /></motion.div>
<motion.blockquote variants={fadeInUp}
+6 -5
View File
@@ -2,19 +2,20 @@
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { disciplinas } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
export function Disciplinas() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-base">
<Container className="py-24 md:py-32">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce}
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-semibold text-gold">{disciplinas.eyebrow}</motion.p>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce}
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-serif italic font-bold text-3xl md:text-5xl text-ink">{disciplinas.title}</motion.h2>
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce}
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 text-muted text-lg">{disciplinas.lead}</motion.p>
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce}
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-12 grid gap-8 md:grid-cols-3">
{disciplinas.items.map((item) => (
<motion.div key={item.name} variants={fadeInUp} className="border-t-2 border-gold pt-5">
+3 -2
View File
@@ -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 (
<section className="bg-base">
<Container className="py-24 md:py-32">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce}
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="grid gap-12 md:grid-cols-[auto_1fr] md:items-center">
{/* Portrait — placeholder gradient until dueña provides a photo */}
<motion.div variants={fadeInUp}
+3 -1
View File
@@ -3,9 +3,10 @@
import { motion } from 'framer-motion';
import { Container } from '@/components/primitives/Container';
import { fraseAncla } from '@/lib/content';
import { fadeInUp, viewportOnce } from '@/lib/motion';
import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
export function FraseAncla() {
const reduced = usePrefersReducedMotion();
return (
<section className="bg-base">
<Container className="py-24 md:py-32 text-center max-w-4xl">
@@ -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"
>
<span className="text-gold">“</span>
+3 -2
View File
@@ -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 (
<section className="bg-bg-pure">
<Container className="min-h-[88vh] flex flex-col items-center justify-center text-center py-24">
<motion.div variants={staggerContainer} initial="hidden" animate="visible" className="flex flex-col items-center">
<motion.div variants={staggerContainer} initial="hidden" animate="visible" {...motionProps(reduced)} className="flex flex-col items-center">
<motion.div variants={fadeInUp}><Wordmark size="xl" /></motion.div>
<motion.p variants={fadeInUp} className="mt-6 text-xs md:text-sm tracking-[0.3em] uppercase font-semibold text-gold">
{hero.eyebrow}
+5 -4
View File
@@ -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 (
<section className="bg-bg-dark text-cream">
<Container className="py-24 md:py-32">
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce}
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="text-xs tracking-[0.3em] uppercase font-semibold text-gold-bright">{mecanismo.eyebrow}</motion.p>
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce}
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-4 font-serif italic font-bold text-3xl md:text-5xl">{mecanismo.title}</motion.h2>
<motion.ol variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce}
<motion.ol variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
className="mt-12 grid gap-10 md:grid-cols-3">
{mecanismo.steps.map((step) => (
<motion.li key={step.n} variants={fadeInUp} className="border-t border-gold-bright/30 pt-6">
+3 -2
View File
@@ -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 (
<section className="bg-base">
<Container className="py-24 md:py-32">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} className="max-w-3xl">
<motion.div variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} className="max-w-3xl">
<motion.h2 variants={fadeInUp} className="font-serif italic font-bold text-3xl md:text-5xl text-ink leading-tight">
{paraQuien.title}
</motion.h2>
+21
View File
@@ -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 <motion.*> 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 }
: {};
+17
View File
@@ -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;
}