Explorer
KNOW-PAT-134

Web — UI patterns extraits de ProjectBeta : gradient button, custom cursor, CSS design system

Domaine
web-uiux
Type
pattern
Priorité
P1

Parent : [[INDEX-WEB-UIUX]]

Web — UI patterns extraits de ProjectBeta : gradient button, custom cursor, CSS design system

Pattern 1 — Gradient button animé sans hydratation mismatch (SSR safe)

Problème : Utiliser Math.random() au render initial cause un hydration mismatch Next.js entre le serveur et le client.

Solution : Initialiser avec des positions statiques { x: 0, y: 0 } pour le SSR. Dans useEffect (client only), setter isMounted = true puis remplacer par les positions aléatoires réelles. Désynchroniser les boutons avec un initialDelay aléatoire pour qu'ils ne clignotent pas tous ensemble.

const [colorPositions, setColorPositions] = useState(staticPositions) // SSR safe
const [isMounted, setIsMounted] = useState(false)

useEffect(() => {
  setIsMounted(true)
  setColorPositions(getInitialPositions()) // Positions aléatoires côté client
  const initialDelay = Math.random() * 2000 // Désynchroniser les boutons
  const timeout = setTimeout(startAnimation, initialDelay)
  return () => clearTimeout(timeout)
}, [])

Pattern 2 — Gradient animé : RAF throttlé (update toutes les 2s)

Problème : Animer des gradients complexes à 60fps via requestAnimationFrame consomme trop de CPU.

Solution : Maintenir le RAF pour garder une boucle smooth, mais ne mettre à jour les positions qu'une fois toutes les 2000ms. willChange: 'transform' + transform: translate3d() pour forcer l'accélération GPU.

// Dans la boucle RAF :
if (elapsed >= 2000) {
  setColorPositions(getInitialPositions())
  animationStartTime = now
}
// Sur chaque élément gradient :
style={{ transform: `translate3d(${x}%, ${y}%, 0)`, willChange: 'transform' }}

Pattern 3 — Custom cursor avec particules : throttle à 30ms

Problème : Générer une particule à chaque mousemove (60fps) crée des milliers de particules par seconde et sature le state React.

Solution : Throttler la génération de particules à 30ms minimum entre chaque création. Utiliser Date.now() avec useRef (pas de state) pour tracker le dernier temps sans re-render.

const lastParticleTimeRef = useRef(0)
if (now - lastParticleTimeRef.current > 30) {
  lastParticleTimeRef.current = now
  // créer la particule
}

Pattern 4 — Custom cursor rendu via createPortal

Problème : Un cursor custom dans le DOM normal est affecté par les overflow: hidden des parents et les stacking contexts.

Solution : Utiliser createPortal(cursorJSX, document.body) pour rendre le cursor directement dans le body, hors de tout contexte. pointerEvents: 'none' + zIndex: 99999 pour ne jamais bloquer les interactions.

return createPortal(
  <div style={{ position: 'fixed', pointerEvents: 'none', zIndex: 99999 }}>
    {/* cursor + particules */}
  </div>,
  document.body
)

Pattern 5 — CSS Variables pour le design system dark/light

Problème : Hardcoder des couleurs dans le CSS rend le toggle dark/light mode impossible sans réécrire tous les styles.

Solution : Définir toutes les couleurs en variables CSS HSL dans :root (light) et .dark (dark). Référencer uniquement via hsl(var(--primary)) dans Tailwind. Les composants n'ont jamais de couleurs hardcodées.

:root {
  --primary: 262 83% 58%;
  --background: 0 0% 100%;
}
.dark {
  --primary: 262 83% 58%;
  --background: 240 10% 3.9%;
}
// tailwind.config.ts
primary: { DEFAULT: 'hsl(var(--primary))' }

Pattern 6 — Masquer les scrollbars globalement sans casser le scroll

Problème : overflow: hidden cache le scroll, mais scrollbar-width: none le garde fonctionnel sans afficher la barre.

Solution : Appliquer sur * avec les 3 propriétés pour cross-browser coverage.

* {
  scrollbar-width: none !important;        /* Firefox */
  -ms-overflow-style: none !important;     /* IE/Edge */
}
*::-webkit-scrollbar { display: none !important; } /* Chrome/Safari */

Pattern 7 — Autocomplete browser sans le fond bleu horrible

Problème : Les navigateurs appliquent un fond bleu sur les champs autofill, ignorant le dark theme.

Solution : Override via -webkit-box-shadow inset blanc — le seul moyen de forcer la couleur de fond sur autofill.

input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus {
  -webkit-text-fill-color: #ffffff !important;
  -webkit-box-shadow: 0 0 0px 1000px #1a1a1a inset !important;
}

Pattern 8 — CSS animations keyframes pour effets de texte

Problème : Implémenter des effets texte (rainbow, fire, glitch, sparkle) avec JS génère des re-renders constants.

Solution : Définir tous les effets en @keyframes CSS pur. Appliquer via animation property. Zéro JS, zéro re-render, GPU accelerated nativement.

@keyframes rainbowShift {
  0% { background-position: 0% 50%; }
  100% { background-position: 200% 50%; }
}
@keyframes glitch {
  20% { transform: translate(-2px, 2px); }
  40% { transform: translate(-2px, -2px); }
  60% { transform: translate(2px, 2px); }
}

Anti-pattern — Scroll listener sans passive + sans RAF

Problème : Un scroll listener qui fait des DOM reads/writes directement bloque le thread principal et cause du jank visible.

Solution : { passive: true } sur le listener. Debouncer via requestAnimationFrame avec un flag isScheduled pour éviter les RAF multiples simultanés.

window.addEventListener('scroll', handleScroll, { passive: true })
// Dans handleScroll :
if (isScheduled) return
isScheduled = true
rafId = requestAnimationFrame(() => {
  isScheduled = false
  // DOM reads ici
})