Retour au catalogue

About Timeline Interactive

Timeline avec points cliquables et ligne de progression animee. Au clic, le contenu de droite change avec AnimatePresence slide. Layout split.

aboutcomplex Both Responsive a11y
corporateelegantuniversalagencysaassplit
Theme

Cómo crear una sección de timeline interactiva con clic en React

Una timeline interactiva en React muestra una lista vertical de botones de años clicables a la izquierda y un panel de detalle animado a la derecha. Una línea impulsada por un spring avanza a medida que el usuario recorre los eventos; AnimatePresence cambia el contenido del panel con un slide horizontal en cada clic.

  • Stack: React + Framer Motion 11 + CSS custom properties, ~78 líneas, sin dependencias adicionales.
  • Estado: un único índice `active` controla tanto la altura de la línea de progreso como el contenido del panel.
  • Animación: `type: spring` para la línea, `AnimatePresence mode='wait'` para el slide de entrada/salida del panel.
  • Accesible: botones con onClick, navegables con teclado; el punto activo se agranda para indicar el foco visual.
  • Admite de 3 a 6 eventos; por debajo de 3, el efecto de la línea de progreso pierde su impacto narrativo.

Este componente convierte la historia de una empresa en un relato interactivo. La columna de la izquierda enumera los hitos como botones; al hacer clic en uno, una línea de acento animada con spring avanza hasta ese punto y desliza una tarjeta de detalle en el panel de la derecha. Sustituye una lista estática por un ritmo que el visitante controla.

Anatomía

El layout es una grid CSS de dos columnas (1fr / 2fr). La columna de la izquierda contiene un contenedor relativo con una línea gris estática y una línea de acento animada con spring superpuesta encima, ambas posicionadas en absoluto. Cada evento se renderiza como un button nativo que contiene un motion.div dot (el selector) y el texto del año y el título. La columna de la derecha es una tarjeta con min-height fijo que aloja el panel de AnimatePresence; un gran número de año semitransparente se sitúa arriba como marca de agua decorativa.

Cómo funciona

La altura de la línea se deriva de `(active / Math.max(events.length - 1, 1)) * 100` por ciento, animada con `type: 'spring', stiffness: 80, damping: 20` para que se suavice hacia la nueva posición al hacer clic. El fondo y la escala de cada punto cambian según si su índice es igual o inferior a `active`, dando una lectura de 'relleno hasta aquí'. A la derecha, `AnimatePresence mode='wait'` envuelve un `motion.div` cuya key es `active`; entra desde `x: 30` y sale hacia `x: -30`, de modo que la transición se lee como un desplazamiento hacia adelante en el tiempo.

Cómo crearlo en React

  1. Configurar la grid y la línea vertical

    Crea un contenedor grid de dos columnas. Dentro de la columna izquierda, añade un wrapper relativo y posiciona en absoluto una línea gris (altura completa) y un motion.div de acento (altura variable). Ambos comparten los mismos valores de `left`, `top` y `width`; solo la línea de acento recibe `animate={{ height }}` impulsado por el índice activo.

    const lineBase = { position: "absolute", left: 7, top: 4, width: 2, borderRadius: 1 };
    
    // Static track
    <div style={{ ...lineBase, bottom: 4, background: "var(--color-border)" }} />
    
    // Animated fill
    <motion.div
      animate={{ height: `${(active / Math.max(events.length - 1, 1)) * 100}%` }}
      transition={{ type: "spring", stiffness: 80, damping: 20 }}
      style={{ ...lineBase, background: "var(--color-accent)" }}
    />
  2. Renderizar botones de eventos clicables con puntos animados

    Itera sobre los eventos y renderiza un button nativo para cada uno. Dentro, coloca un motion.div dot posicionado en absoluto para alinearse con la línea vertical. Anima su `scale` (1 o 1.3) y su `background` (acento cuando es igual o anterior a active, color border en caso contrario) para que el estado relleno/sin rellenar sea legible de inmediato.

    <motion.div
      animate={{
        scale: i === active ? 1.3 : 1,
        background: i <= active ? "var(--color-accent)" : "var(--color-border)",
      }}
      transition={{ type: "spring", stiffness: 300, damping: 20 }}
      style={{ position: "absolute", left: "-2rem", width: 14, height: 14, borderRadius: "50%" }}
    />
  3. Cambiar el panel de detalle con AnimatePresence

    En la columna de la derecha, envuelve un motion.div en AnimatePresence con `mode='wait'`. Asigna al motion.div la key `active` para que React desmonte el contenido antiguo antes de montar el nuevo. Usa `initial={{ opacity: 0, x: 30 }}`, `animate={{ opacity: 1, x: 0 }}`, `exit={{ opacity: 0, x: -30 }}` para conseguir la sensación de desplazamiento hacia adelante.

    <AnimatePresence mode="wait">
      {current && (
        <motion.div
          key={active}
          initial={{ opacity: 0, x: 30 }}
          animate={{ opacity: 1, x: 0 }}
          exit={{ opacity: 0, x: -30 }}
          transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }}
        >
          <span style={{ fontSize: "3rem", fontWeight: 900, opacity: 0.2 }}>{current.year}</span>
          <h3>{current.title}</h3>
          <p>{current.description}</p>
        </motion.div>
      )}
    </AnimatePresence>
  4. Conectar el estado y pasar los datos

    Un único índice `useState(0)` es todo el estado que necesitas. Pasa un array `events` (year, title, description) como prop con datos por defecto integrados. En móvil, la grid de dos columnas colapsa a una sola columna mediante una media query o un fallback de grid responsive; la línea sigue funcionando pero la división desaparece.

