Explorer
KNOW-PAT-135

Web — Patterns avancés ProjectBeta : WebGL, upload multipart, UX editor, nav active

Domaine
web-uiux
Type
pattern
Priorité
P1

Parent : [[INDEX-WEB-UIUX]]

Web — Patterns avancés ProjectBeta : WebGL, upload multipart, UX editor, nav active

Pattern 1 — WebGL sur un bouton : gradient mesh animé via fragment shader

Problème : Les gradients CSS animés complexes sont limités (pas de bruit organique, pas de blobs fluides).

Solution : Monter un canvas WebGL directement dans le bouton. Vertex shader minimal (full quad), tout le travail dans le fragment shader. Noise FBM (Fractional Brownian Motion) multi-octave pour le mouvement organique. u_time uniform passé à chaque frame pour l'animation. preserveDrawingBuffer: false pour la perf.

// Fragment shader : FBM noise pour distorsion organique
float fbm(vec2 p, float time) {
  float value = 0.0;
  float amplitude = 0.5;
  for (int i = 0; i < 4; i++) {
    value += amplitude * noise(p + time * 0.1);
    p *= 2.0;
    amplitude *= 0.5;
  }
  return value;
}
// Blobs animés par sin/cos avec vitesses différentes → désynchronisés naturellement
vec2 pos1 = vec2(0.3, 0.5) + vec2(sin(u_time * 0.0005) * 0.25, cos(u_time * 0.0006) * 0.2);

Pattern de cleanup WebGL : toujours cancelAnimationFrame + removeEventListener dans le return du useEffect.


Pattern 2 — Scroll smooth Lenis : désactiver sur mobile et prefers-reduced-motion

Problème : Lenis smooth scroll sur mobile est contre-intuitif et ignore les préférences d'accessibilité.

Solution : Vérifier isMobileDevice() ET prefersReducedMotion() avant d'initialiser Lenis. Si l'un est vrai → ne pas activer. Easing exponentiel pour un feel naturel.

const shouldEnableLenis = !isMobileDevice() && !prefersReducedMotion()
// Easing recommandé :
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t))

Pattern 3 — Navigation active avec underline animé (getBoundingClientRect)

Problème : Animer un indicateur de navigation actif entre les liens nécessite de connaître la position exacte de chaque lien — impossible avec du CSS pur si les largeurs sont dynamiques.

Solution : Ref sur le <ul> de navigation. Après chaque changement de route, trouver l'élément actif, calculer linkRect.left - navRect.left et linkRect.width. Appliquer via style inline sur un pseudo-élément absolu. Lancer l'update dans useEffect([pathname]).

const updateUnderlinePosition = useCallback(() => {
  const activeLink = navRef.current?.querySelector(`.${styles.active}`)
  if (activeLink) {
    const navRect = navRef.current.getBoundingClientRect()
    const linkRect = activeLink.getBoundingClientRect()
    setUnderlineStyle({
      width: `${linkRect.width}px`,
      left: `${linkRect.left - navRect.left}px`
    })
  }
}, [])

Pattern 4 — Burger menu : bloquer le scroll body quand ouvert

Problème : Un menu mobile ouvert laisse le background scrollable, ce qui cause du layout shift et une mauvaise UX.

Solution : document.body.style.overflow = 'hidden' quand ouvert. Cleanup dans le return du useEffect pour garantir la restauration même si le composant unmount pendant que le menu est ouvert.

useEffect(() => {
  document.body.style.overflow = isBurgerOpen ? 'hidden' : 'unset'
  return () => { document.body.style.overflow = 'unset' }
}, [isBurgerOpen])

Pattern 5 — Upload hybride : multipart parallèle pour gros fichiers

Problème : Un upload fetch classique d'un fichier 500MB bloque, n'a pas de progress, et timeout.

Solution : Seuil 10MB → XMLHttpRequest avec xhr.upload.progress pour le progress réel. Au-dessus : multipart S3 avec chunks de 5MB uploadés en parallel par batch de 4 (Promise.all). Progress calculé sur completedParts / totalParts.

