Explorer
KNOW-PAT-115

Web UI/UX - Modal Scroll Lock + Keyboard Navigation : body overflow, Escape, Arrow keys

Domaine
web-uiux
Type
pattern
Priorité
P1

Parent : [[INDEX-WEB-UIUX]]

Web UI/UX - Modal Scroll Lock + Keyboard Navigation : body overflow, Escape, Arrow keys

Problème

Un modal sans scroll lock permet de scroller la page en arrière-plan. Sans gestion clavier, l'utilisateur ne peut pas fermer le modal avec Escape ni naviguer dans une galerie avec les flèches.

Solution

Scroll lock via document.body.style.overflow, backdrop click pour fermer, et keydown listener pour Escape/ArrowLeft/ArrowRight.

export function AssetModal({ asset, onClose }: Props) {
  const [activeIdx, setActiveIdx] = useState(0)

  // Reset index quand l'asset change
  useEffect(() => { setActiveIdx(0) }, [asset.id])

  // ── BODY SCROLL LOCK ──
  useEffect(() => {
    const prev = document.body.style.overflow
    document.body.style.overflow = 'hidden'
    return () => { document.body.style.overflow = prev }
  }, [])

  const prev = useCallback(() => setActiveIdx((i) => (i - 1 + images.length) % images.length), [images.length])
  const next = useCallback(() => setActiveIdx((i) => (i + 1) % images.length), [images.length])

  // ── KEYBOARD NAVIGATION ──
  useEffect(() => {
    const h = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose()
      if (e.key === 'ArrowLeft') prev()
      if (e.key === 'ArrowRight') next()
    }
    document.addEventListener('keydown', h)
    return () => document.removeEventListener('keydown', h)
  }, [onClose, prev, next])

  return (
    <div
      className="fixed inset-0 z-50 flex items-center justify-center"
      style={{ background: 'rgba(0,0,0,0.75)', backdropFilter: 'blur(4px)' }}
      onClick={onClose} // Backdrop click
    >
      <div
        className="w-full max-w-2xl mx-4 flex flex-col max-h-[90vh] overflow-y-auto rounded-[6px]"
        style={{ background: 'var(--surface)', border: '1px solid var(--border)' }}
        onClick={(e) => e.stopPropagation()} // Empêche la fermeture au clic sur le contenu
      >
        {/* Header */}
        <div className="flex items-start justify-between gap-4 p-5 border-b" style={{ borderColor: 'var(--border)' }}>
          <h2>{asset.title}</h2>
          <button onClick={onClose}>✕</button>
        </div>

        {/* Gallery */}
        <div className="relative aspect-video w-full overflow-hidden">
          <img src={activeImage} alt={asset.title} />
          
          {images.length > 1 && (
            <>
              <button onClick={prev} className="absolute left-3 top-1/2 -translate-y-1/2">‹</button>
              <button onClick={next} className="absolute right-3 top-1/2 -translate-y-1/2">›</button>
              <div className="absolute bottom-3 left-1/2 -translate-x-1/2">
                {activeIdx + 1} / {images.length}
              </div>
            </>
          )}
        </div>
      </div>
    </div>
  )
}

Points clés

  1. Sauvegarde de document.body.style.overflow : restauration propre à la fermeture
  2. e.stopPropagation() sur le contenu du modal : évite la fermeture accidentelle
  3. Escape pour fermer : standard UX attendu par tous les utilisateurs
  4. ArrowLeft/ArrowRight pour la galerie : navigation rapide sans souris
  5. max-h-[90vh] overflow-y-auto : le contenu scrollable sans déborder l'écran
  6. z-50 : au-dessus de tout (header z-40)

Pourquoi

  • Scroll lock empêche la perte de contexte (l'utilisateur revient au même endroit)
  • Keyboard navigation = accessibilité + power users
  • Backdrop click = fermeture intuitive

Quand l'utiliser

  • Galeries, lightboxes, confirmations, formulaires
  • Tout contenu qui demande le focus exclusif

Quand NE PAS l'utiliser

  • Drawer latéral (pas besoin de backdrop blur)
  • Toast/notification (pas de scroll lock nécessaire)

Références

  • ProjectAlpha/AssetModal.tsx L42-100