Cuándo usarlo

Recurre a este patrón en páginas About de empresas, agencias o startups que tengan una cronología significativa: rondas de financiación, lanzamientos de productos, aperturas de oficinas, pivots. Funciona mejor con 4 a 6 hitos donde cada evento tiene una historia real que contar. Evítalo si la historia de tu empresa tiene menos de 3 eventos (la línea animada pierde su efecto) o si la página ya está densa con secciones que compiten por la atención. En móvil el layout dividido colapsa, así que verifica que el renderizado a una sola columna parezca intencional.

Usado por

  • Stripe, Usa un relato basado en hitos en su página About para recorrer el crecimiento de la empresa desde 2010 hasta hoy.
  • Linear, Presenta la historia de la empresa como un relato progresivo donde cada capítulo es un hito distinto, en línea con este enfoque de panel dividido.
  • Notion, Página About interactiva con hitos anclados por año y paneles de contenido expandibles para cada etapa de la empresa.

FAQ

¿Cuántos eventos funcionan mejor?

Entre 4 y 6. Por debajo de 3, la animación de la línea de progreso parece inútil porque casi no hay nada que rellenar. Por encima de 6, la columna de la izquierda se satura y los puntos son difíciles de tocar en móvil.

¿Puedo reproducir los eventos automáticamente?

Sí. Añade un useEffect con un setInterval que incremente `active` hasta `events.length - 1` y luego limpie al desmontar. Pausa al pasar el ratón borrando el intervalo en onMouseEnter y reiniciándolo en onMouseLeave.

¿Por qué la línea usa una altura en porcentaje en lugar de píxeles?

El porcentaje hace que el cálculo sea independiente de la altura real renderizada de la columna izquierda, que varía con el contenido. La fórmula `active / (events.length - 1) * 100%` siempre alcanza exactamente el último punto, sin importar cuántos eventos haya ni la altura del texto.

¿Funciona el layout en móvil?

El componente está marcado como responsive en sus metadatos, pero la grid 1fr/2fr necesita un breakpoint explícito para colapsar a una sola columna. Añade una media query CSS o cambia a `gridTemplateColumns: '1fr'` por debajo de 640px. La lógica de la timeline y las animaciones funcionan sin cambios en modo de una sola columna.

"use client";

import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";

interface TimelineEvent { year: string; title: string; description: string }
interface AboutTimelineInteractiveProps { title?: string; subtitle?: string; events?: TimelineEvent[] }

const EASE = [0.16, 1, 0.3, 1] as const;
const lineBase = { position: "absolute" as const, left: 7, top: 4, width: 2, borderRadius: 1 };

export default function AboutTimelineInteractive({
  title = "Notre parcours",
  subtitle = "Les etapes cles qui ont forge notre identite.",
  events = [],
}: AboutTimelineInteractiveProps) {
  const [active, setActive] = useState(0);
  const current = events[active];

  return (
    <section style={{ padding: "var(--section-padding-y) 0", background: "var(--color-background)" }}>
      <div style={{ maxWidth: "var(--container-max-width)", margin: "0 auto", padding: "0 var(--container-padding-x)" }}>

Code complet réservé à Pro

Code source intégral, export multi-framework et playground.

Passer en Pro, 9,99€/mois

Reseñas

Componente de timeline interactiva en React con Framer