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} />