feat(recorrido): la conversación como tarjetas, fijada al scroll
La sección se ancla una pantalla y avanza con el scroll: llega la tarjeta de la pregunta, se te devuelve la de creeer, y al seguir bajando entra la siguiente. Cifra fantasma, raíl de posición y barra de avance para saber dónde estás. Con prefers-reduced-motion no hay anclaje ni escenas: las seis parejas se leen seguidas. Dos fallos corregidos en el camino: - las escenas se cruzaban superpuestas y quedaban ilegibles las dos; entrada y salida ya no se solapan. - el rótulo se posicionaba con left-0 dentro de Container, que resuelve contra la caja de padding e ignora su px-6; en móvil cortaba la primera letra. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
90a35714f2
commit
81c36bb657
2 files changed
+251
-45
No files matched your search
@@ -1,65 +1,164 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from 'framer-motion';
|
||||
import { Container } from '@/components/primitives/Container';
|
||||
import { FondoPuntos } from '@/components/primitives/FondoPuntos';
|
||||
import { ParDialogo } from '@/components/primitives/ParDialogo';
|
||||
import { EscenaDialogo } from '@/components/primitives/EscenaDialogo';
|
||||
import { IlustracionInflexion } from '@/components/primitives/IlustracionInflexion';
|
||||
import { puntosInflexion } from '@/lib/content';
|
||||
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
|
||||
|
||||
export function PuntosInflexion() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
const items = puntosInflexion.items;
|
||||
const TOTAL = items.length;
|
||||
|
||||
// How far down the conversation the reader has got. It only ever moves
|
||||
// forward: an answer that faded back out on the way past would leave the page
|
||||
// emptier the further you read. Scroll drives it, and so do hover and
|
||||
// keyboard focus, so the reader can also run the conversation themselves
|
||||
// instead of only being carried through it.
|
||||
const [alcanzado, setAlcanzado] = useState(0);
|
||||
const activar = useCallback((n: number) => setAlcanzado((previo) => Math.max(previo, n)), []);
|
||||
function Cabecera({ reduced }: { reduced: boolean }) {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between md:gap-12">
|
||||
<div>
|
||||
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.eyebrow}</motion.p>
|
||||
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-4 font-sans font-black text-seccion text-azul-profundo max-w-3xl">{puntosInflexion.title}</motion.h2>
|
||||
</div>
|
||||
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-8 md:mt-0 shrink-0">
|
||||
<IlustracionInflexion className="w-56 md:w-72 h-auto mx-auto" />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Reduced motion, and the server's first paint, get the whole conversation laid
|
||||
// out at once. A pinned section that advances on scroll is exactly the thing
|
||||
// someone asking for less movement is asking to be spared, and hiding five
|
||||
// sixths of the copy behind a scroll position is not an option either way.
|
||||
function Estatica() {
|
||||
return (
|
||||
<section className="bg-gris-claro relative overflow-hidden">
|
||||
<FondoPuntos className="-bottom-6 -right-12 w-80 md:w-[32rem] text-azul-profundo/10" distance={32} />
|
||||
<Container className="relative py-16 md:py-24">
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between md:gap-12">
|
||||
<div>
|
||||
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.eyebrow}</motion.p>
|
||||
<motion.h2 variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-4 font-sans font-black text-seccion text-azul-profundo max-w-3xl">{puntosInflexion.title}</motion.h2>
|
||||
</div>
|
||||
<motion.div variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-8 md:mt-0 shrink-0">
|
||||
<IlustracionInflexion className="w-56 md:w-72 h-auto mx-auto" />
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-14 text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.lead}</motion.p>
|
||||
|
||||
{/* The conversation. One <li> per exchange, read top to bottom — no
|
||||
carousel at any breakpoint, because "te bajas" is the whole shape
|
||||
the dueña asked for. Each answer arrives when the reader reaches its
|
||||
pair, so the section answers as you go down instead of sitting there
|
||||
already answered. The stagger reaches ParDialogo's two halves
|
||||
through React context. */}
|
||||
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
|
||||
className="mt-8 list-none p-0 flex flex-col gap-16 md:gap-24">
|
||||
{puntosInflexion.items.map((item, i) => (
|
||||
<ParDialogo
|
||||
key={item.name}
|
||||
n={i + 1}
|
||||
{...item}
|
||||
activo={i + 1 <= alcanzado}
|
||||
reducido={reduced}
|
||||
onActivar={activar}
|
||||
/>
|
||||
<Cabecera reduced />
|
||||
<p className="mt-14 text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.lead}</p>
|
||||
<ul className="mt-8 list-none p-0 flex flex-col gap-16 md:gap-24">
|
||||
{items.map((item, i) => (
|
||||
<ParDialogo key={item.name} n={i + 1} {...item} activo reducido onActivar={() => {}} />
|
||||
))}
|
||||
</motion.ul>
|
||||
</ul>
|
||||
</Container>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Escenificado() {
|
||||
const contenedor = useRef<HTMLDivElement>(null);
|
||||
|
||||
// How far through the pinned stage the reader is, 0 → 1, measured by hand.
|
||||
// `useScroll({ target })` sat at exactly 0 for the whole 5.400px of scroll —
|
||||
// measured, not guessed: every scene stayed at its initial opacity from the
|
||||
// top of the section to the bottom. Reading the container's own rect on each
|
||||
// frame is a dozen lines, needs no measurement pass from the library, and can
|
||||
// be verified in the browser, which the black box could not.
|
||||
const bruto = useMotionValue(0);
|
||||
useEffect(() => {
|
||||
let pendiente = 0;
|
||||
const medir = () => {
|
||||
pendiente = 0;
|
||||
const el = contenedor.current;
|
||||
if (!el) return;
|
||||
const { top, height } = el.getBoundingClientRect();
|
||||
const recorrido = height - window.innerHeight;
|
||||
bruto.set(recorrido > 0 ? Math.min(1, Math.max(0, -top / recorrido)) : 0);
|
||||
};
|
||||
const alScroll = () => {
|
||||
if (!pendiente) pendiente = requestAnimationFrame(medir);
|
||||
};
|
||||
medir();
|
||||
window.addEventListener('scroll', alScroll, { passive: true });
|
||||
window.addEventListener('resize', alScroll);
|
||||
return () => {
|
||||
if (pendiente) cancelAnimationFrame(pendiente);
|
||||
window.removeEventListener('scroll', alScroll);
|
||||
window.removeEventListener('resize', alScroll);
|
||||
};
|
||||
}, [bruto]);
|
||||
|
||||
// Takes the jitter out of a trackpad without lagging behind the reader; the
|
||||
// hilo conductor taught this project what an overdamped spring feels like.
|
||||
const progreso = useSpring(bruto, { stiffness: 260, damping: 42, restDelta: 0.0005 });
|
||||
|
||||
const [activo, setActivo] = useState(0);
|
||||
useMotionValueEvent(progreso, 'change', (v) => {
|
||||
setActivo(Math.min(TOTAL - 1, Math.max(0, Math.floor(v * TOTAL))));
|
||||
});
|
||||
|
||||
const barra = useTransform(progreso, [0, 1], [0, 1]);
|
||||
const fondoY = useTransform(progreso, [0, 1], [0, -140]);
|
||||
|
||||
return (
|
||||
<section className="bg-gris-claro relative">
|
||||
<Container className="relative pt-16 md:pt-24">
|
||||
<Cabecera reduced={false} />
|
||||
</Container>
|
||||
|
||||
{/* One viewport of stage, six viewports of scroll to move through it. */}
|
||||
<div ref={contenedor} className="relative" style={{ height: `${TOTAL * 100}vh` }}>
|
||||
<div data-escena={activo} className="sticky top-0 h-screen overflow-hidden">
|
||||
<motion.div style={{ y: fondoY }} className="absolute inset-0">
|
||||
<FondoPuntos className="-bottom-10 -right-16 w-80 md:w-[34rem] text-azul-profundo/10" distance={32} />
|
||||
</motion.div>
|
||||
|
||||
<Container className="relative h-full">
|
||||
{/* left-6 / md:left-12, not left-0: an absolutely positioned child
|
||||
resolves against Container's padding box, so `left-0` lands on
|
||||
the inside of the border and ignores the px-6 the rest of the
|
||||
page is aligned to. On a phone that clipped the first glyph. */}
|
||||
<p className="absolute top-24 left-6 md:left-12 text-xs tracking-[0.3em] uppercase font-black text-dorado md:top-28">
|
||||
{puntosInflexion.lead}
|
||||
</p>
|
||||
|
||||
{/* The rail. Where you are in the conversation, and how much is
|
||||
left — a pinned section without it feels like a page that
|
||||
stopped responding. */}
|
||||
<ol aria-hidden="true" className="absolute right-0 top-1/2 hidden -translate-y-1/2 list-none flex-col gap-3 p-0 md:flex">
|
||||
{items.map((item, i) => (
|
||||
<li key={item.name}>
|
||||
<span
|
||||
className={`block h-px origin-right transition-all duration-500 ${
|
||||
i === activo ? 'w-10 bg-dorado' : i < activo ? 'w-6 bg-dorado/40' : 'w-4 bg-azul-profundo/20'
|
||||
}`}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<ol className="relative m-0 h-full list-none p-0">
|
||||
{items.map((item, i) => (
|
||||
<EscenaDialogo
|
||||
key={item.name}
|
||||
i={i}
|
||||
total={TOTAL}
|
||||
progreso={progreso}
|
||||
n={String(i + 1).padStart(2, '0')}
|
||||
{...item}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</Container>
|
||||
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
style={{ scaleX: barra }}
|
||||
className="absolute bottom-0 left-0 h-0.5 w-full origin-left bg-dorado"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function PuntosInflexion() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
return reduced ? <Estatica /> : <Escenificado />;
|
||||
}
|
||||
Reference in new issue
Block a user