feat: compose page + Framer Motion animations + reduced-motion support
This commit is contained in:
1 parent
8947a11e70
commit
e98adf5b08
10 files changed
+65
-19
No files matched your search
+1
-1
@@ -9,7 +9,7 @@ import { Cierre } from '@/components/sections/Cierre';
|
|||||||
export default function Home() {
|
export default function Home() {
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
<Hero variant="editorial" />
|
<Hero variant="editorial" /> {/* dueña puede cambiar a "hormozi" */}
|
||||||
<FraseAncla />
|
<FraseAncla />
|
||||||
<ParaQuien />
|
<ParaQuien />
|
||||||
<Mecanismo />
|
<Mecanismo />
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import { Container } from '@/components/primitives/Container';
|
|||||||
import { CTA } from '@/components/primitives/CTA';
|
import { CTA } from '@/components/primitives/CTA';
|
||||||
import { Wordmark } from '@/components/primitives/Wordmark';
|
import { Wordmark } from '@/components/primitives/Wordmark';
|
||||||
import { cierre } from '@/lib/content';
|
import { cierre } from '@/lib/content';
|
||||||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||||
|
|
||||||
export function Cierre() {
|
export function Cierre() {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
const { whatsapp, email, linkedin } = cierre.contact;
|
const { whatsapp, email, linkedin } = cierre.contact;
|
||||||
return (
|
return (
|
||||||
<section id="contacto" className="bg-bg-dark text-cream">
|
<section id="contacto" className="bg-bg-dark text-cream">
|
||||||
<Container className="py-24 md:py-32 text-center">
|
<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">
|
className="flex flex-col items-center">
|
||||||
<motion.div variants={fadeInUp}><Wordmark size="lg" onDark /></motion.div>
|
<motion.div variants={fadeInUp}><Wordmark size="lg" onDark /></motion.div>
|
||||||
<motion.blockquote variants={fadeInUp}
|
<motion.blockquote variants={fadeInUp}
|
||||||
|
|||||||
@@ -2,19 +2,20 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Container } from '@/components/primitives/Container';
|
import { Container } from '@/components/primitives/Container';
|
||||||
import { disciplinas } from '@/lib/content';
|
import { disciplinas } from '@/lib/content';
|
||||||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||||
|
|
||||||
export function Disciplinas() {
|
export function Disciplinas() {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
return (
|
return (
|
||||||
<section className="bg-base">
|
<section className="bg-base">
|
||||||
<Container className="py-24 md:py-32">
|
<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>
|
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>
|
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>
|
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">
|
className="mt-12 grid gap-8 md:grid-cols-3">
|
||||||
{disciplinas.items.map((item) => (
|
{disciplinas.items.map((item) => (
|
||||||
<motion.div key={item.name} variants={fadeInUp} className="border-t-2 border-gold pt-5">
|
<motion.div key={item.name} variants={fadeInUp} className="border-t-2 border-gold pt-5">
|
||||||
|
|||||||
@@ -2,13 +2,14 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Container } from '@/components/primitives/Container';
|
import { Container } from '@/components/primitives/Container';
|
||||||
import { estratega } from '@/lib/content';
|
import { estratega } from '@/lib/content';
|
||||||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||||
|
|
||||||
export function Estratega() {
|
export function Estratega() {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
return (
|
return (
|
||||||
<section className="bg-base">
|
<section className="bg-base">
|
||||||
<Container className="py-24 md:py-32">
|
<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">
|
className="grid gap-12 md:grid-cols-[auto_1fr] md:items-center">
|
||||||
{/* Portrait — placeholder gradient until dueña provides a photo */}
|
{/* Portrait — placeholder gradient until dueña provides a photo */}
|
||||||
<motion.div variants={fadeInUp}
|
<motion.div variants={fadeInUp}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Container } from '@/components/primitives/Container';
|
import { Container } from '@/components/primitives/Container';
|
||||||
import { fraseAncla } from '@/lib/content';
|
import { fraseAncla } from '@/lib/content';
|
||||||
import { fadeInUp, viewportOnce } from '@/lib/motion';
|
import { fadeInUp, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||||
|
|
||||||
export function FraseAncla() {
|
export function FraseAncla() {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
return (
|
return (
|
||||||
<section className="bg-base">
|
<section className="bg-base">
|
||||||
<Container className="py-24 md:py-32 text-center max-w-4xl">
|
<Container className="py-24 md:py-32 text-center max-w-4xl">
|
||||||
@@ -14,6 +15,7 @@ export function FraseAncla() {
|
|||||||
initial="hidden"
|
initial="hidden"
|
||||||
whileInView="visible"
|
whileInView="visible"
|
||||||
viewport={viewportOnce}
|
viewport={viewportOnce}
|
||||||
|
{...motionProps(reduced)}
|
||||||
className="font-serif italic text-2xl md:text-4xl font-bold leading-snug text-ink"
|
className="font-serif italic text-2xl md:text-4xl font-bold leading-snug text-ink"
|
||||||
>
|
>
|
||||||
<span className="text-gold">“</span>
|
<span className="text-gold">“</span>
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import { Wordmark } from '@/components/primitives/Wordmark';
|
|||||||
import { CTA } from '@/components/primitives/CTA';
|
import { CTA } from '@/components/primitives/CTA';
|
||||||
import { Container } from '@/components/primitives/Container';
|
import { Container } from '@/components/primitives/Container';
|
||||||
import { hero } from '@/lib/content';
|
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';
|
type Variant = 'hormozi' | 'editorial';
|
||||||
|
|
||||||
export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
|
export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
const isHormozi = variant === 'hormozi';
|
const isHormozi = variant === 'hormozi';
|
||||||
const headlineBase = isHormozi
|
const headlineBase = isHormozi
|
||||||
? 'font-sans font-black uppercase tracking-tight'
|
? 'font-sans font-black uppercase tracking-tight'
|
||||||
@@ -19,7 +20,7 @@ export function Hero({ variant = 'editorial' }: { variant?: Variant }) {
|
|||||||
return (
|
return (
|
||||||
<section className="bg-bg-pure">
|
<section className="bg-bg-pure">
|
||||||
<Container className="min-h-[88vh] flex flex-col items-center justify-center text-center py-24">
|
<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.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">
|
<motion.p variants={fadeInUp} className="mt-6 text-xs md:text-sm tracking-[0.3em] uppercase font-semibold text-gold">
|
||||||
{hero.eyebrow}
|
{hero.eyebrow}
|
||||||
|
|||||||
@@ -3,17 +3,18 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Container } from '@/components/primitives/Container';
|
import { Container } from '@/components/primitives/Container';
|
||||||
import { mecanismo } from '@/lib/content';
|
import { mecanismo } from '@/lib/content';
|
||||||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||||
|
|
||||||
export function Mecanismo() {
|
export function Mecanismo() {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
return (
|
return (
|
||||||
<section className="bg-bg-dark text-cream">
|
<section className="bg-bg-dark text-cream">
|
||||||
<Container className="py-24 md:py-32">
|
<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>
|
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>
|
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">
|
className="mt-12 grid gap-10 md:grid-cols-3">
|
||||||
{mecanismo.steps.map((step) => (
|
{mecanismo.steps.map((step) => (
|
||||||
<motion.li key={step.n} variants={fadeInUp} className="border-t border-gold-bright/30 pt-6">
|
<motion.li key={step.n} variants={fadeInUp} className="border-t border-gold-bright/30 pt-6">
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Container } from '@/components/primitives/Container';
|
import { Container } from '@/components/primitives/Container';
|
||||||
import { paraQuien } from '@/lib/content';
|
import { paraQuien } from '@/lib/content';
|
||||||
import { fadeInUp, staggerContainer, viewportOnce } from '@/lib/motion';
|
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||||
|
|
||||||
export function ParaQuien() {
|
export function ParaQuien() {
|
||||||
|
const reduced = usePrefersReducedMotion();
|
||||||
return (
|
return (
|
||||||
<section className="bg-base">
|
<section className="bg-base">
|
||||||
<Container className="py-24 md:py-32">
|
<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">
|
<motion.h2 variants={fadeInUp} className="font-serif italic font-bold text-3xl md:text-5xl text-ink leading-tight">
|
||||||
{paraQuien.title}
|
{paraQuien.title}
|
||||||
</motion.h2>
|
</motion.h2>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import type { Variants } from 'framer-motion';
|
import type { Variants } from 'framer-motion';
|
||||||
|
|
||||||
// EaseOutExpo — smooth editorial deceleration
|
// EaseOutExpo — smooth editorial deceleration
|
||||||
@@ -19,3 +20,23 @@ export const staggerContainer: Variants = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const viewportOnce = { once: true, amount: 0.3 } as const;
|
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,3 +17,20 @@ if (typeof window !== 'undefined' && !('IntersectionObserver' in window)) {
|
|||||||
window.IntersectionObserver = IntersectionObserverStub;
|
window.IntersectionObserver = IntersectionObserverStub;
|
||||||
global.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;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user