import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
import {
AnimatePresence,
motion,
useMotionValue,
useMotionValueEvent,
useScroll,
useSpring,
useTransform,
type MotionValue,
} from "framer-motion";
interface ReadingProgressProps {
accent?: string;
/** Muestra los minutos de lectura que quedan. */
showTime?: boolean;
/** true: caja con scroll propio. false: usa el scroll de la página (la cabecera se queda pegada arriba). */
contained?: boolean;
}
const CHAPTERS = [
{
"title": "The brief",
"body": [
"We inherited a booking app with four years of patches piled on top. It worked, but nobody really knew why. The goal was clear: booking a table should take less effort than calling the restaurant.",
"We didn’t open Figma the first week. We sat in three restaurants, wrote down every question the servers had, and timed how long it took people to find a single button."
]
},
{
"title": "Sketches",
"body": [
"We sketched more than forty screens in pencil. Most of them ended up in the trash, and that was exactly the point: fail fast and cheap.",
"One simple idea survived. A single screen with the day, the time and the party size, plus a big button that said exactly what would happen next."
]
},
{
"title": "The details",
"body": [
"We tuned the timing of every transition until you stopped noticing it. We rewrote the tone of our error messages three times so they’d sound like help, not a scolding.",
"We also spent a whole afternoon testing the app one-handed, standing up, on a bus. If it didn’t work there, it didn’t work anywhere."
]
},
{
"title": "Launch",
"body": [
"We launched in two cities first. In the first week, completed bookings rose thirty-eight percent and calls to restaurants dropped by half.",
"The best part wasn’t the numbers but a message from a server: “I can finally put my phone down and look after my tables.” That’s when we called the project done."
]
}
];
function Segment({
index,
title,
position,
accent,
current,
onJump,
}: {
index: number;
title: string;
position: MotionValue<number>;
accent: string;
current: boolean;
onJump: (i: number) => void;
}) {
const fill = useTransform(position, [index, index + 1], [0, 1]);
return (
<button
type="button"
onClick={() => onJump(index)}
aria-label={"Go to «" + title + "»"}
aria-current={current ? "true" : undefined}
className="group flex-1 py-2"
>
<span className="block h-[3px] overflow-hidden rounded-full bg-[#2a251d]/10 transition-colors group-hover:bg-[#2a251d]/20 dark:bg-white/10 dark:group-hover:bg-white/20">
<motion.span className="block h-full origin-left rounded-full" style={{ scaleX: fill, background: accent }} />
</span>
</button>
);
}
export default function ReadingProgress({
accent = "#c2410c",
showTime = true,
contained = true,
}: ReadingProgressProps) {
const scroller = useRef<HTMLDivElement>(null);
const header = useRef<HTMLElement>(null);
const refs = useRef<(HTMLElement | null)[]>([]);
const raw = useMotionValue(0);
const position = useSpring(raw, { stiffness: 260, damping: 40, restDelta: 0.001 });
const [active, setActive] = useState(0);
const [done, setDone] = useState(0);
const { scrollY } = useScroll(contained ? { container: scroller } : {});
const measure = useCallback(() => {
const root = contained ? scroller.current : null;
const top = root ? root.getBoundingClientRect().top : 0;
const height = root ? root.clientHeight : window.innerHeight;
const line = top + (header.current?.offsetHeight ?? 0) + height * 0.3;
let p = 0;
for (const el of refs.current) {
if (!el) continue;
const r = el.getBoundingClientRect();
p += Math.min(1, Math.max(0, (line - r.top) / r.height));
}
const end = root
? root.scrollTop + root.clientHeight >= root.scrollHeight - 4
: window.scrollY + window.innerHeight >= document.documentElement.scrollHeight - 4;
if (end) p = CHAPTERS.length;
raw.set(p);
setActive(Math.min(CHAPTERS.length - 1, Math.floor(p)));
setDone(p / CHAPTERS.length);
}, [contained, raw]);
useMotionValueEvent(scrollY, "change", measure);
useEffect(() => measure(), [measure]);
const jump = (i: number) => {
const el = refs.current[i];
if (!el) return;
const offset = (header.current?.offsetHeight ?? 0) + 16;
const box = scroller.current;
if (contained && box) {
const top = el.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop - offset;
box.scrollTo({ top, behavior: "smooth" });
} else {
window.scrollTo({ top: el.getBoundingClientRect().top + window.scrollY - offset, behavior: "smooth" });
}
};
const minutes = Math.ceil(4 * (1 - done));
const vars = { "--accent": accent } as CSSProperties;
const content = (
<div style={vars} className="relative bg-[#fbf8f1] text-[#2a251d] dark:bg-[#15130f] dark:text-[#ece5d8]">
<header ref={header} className="sticky top-0 z-10 bg-[#fbf8f1]/85 px-5 pb-1.5 pt-4 backdrop-blur-md dark:bg-[#15130f]/85">
<div className="flex items-center justify-between gap-3">
<div className="relative h-5 flex-1 overflow-hidden">
<AnimatePresence initial={false}>
<motion.p
key={active}
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -20, opacity: 0 }}
transition={{ type: "spring", stiffness: 380, damping: 34 }}
className="absolute inset-0 truncate text-[14px] font-semibold tracking-[-0.01em]"
>
<span className="mr-2 tabular-nums" style={{ color: accent }}>
{String(active + 1).padStart(2, "0")}
</span>
{CHAPTERS[active]?.title}
</motion.p>
</AnimatePresence>
</div>
{showTime ? (
<span className="shrink-0 text-[12px] tabular-nums text-[#8c826f]">{minutes > 0 ? minutes + " min" : "Read"}</span>
) : null}
</div>
<nav aria-label="Chapters" className="mt-1 flex gap-1.5">
{CHAPTERS.map((c, i) => (
<Segment
key={c.title}
index={i}
title={c.title}
position={position}
accent={accent}
current={i === active}
onJump={jump}
/>
))}
</nav>
</header>
<article className="mx-auto max-w-prose px-5 pb-16 pt-6">
<p className="text-[12px] font-semibold uppercase tracking-[0.16em]" style={{ color: accent }}>
Case study
</p>
<h2 className="mt-3 font-serif text-[32px] font-semibold leading-[1.06] tracking-[-0.02em] sm:text-[40px]">
How we redesigned a booking app in six weeks
</h2>
<p className="mt-4 text-[13px] text-[#8c826f]">Lucía Ferrer · 4 min read</p>
{CHAPTERS.map((c, i) => (
<section
key={c.title}
ref={(el) => {
refs.current[i] = el;
}}
className="mt-10 font-serif"
>
<h3 className="text-[22px] font-semibold tracking-[-0.01em]">{c.title}</h3>
{c.body.map((p, j) => (
<p
key={j}
className={
i === 0 && j === 0
? "mt-3 text-[17px] leading-[1.7] first-letter:float-left first-letter:mr-2 first-letter:text-[3.2em] first-letter:font-semibold first-letter:leading-[0.85] first-letter:text-[var(--accent)]"
: "mt-3 text-[17px] leading-[1.7]"
}
>
{p}
</p>
))}
</section>
))}
</article>
</div>
);
if (!contained) return content;
return (
<div
ref={scroller}
className="relative h-[30rem] w-full overflow-y-auto rounded-3xl bg-[#fbf8f1] [scrollbar-width:none] dark:bg-[#15130f]"
>
{content}
</div>
);
}