Créer un dock navbar avec effet de grossissement en React
Un dock style macOS en React suit la position de la souris avec useMotionValue de Framer Motion, calcule la distance de chaque icône à son centre et alimente un useTransform couplé à un useSpring pour faire grossir l'icône en temps réel. Les paramètres du spring (mass, stiffness, damping) contrôlent l'élasticité du grossissement.
- Stack : React + Framer Motion 11 + Lucide React + Tailwind v4, ~225 lignes, zéro dépendance supplémentaire.
- API Framer Motion utilisées : useMotionValue, useSpring, useTransform, AnimatePresence.
- Supporte les orientations horizontale (dock bas) et verticale (dock latéral) via une prop.
- L'item actif utilise layoutId pour un indicateur en point qui glisse entre les items en shared-layout.
- Les attributs aria ne sont pas encore câblés ; les tooltips se dégradent proprement sans JS.
Ce dock flottant transpose le shelf d'applications macOS dans le navigateur. Chaque icône détecte la proximité du curseur et grossit doucement à l'approche, rétrécit au départ. La barre entière s'ouvre avec un slide-up en spring, les tooltips apparaissent au survol et un point en shared-layout glisse entre l'item actif. Il fonctionne comme barre de navigation basse, barre latérale ou lanceur de palette de commandes.
Anatomie
Le wrapper racine capture onMouseMove et écrit clientX/clientY dans deux motion values de niveau supérieur. Le conteneur du dock est un flex en ligne (ou colonne) en pilule arrondie avec un fond glass-morphism. Chaque slot d'icône est un composant DockIcon qui contient un bouton, un badge de notification, un point actif et un tooltip. Un séparateur visuel et un bouton Plus fixe terminent la liste, séparés des items dynamiques par une règle de 1px.
Comment ça marche
À l'intérieur de chaque DockIcon, useTransform lit la motion value mouseX partagée (ou mouseY en vertical) et calcule la distance depuis le centre de l'icône via getBoundingClientRect. Cette distance brute alimente un useTransform qui mappe distance vers taille ([0, 100, 200] → [56, 48, 44]px). Un useSpring enveloppe la sortie avec mass 0.1, stiffness 200 et damping 15, donnant au grossissement un ressenti vif sans être brusque. Le résultat s'applique à la largeur et la hauteur de la motion.button, pour que l'icône grandisse depuis son centre sans décaler le layout.
Comment le coder en React
Crée les motion values de la souris au niveau du dock
En tête du composant dock, appelle useMotionValue(0) pour X et Y. Attache un handler onMouseMove au div racine qui appelle mouseX.set(e.clientX) et mouseY.set(e.clientY). Passe les deux valeurs à chaque icône via des props.
const mouseX = useMotionValue(0); const mouseY = useMotionValue(0); <div onMouseMove={(e) => { mouseX.set(e.clientX); mouseY.set(e.clientY); }}>Calcule la distance pointeur-icône pour chaque item
Dans DockIcon, attache un ref au bouton. Utilise useTransform sur mouseX (ou mouseY) avec un callback qui lit getBoundingClientRect() pour obtenir le centre de l'icône, puis retourne Math.abs(positionPointeur - centre). Cela produit une valeur de distance en direct qui se met à jour à chaque frame.
const distance = useTransform(mouseX, (val) => { const bounds = ref.current?.getBoundingClientRect(); if (!bounds) return 200; return Math.abs(val - (bounds.x + bounds.width / 2)); });Convertis la distance en taille avec un spring
Utilise useTransform sur distance avec une plage d'entrée [0, 100, 200] et une plage de sortie [56, 48, 44]. Enveloppe ça dans useSpring avec une faible mass et une stiffness modérée pour que l'icône se stabilise rapidement sans rebond. Applique la motion value résultante à width et height de ta motion.button.
const size = useSpring( useTransform(distance, [0, 100, 200], [56, 48, 44]), { mass: 0.1, stiffness: 200, damping: 15 } ); <motion.button style={{ width: size, height: size }} />Ajoute le tooltip et l'indicateur actif
Enveloppe le tooltip dans AnimatePresence et rends-le uniquement quand isHovered est true. Utilise initial/animate/exit sur une motion.div pour un fade-scale rapide. Pour l'indicateur actif, donne à toutes les instances le même layoutId ('dock-active') afin que Framer Motion anime automatiquement le glissement du point d'un item à l'autre.
<AnimatePresence> {isHovered && ( <motion.div initial={{ opacity: 0, y: 4, scale: 0.9 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, y: 4, scale: 0.9 }} > {item.label} </motion.div> )} </AnimatePresence> {item.active && ( <motion.div layoutId="dock-active" className="w-1 h-1 rounded-full" /> )}
Quand l'utiliser
Utilise ce dock dans des shells d'application où tu veux une navigation compacte et toujours visible qui ne mobilise pas une colonne de sidebar entière. Il convient aux tableaux de bord SaaS, outils créatifs, sites portfolio et tout contexte avec six à dix destinations principales à atteindre rapidement. À éviter sur les pages chargées en contenu où une sidebar persistante avec labels améliore la lisibilité, et désactive l'effet de grossissement sur mobile car la détection de proximité nécessite un vrai pointeur.
Utilisé par
- Apple macOS, Le dock grossissant originel qui a inspiré ce pattern, utilisé comme lanceur d'applications principal sur chaque Mac.
- Notion, Utilise une barre d'actions flottante compacte dans ses vues canvas qui reprend la métaphore du dock.
- Framer, L'éditeur canvas de Framer expose une barre d'outils flottante avec des icônes qui grossissent et se mettent en valeur au survol.
- Linear, La barre de commandes et la palette d'actions rapides utilisent une navigation compacte orientée icônes avec des indicateurs de sélection animés.
FAQ
Pourquoi le grossissement utilise getBoundingClientRect plutôt qu'une prop de position ?
getBoundingClientRect donne la position réelle rendue de l'icône en coordonnées viewport, ce qui correspond aux valeurs clientX/clientY de l'événement souris. Passer une prop de position pré-calculée casserait si le dock change de layout, est scrollé ou repositionné dynamiquement.
Comment fixer le dock en bas de l'écran ?
Enveloppe le dock dans un conteneur fixe : `position: fixed; bottom: 1.5rem; left: 50%; transform: translateX(-50%)`. Déplace le listener onMouseMove sur window plutôt que sur le div du dock pour que la détection de proximité fonctionne même quand le curseur est au-dessus.
Peut-on utiliser le dock verticalement ?
Oui. Passe orientation='vertical' pour basculer la direction flex en colonne et changer le calcul de proximité de mouseX à mouseY. Le séparateur pivote aussi d'une règle verticale à horizontale automatiquement via le className conditionnel.
Le point actif en shared-layout s'anime-t-il entre les items ?
Oui, tant qu'un seul item a active: true à la fois. Le layoutId='dock-active' de Framer Motion suit l'instance montée unique et la repositionne en douceur quand active change d'item, sans code d'animation manuel.