Retour au catalogue

Features Spotlight Pin

Section features à deux colonnes : liste gauche scrollable avec indicateur actif (border-left accent, fond léger) et panneau droit sticky dont le visuel change dynamiquement via useScroll + AnimatePresence. Style Linear.app.

featurescomplex Both Responsive a11y
minimalelegantcorporatesaasagencyuniversalsplitsticky
Theme

Créer un panneau feature sticky synchronisé au scroll en React

Un panneau feature sticky synchronisé au scroll en React surveille le centre du viewport à chaque événement scroll, détermine l'item le plus proche et échange le visuel de droite via AnimatePresence. L'item actif est mis en évidence par un border-left accent et un fond légèrement teinté.

  • Stack : React 18 + Framer Motion 11 + Lucide React, ~160 lignes réparties sur deux fichiers.
  • La détection de scroll utilise useMotionValueEvent sur scrollY, sans écouteurs à nettoyer manuellement.
  • Changement de visuel : AnimatePresence mode='wait' avec transitions opacity/scale/y en 350ms.
  • Responsive : colonne unique en dessous de 768px via une media query inline.
  • Accessible : les items sont cliquables et l'état actif est piloté par la position de scroll, pas par le hover.

Features Spotlight Pin est une section à deux colonnes inspirée des pages features de Linear. La colonne gauche liste tes features sous forme de cards ; la colonne droite reste épinglée au viewport et affiche un visuel correspondant qui apparaît en fondu et mise à l'échelle quand l'utilisateur scrolle devant chaque item. Le résultat transforme une simple liste de features en walkthrough produit, sans dépendances JavaScript au-delà de ce que tu embarques déjà.

Anatomie

Le conteneur extérieur est une CSS grid à deux colonnes égales. La colonne gauche est une flex column de feature cards, chacune contenant une icône (44x44px, arrondie), un eyebrow optionnel, un titre et une description. La card active gagne un border-left de 3px en couleur accent et un fond légèrement teinté via color-mix. La colonne droite contient un seul div sticky (top: 6rem, hauteur: 440px) qui enveloppe le switcher AnimatePresence et le sous-composant FeatureVisual.

Comment ça marche

À chaque tick de scroll, useMotionValueEvent se déclenche avec la valeur scrollY mise à jour. Le handler itère sur le tableau itemRefs, appelle getBoundingClientRect sur chacun, et calcule la distance absolue entre le centre vertical de chaque card et 45% de la hauteur du viewport. La card avec la distance la plus faible devient le nouvel activeIndex. Changer activeIndex change la key de l'enfant AnimatePresence, déclenchant l'animation de sortie sur le visuel sortant (opacity 0, scale 0.97, y -6) et l'animation d'entrée sur le suivant (opacity 1, scale 1, y 0). Cliquer sur une card définit directement activeIndex, court-circuitant la détection de scroll.

Comment le coder en React

  1. Mettre en place la grille deux colonnes et le panneau sticky

    Crée un conteneur CSS grid avec gridTemplateColumns: '1fr 1fr'. Donne au div de droite position: sticky avec un top fixe pour qu'il reste visible pendant le scroll de la colonne gauche. Définis une hauteur explicite pour que le visuel ait une boîte limite prévisible.

    <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>
  2. Détecter quel item est le plus proche du centre du viewport

    Stocke un tableau de refs pour chaque feature card. À chaque changement de scrollY, boucle sur les refs, calcule la distance entre le centre de chaque card et 45% de window.innerHeight, et mets à jour activeIndex avec la plus proche. Utiliser 45% (légèrement au-dessus du centre) rend le changement réactif avant que la card ne passe complètement au milieu.

    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);
    });
  3. Changer le visuel avec AnimatePresence

    Enveloppe le visuel de la colonne droite dans AnimatePresence avec mode='wait'. Passe activeIndex comme key au motion.div intérieur pour que React démonte l'ancien avant de monter le nouveau. Définis initial, animate et exit pour la transition scale+fondu.

    <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>
  4. Mettre en évidence la card active

    Sur chaque card, compare son index à activeIndex et bascule un border gauche et un fond teinté. Utilise color-mix pour que la teinte reste cohérente quel que soit le preset actif. Réduis l'opacité des cards inactives à 0.45 pour diriger l'attention sur la courante.

    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",
    }}

Quand l'utiliser

Utilise ce pattern quand tu as quatre à six features distinctes à présenter comme un récit guidé plutôt qu'une grille. Il fonctionne bien juste après un hero sur les pages produit SaaS, où l'objectif est de faire découvrir les fonctionnalités une par une. Évite-le pour trois features ou moins (une grille simple est plus rapide à lire) et sur les produits mobile-first où les colonnes sticky se réduisent à une pile plate qui perd l'effet de révélation.

Utilisé par

  • Linear, Utilise une colonne visuelle épinglée au scroll sur sa page features pour révéler chaque fonctionnalité au fur et à mesure que l'utilisateur lit la liste de gauche.
  • Vercel, Emploie des visuels droits sticky qui changent au scroll sur plusieurs pages marketing produit.
  • Loom, Les sections features utilisent un panneau de prévisualisation épinglé qui se met à jour quand l'utilisateur scrolle dans les descriptions de cas d'usage.
  • Notion, Les pages de visite produit épinglent un mockup d'écran à droite pendant que la gauche présente les descriptions de features.

FAQ

Pourquoi utiliser useMotionValueEvent plutôt qu'un écouteur scroll classique ?

useMotionValueEvent souscrit directement à la motion value scrollY de Framer Motion, déjà throttlée et exécutée hors du cycle de rendu React. Un addEventListener('scroll') classique se déclenche à chaque tick dans le thread principal et demande un nettoyage manuel dans useEffect ; useMotionValueEvent gère les deux automatiquement.

La colonne sticky ne fonctionne pas sur mobile, pourquoi ?

Le composant passe en colonne unique en dessous de 768px. En colonne unique, le visuel apparaît inline après la liste de features, pas de comportement sticky car le sticky n'a de sens qu'avec un sibling scrollable. C'est voulu : un panneau sticky pleine hauteur sur petit écran masquerait presque tout le contenu lisible.

Puis-je ajouter plus de cinq features sans problème de performance ?

Le handler de scroll boucle sur tous les itemRefs à chaque tick. Pour six à dix items, le coût est négligeable. Au-delà, envisage de throttler le handler avec requestAnimationFrame ou de passer à une approche IntersectionObserver, qui ne se déclenche que quand les items franchissent le seuil plutôt qu'à chaque pixel de scroll.

Comment remplacer le FeatureVisual dégradé par une vraie capture ou une vidéo ?

FeatureVisual est un sous-composant séparé qui reçoit l'objet feature en prop. Remplace ses internes par un <Image> ou un élément <video> en gardant la même interface de props. AnimatePresence gère la transition quel que soit ce que tu rends à l'intérieur.

"use client";

import { useRef, useState } from "react";
import { motion, AnimatePresence, useScroll, useMotionValueEvent } from "framer-motion";
import * as LucideIcons from "lucide-react";
import type React from "react";
import { FeatureVisual } from "./FeatureVisual";

interface Feature {
  id: string;
  title: string;
  description: string;
  eyebrow?: string;
  icon?: string;
  visual: { gradient: string; label: string; sublabel: string };
}

interface FeaturesSpotlightPinProps {
  badge?: string;
  title?: string;
  subtitle?: string;
  features?: Feature[];

Code complet réservé à Pro

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

Passer en Pro, 9,99€/mois

Avis

Panneau feature sticky React synchronisé au scroll, Tutoriel