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 + MotionTexto y scroll

Índice de lectura por capítulos

Un artículo con una cabecera que te dice en qué capítulo estás y una barra por capítulo que se llena al leer.

Ajustes

Código

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": "El encargo",
    "body": [
      "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."
    ]
  },
  {
    "title": "Bocetos",
    "body": [
      "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."
    ]
  },
  {
    "title": "Los detalles",
    "body": [
      "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."
    ]
  },
  {
    "title": "Lanzamiento",
    "body": [
      "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."
    ]
  }
];

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={"Ir a «" + 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" : "Leído"}</span>
          ) : null}
        </div>
        <nav aria-label="Capítulos" 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 }}>
          Caso de estudio
        </p>
        <h2 className="mt-3 font-serif text-[32px] font-semibold leading-[1.06] tracking-[-0.02em] sm:text-[40px]">
          Cómo rediseñamos una app de reservas en seis semanas
        </h2>
        <p className="mt-4 text-[13px] text-[#8c826f]">Lucía Ferrer · 4 min de lectura</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 para IA

Pégalo en ChatGPT, Claude o Cursor y adaptará el bloque a tu proyecto con estos ajustes, aunque no uses React.

Más en Texto y scroll

Dónde usarlo

Los textos largos pierden a la gente a mitad si no ve venir el final. Esta barra de progreso de lectura se divide en un tramo por capítulo, así que un caso de estudio, un post largo o una documentación enseñan dónde estás y cuánto queda. Toca una barra para saltar a ese capítulo.

Compensa en textos de cinco minutos o más; en un post corto es solo adorno. Cada capítulo necesita un título claro, porque es lo que enseña la cabecera. Oculta los minutos restantes si tu cálculo va a ser demasiado aproximado para fiarse.