Cómo crear un panel de features sticky sincronizado con el scroll en React
Un panel de features sticky sincronizado con el scroll en React vigila el centro del viewport en cada evento de scroll, determina el item de feature más cercano e intercambia el visual de la derecha mediante AnimatePresence. El item activo se resalta con un border-left de acento y un fondo ligeramente tintado.
- Stack: React 18 + Framer Motion 11 + Lucide React, ~160 líneas repartidas en dos archivos.
- La detección de scroll usa useMotionValueEvent sobre scrollY, sin listeners de scroll que limpiar manualmente.
- Cambio de visual: AnimatePresence mode='wait' con transiciones opacity/scale/y a 350ms.
- Responsive: diseño apilado en una sola columna por debajo de 768px mediante una media query inline.
- Accesible: los items de feature son clicables y el estado activo se controla por la posición de scroll, no por el hover.
Features Spotlight Pin es una sección de dos columnas inspirada en las páginas de features de Linear. La columna izquierda lista tus features como cards; la columna derecha permanece fijada al viewport y muestra un visual correspondiente que aparece con un fundido y un escalado a medida que el usuario hace scroll por cada item. El resultado convierte una simple lista de features en un recorrido de producto, sin dependencias JavaScript más allá de lo que ya incluyes.
Anatomía
El contenedor exterior es un CSS grid con dos columnas iguales. La columna izquierda es una flex column de feature cards, cada una con un icon box (44x44px, redondeado), un eyebrow opcional, un título y una descripción. La card activa gana un border-left de 3px en el color de acento y un fondo ligeramente tintado mediante color-mix. La columna derecha contiene un único div sticky (top: 6rem, altura: 440px) que envuelve el switcher AnimatePresence y el subcomponente FeatureVisual.
Cómo funciona
En cada tick de scroll, useMotionValueEvent se dispara con el valor scrollY actualizado. El handler recorre el array itemRefs, llama a getBoundingClientRect en cada uno y calcula la distancia absoluta entre el centro vertical de cada card y el 45% de la altura del viewport. La card con la distancia más pequeña se convierte en el nuevo activeIndex. Cambiar activeIndex reasigna la key del hijo de AnimatePresence, lo que dispara la animación de salida en el visual saliente (opacity 0, scale 0.97, y -6) y la animación de entrada en el entrante (opacity 1, scale 1, y 0). Hacer clic en una card asigna activeIndex directamente, saltándose la detección de scroll.
Cómo crearlo en React
Configurar la grid de dos columnas y el panel sticky
Crea un contenedor CSS grid con gridTemplateColumns: '1fr 1fr'. Dale al div de la derecha position: sticky y un top fijo para que permanezca a la vista mientras la columna izquierda hace scroll. Define una altura explícita para que el visual tenga una bounding box predecible.
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "3rem" }}> <div>{/* scrollable feature list */}</div> <div style={{ position: "sticky", top: "6rem", height: 440 }}> {/* sticky visual */} </div> </div>Detectar qué item está más cerca del centro del viewport
Guarda un array de refs para cada feature card. En cada cambio de scrollY, recorre los refs, calcula la distancia entre el centro de cada card y el 45% de window.innerHeight, y actualiza activeIndex con la más cercana. Usar el 45% (ligeramente por encima del centro) hace que el cambio resulte ágil antes de que la card pase del todo por el medio de la pantalla.
const { scrollY } = useScroll(); useMotionValueEvent(scrollY, "change", () => { const viewportMid = window.innerHeight * 0.45; let closest = 0, closestDist = Infinity; itemRefs.current.forEach((el, i) => { if (!el) return; const rect = el.getBoundingClientRect(); const dist = Math.abs(rect.top + rect.height / 2 - viewportMid); if (dist < closestDist) { closestDist = dist; closest = i; } }); setActiveIndex(closest); });Cambiar el visual con AnimatePresence
Envuelve el visual de la columna derecha en AnimatePresence con mode='wait'. Asigna activeIndex como key al motion.div interior para que React desmonte el antiguo antes de montar el nuevo. Define las props initial, animate y exit para la transición de escala y fundido.
<AnimatePresence mode="wait"> <motion.div key={activeIndex} initial={{ opacity: 0, scale: 0.95, y: 10 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.97, y: -6 }} transition={{ duration: 0.35, ease: [0.16, 1, 0.3, 1] }} > <FeatureVisual feature={features[activeIndex]} /> </motion.div> </AnimatePresence>Resaltar la feature card activa
En cada card, compara su índice con activeIndex y alterna un border izquierdo y un fondo tintado. Usa color-mix para que la tinta se mantenga acorde al tema sea cual sea el preset activo. Reduce la opacidad de las cards inactivas a 0.45 para dirigir la atención hacia la actual.
style={{ opacity: isActive ? 1 : 0.45, borderLeft: `3px solid ${isActive ? "var(--color-accent)" : "transparent"}`, background: isActive ? "color-mix(in srgb, var(--color-accent) 6%, var(--color-background-card))" : "transparent", }}
Cuándo usarlo
Recurre a este patrón cuando tengas de cuatro a seis features distintas que quieras presentar como un relato guiado en lugar de una grid. Funciona bien justo después de una sección hero en páginas de producto SaaS, donde el objetivo es mostrar a los prospects las capacidades de una en una. Evítalo con tres features o menos (una grid simple se lee más rápido) y en productos mobile-first donde las columnas sticky se reducen a una pila plana que pierde el efecto de revelado.
Usado por
- Linear, Usa una columna visual fijada al scroll en su página de features para revelar cada capacidad a medida que el usuario va leyendo la lista de la izquierda.
- Vercel, Emplea visuales sticky en el lado derecho que cambian con el scroll en varias páginas de marketing de producto.
- Loom, Las secciones de features usan un panel de previsualización fijado que se actualiza a medida que el usuario hace scroll por las descripciones de casos de uso.
- Notion, Las páginas de tour de producto fijan un mockup de pantalla a la derecha mientras el lado izquierdo va recorriendo las descripciones de features.
FAQ
¿Por qué usar useMotionValueEvent en lugar de un listener de scroll clásico?
useMotionValueEvent se suscribe directamente a la motion value scrollY de Framer Motion, que ya está throttleada y se ejecuta fuera del ciclo de render de React. Un addEventListener('scroll') clásico se dispara en cada tick de scroll en el hilo principal y exige una limpieza manual con useEffect; useMotionValueEvent se encarga de ambas cosas por ti.
La columna sticky no funciona en móvil, ¿por qué?
El componente pasa a una sola columna por debajo de 768px. Con una sola columna, el visual aparece inline tras la lista de features, no hay comportamiento sticky porque el sticky solo tiene sentido junto a un sibling con scroll. Es intencionado: un panel sticky de altura completa en una pantalla pequeña taparía casi todo el contenido legible.
¿Puedo añadir más de cinco features sin problemas de rendimiento?
El handler de scroll recorre todos los itemRefs en cada tick. Para seis a diez items el coste es insignificante. Más allá de eso, plantéate throttlear el handler con requestAnimationFrame o cambiar a un enfoque IntersectionObserver, que solo se dispara cuando los items cruzan el umbral en lugar de en cada píxel de scroll.
¿Cómo reemplazo el FeatureVisual con degradado por una captura o un vídeo reales?
FeatureVisual es un subcomponente separado que recibe el objeto feature como prop. Sustituye sus internos por un <Image> o un elemento <video> manteniendo la misma interfaz de props. AnimatePresence gestiona la transición sea cual sea lo que rendericen dentro.