const MULTIPART_THRESHOLD = 10 * 1024 * 1024  // 10MB
const CHUNK_SIZE = 5 * 1024 * 1024             // 5MB
const MAX_CONCURRENT_UPLOADS = 4

// Batch de 4 chunks en parallèle :
for (let i = 0; i < totalParts; i += MAX_CONCURRENT_UPLOADS) {
  const batch = chunks.slice(i, i + MAX_CONCURRENT_UPLOADS).map(uploadChunk)
  await Promise.all(batch)
}

Pattern 6 — NSFW check + presigned URL en parallèle (zéro overhead)

Problème : Faire la détection NSFW avant de demander l'URL presignée ajoute 200-500ms au temps d'upload.

Solution : Promise.all([checkNSFW(file), fetch(presignedUrlEndpoint)]) — les deux tournent simultanément. Si NSFW détecté → throw avant l'upload. L'URL presignée était prête de toute façon.

const [nsfwResult, presignedResponse] = await Promise.all([
  shouldCheckNSFW ? checkNSFW(fileToUpload) : Promise.resolve(null),
  fetch(`/api/media/presigned-url?...`)
])
if (nsfwResult?.isNSFW) throw new Error('Content not allowed')

Pattern 7 — Compression image client-side avant upload

Problème : Uploader une photo brute de 8MB depuis un iPhone surcharge le réseau et la bande passante S3.

Solution : Compresser via browser-image-compression avec useWebWorker: true (non-bloquant). Skip les formats déjà optimisés (WebP, GIF). Skip les fichiers petits < 300KB. Convertir systématiquement en WebP quality 85%.

if (file.type === 'image/gif') return file   // Préserver les animations
if (file.type === 'image/webp') return file  // Déjà optimisé
if (file.size < 300 * 1024) return file      // Trop petit à compresser

await imageCompression(file, {
  maxSizeMB: 5, maxWidthOrHeight: 2048,
  useWebWorker: true, initialQuality: 0.85,
  fileType: 'image/webp'
})

Pattern 8 — Draft recovery UX : modal avec relative time

Problème : Afficher "votre brouillon du 2026-05-02T14:32:00" est technique et peu lisible.

Solution : Formatter en relatif ("yesterday", "2 days ago", "1 hour ago") + date absolue secondaire. Modal avec deux actions claires : destructive (discard, rouge) vs primaire (continue, blanc). AnimatePresence + spring animation pour l'entrée/sortie.

// Spring modal : feel naturel vs ease linéaire
transition={{ type: "spring", damping: 25, stiffness: 300 }}
// Layout actions : destructive à gauche, primaire à droite
<button className="text-red-400 bg-red-500/10">Discard</button>
<button className="text-black bg-white">Continue editing</button>

Pattern 9 — Collaboration : hover state des cursors autres utilisateurs (style Figma)

Problème : Afficher juste le curseur d'un collaborateur sans feedback visuel sur ce qu'il survole est peu informatif.

Solution : elementFromPoint(cursor.x, cursor.y) pour trouver l'élément sous le curseur de chaque collaborateur. Ajouter une classe CSS collab-hover avec outline coloré (couleur propre à chaque user). Débouncer à 50ms pour éviter le flashing. Nettoyer les anciens éléments via un Map<userId, Element>.

const element = document.elementFromPoint(cursor.x, cursor.y)
element.classList.add('collab-hover')
element.style.setProperty('--collab-hover-color', cursor.color)
// CSS :
.collab-hover { outline: 2px solid var(--collab-hover-color) !important; }

Pattern 10 — Server/Client split pour Header et Footer (Next.js App Router)

Problème : Un Header avec authentification et animations ne peut pas être full Server Component, mais le rendre entièrement client perd les bénéfices du SSR.

Solution : HeaderServer.tsx (RSC) récupère la session, passe les données à HeaderClient.tsx ('use client') via props. Le client reçoit isAuthenticated, locale, translations — jamais de fetch côté client pour ces données. Même pattern pour Footer.

// HeaderServer.tsx (RSC)
const session = await getServerSession()
return <HeaderClient isAuthenticated={!!session} locale={locale} translations={t} />