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:
erikghandClaude Opus 5 committed 2026-08-03 11:52:52 -06:00
1 parent 90a35714f2
commit 81c36bb657
2 files changed
+242 -36

No files matched your search

+107
View File
@@ -0,0 +1,107 @@
'use client';
import { motion, useTransform, type MotionValue } from 'framer-motion';
import { Wordmark } from '@/components/primitives/Wordmark';
// One beat of the pinned conversation, staged as two calling cards laid on a
// table: the reader's, in white, and creeer's, in navy, handed back.
//
// Every scene is mounted at all times and stacked in the same place; what
// changes is opacity and offset, driven by the section's scroll position.
// Mounting only the active scene would animate more cheaply and would strip
// five sixths of the section out of the static export.
//
// Each scene owns a slice of that scroll: the question card arrives at the top
// of its slice, creeer's card is handed over around the middle, and both leave
// as the next question comes up.
export function EscenaDialogo({
i,
total,
progreso,
n,
name,
situacion,
respuesta,
}: {
i: number;
total: number;
progreso: MotionValue<number>;
n: string;
name: string;
situacion: string;
respuesta: string;
}) {
const paso = 1 / total;
const inicio = i * paso;
const fin = inicio + paso;
// Entrance and exit must not overlap. Every scene is stacked in the same
// place, so a cross-dissolve puts two full cards of type on top of each other
// and both become unreadable — it looked like a rendering fault, not like a
// transition. One card leaves, a beat of empty stage, the next arrives.
const entrada = i === 0 ? [-1, inicio] : [inicio + paso * 0.04, inicio + paso * 0.17];
const opacidad = useTransform(
progreso,
[entrada[0], entrada[1], fin - paso * 0.17, fin - paso * 0.04],
[0, 1, 1, 0],
);
// Nothing sits perfectly still: static type on a pinned screen reads as a
// stuck page rather than as a held moment.
const deriva = useTransform(progreso, [inicio - paso, fin + paso], [70, -70]);
// Where creeer's card is handed over inside this scene's slice.
const revelado = useTransform(progreso, [inicio + paso * 0.32, inicio + paso * 0.6], [0, 1]);
const respY = useTransform(revelado, [0, 1], [70, 0]);
const respEscala = useTransform(revelado, [0, 1], [0.94, 1]);
const respGiro = useTransform(revelado, [0, 1], [5.5, 1.6]);
// The reader's card gives way a little as the answer lands: it stays
// legible, but it stops being the thing you are looking at.
const pregOpacidad = useTransform(revelado, [0, 1], [1, 0.72]);
const pregGiro = useTransform(revelado, [0, 1], [-0.6, -1.8]);
const pregY = useTransform(revelado, [0, 1], [0, -14]);
const cifraY = useTransform(progreso, [inicio - paso, fin + paso], [60, -60]);
return (
<motion.li
style={{ opacity: opacidad }}
className="absolute inset-x-0 top-1/2 -translate-y-1/2 will-change-[opacity]"
>
<motion.span
aria-hidden="true"
style={{ y: cifraY }}
className="pointer-events-none absolute -top-32 right-0 select-none font-sans font-black leading-none text-azul-profundo/[0.045] text-[9rem] md:text-[17rem]"
>
{n}
</motion.span>
<motion.div style={{ y: deriva }} className="relative">
{/* The reader's card. */}
<motion.div
style={{ opacity: pregOpacidad, rotate: pregGiro, y: pregY }}
className="relative z-10 max-w-xl rounded-xl border border-azul-profundo/10 bg-white p-7 shadow-[0_28px_70px_-32px_rgba(10,29,61,0.5)] md:p-9"
>
<p className="text-[0.65rem] font-black uppercase tracking-[0.34em] text-dorado">
{n} · {name}
</p>
<p className="mt-4 font-sans font-black leading-[1.12] text-azul-profundo text-2xl md:text-4xl">
«{situacion}»
</p>
</motion.div>
{/* creeer's card, handed back. Navy against the reader's white, tilted
the other way, overlapping the corner — two cards on a table, not
two paragraphs in a column. */}
<motion.div
style={{ opacity: revelado, y: respY, scale: respEscala, rotate: respGiro }}
className="relative z-20 -mt-5 ml-auto max-w-lg rounded-xl bg-azul-profundo p-7 shadow-[0_34px_80px_-30px_rgba(10,29,61,0.75)] md:-mt-8 md:mr-6 md:p-9"
>
<span aria-hidden="true" className="absolute left-0 top-7 h-10 w-[3px] rounded-r bg-dorado md:top-9" />
<Wordmark size="sm" onDark />
<p className="mt-4 text-base leading-relaxed text-white/75 md:text-xl">{respuesta}</p>
</motion.div>
</motion.div>
</motion.li>
);
}
+135 -36
View File
@@ -1,29 +1,20 @@
'use client'; 'use client';
import { useCallback, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { motion } from 'framer-motion'; import { motion, useMotionValue, useMotionValueEvent, useSpring, useTransform } from 'framer-motion';
import { Container } from '@/components/primitives/Container'; import { Container } from '@/components/primitives/Container';
import { FondoPuntos } from '@/components/primitives/FondoPuntos'; import { FondoPuntos } from '@/components/primitives/FondoPuntos';
import { ParDialogo } from '@/components/primitives/ParDialogo'; import { ParDialogo } from '@/components/primitives/ParDialogo';
import { EscenaDialogo } from '@/components/primitives/EscenaDialogo';
import { IlustracionInflexion } from '@/components/primitives/IlustracionInflexion'; import { IlustracionInflexion } from '@/components/primitives/IlustracionInflexion';
import { puntosInflexion } from '@/lib/content'; import { puntosInflexion } from '@/lib/content';
import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion'; import { fadeInUp, staggerContainer, viewportOnce, usePrefersReducedMotion, motionProps } from '@/lib/motion';
export function PuntosInflexion() { const items = puntosInflexion.items;
const reduced = usePrefersReducedMotion(); 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 ( 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 className="flex flex-col md:flex-row md:items-center md:justify-between md:gap-12">
<div> <div>
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)}
@@ -36,30 +27,138 @@ export function PuntosInflexion() {
<IlustracionInflexion className="w-56 md:w-72 h-auto mx-auto" /> <IlustracionInflexion className="w-56 md:w-72 h-auto mx-auto" />
</motion.div> </motion.div>
</div> </div>
);
}
<motion.p variants={fadeInUp} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} // Reduced motion, and the server's first paint, get the whole conversation laid
className="mt-14 text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.lead}</motion.p> // 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
{/* The conversation. One <li> per exchange, read top to bottom — no // sixths of the copy behind a scroll position is not an option either way.
carousel at any breakpoint, because "te bajas" is the whole shape function Estatica() {
the dueña asked for. Each answer arrives when the reader reaches its return (
pair, so the section answers as you go down instead of sitting there <section className="bg-gris-claro relative overflow-hidden">
already answered. The stagger reaches ParDialogo's two halves <FondoPuntos className="-bottom-6 -right-12 w-80 md:w-[32rem] text-azul-profundo/10" distance={32} />
through React context. */} <Container className="relative py-16 md:py-24">
<motion.ul variants={staggerContainer} initial="hidden" whileInView="visible" viewport={viewportOnce} {...motionProps(reduced)} <Cabecera reduced />
className="mt-8 list-none p-0 flex flex-col gap-16 md:gap-24"> <p className="mt-14 text-xs tracking-[0.3em] uppercase font-black text-dorado">{puntosInflexion.lead}</p>
{puntosInflexion.items.map((item, i) => ( <ul className="mt-8 list-none p-0 flex flex-col gap-16 md:gap-24">
<ParDialogo {items.map((item, i) => (
key={item.name} <ParDialogo key={item.name} n={i + 1} {...item} activo reducido onActivar={() => {}} />
n={i + 1}
{...item}
activo={i + 1 <= alcanzado}
reducido={reduced}
onActivar={activar}
/>
))} ))}
</motion.ul> </ul>
</Container> </Container>
</section> </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 />;
}