01El encargo

4 min

Caso de estudio

Cómo rediseñamos una app de reservas en seis semanas

Lucía Ferrer · 4 min de lectura

El encargo

Nos llegó una app de reservas con cuatro años de parches encima. Funcionaba, pero nadie sabía muy bien por qué. El objetivo era claro: que reservar mesa costara menos que llamar por teléfono.

La primera semana no abrimos Figma. Nos sentamos en tres restaurantes, apuntamos cada duda de los camareros y cronometramos cuánto tardaba la gente en encontrar un simple botón.

Bocetos

Dibujamos más de cuarenta pantallas a lápiz. La mayoría acabaron en la papelera, y eso era justo lo que buscábamos: equivocarnos rápido y barato.

De todas ellas sobrevivió una idea sencilla. Una sola pantalla con el día, la hora y el número de personas, y un botón grande que decía exactamente lo que iba a pasar.

Los detalles

Ajustamos los tiempos de cada transición hasta que dejaron de notarse. Cambiamos tres veces el tono de los errores para que sonaran a ayuda y no a regañina.

También pasamos una tarde entera probando la app con una mano, de pie y en un autobús. Lo que no funcionaba ahí, no funcionaba en ningún sitio.

Lanzamiento

Salimos primero en dos ciudades. En la primera semana, las reservas completadas subieron un treinta y ocho por ciento y las llamadas al restaurante bajaron a la mitad.

Lo mejor no fueron los números, sino un mensaje de una camarera: «Por fin puedo dejar el teléfono y atender mesas». Con eso dimos el proyecto por terminado.

React + MotionText & scroll

Reading progress by chapter

An article with a header that shows which chapter you are in and one bar per chapter that fills as you read.

Settings

Code

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>
  );
}

Prompt for AI

Paste it into ChatGPT, Claude or Cursor and it will adapt the block to your project with these settings, even if you don’t use React.

More in Text & scroll

Where to use it

Long reads lose people halfway unless they can see the end coming. This reading progress bar splits into one segment per chapter, so a case study, a long blog post or a documentation page shows both where you are and what is left. Tap a bar to jump to that chapter, and the header can show minutes remaining.

It pays off on texts of five minutes or more; on a short post it is just decoration. Each chapter needs a clear title, since that is what the header shows. Hide the minutes if your estimate would be too rough to trust.