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
+251 -45

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>
);
}
+144 -45
View File
@@ -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 />;
}