Baptiste Drillien
Baptiste Drillien

Développeur Next.js - Freelance

Disponible - Me contacter

UI Engineering : créer des interfaces haut de gamme avec Next.js

Dans le développement front moderne, on croise souvent des interfaces fonctionnelles, mais qui manquent de qualité perçue.

La qualité d'une interface ne vient pas de la couleur des boutons, mais de la réponse de l'interface aux actions de l'utilisateur.

C'est ici que l'UI Engineering brille.

L’UI Engineer n’optimise pas seulement des mesures objectives comme Lighthouse. Il optimise la perception : ce qui semble instantané, ce qui paraît fluide, ce qui inspire confiance.

L’objectif de cet article : comprendre l’UI Engineering et appliquer ses principes pour créer des interfaces haut de gamme avec Next.js.

Qu'est-ce que l'UI Engineering ?

Définition et positionnement

L'UI Engineering est un rôle qui se situe à l'intersection du design et du développement.

Contrairement au développeur front-end classique qui développe des features, l'UI Engineer se concentre sur la qualité perçue de l'interface utilisateur.

Différences clés :

Le niveau "senior UI Engineer"

Un senior UI Engineer sait :

Parler les deux langues : design & code

L'UI Engineer doit comprendre le design autant que le code. Il doit :

Cette capacité à parler les deux langues permet de créer des interfaces qui respectent l'intention du design.

Tout en étant techniquement solides.

Feedback optimiste et réactivité de l'interface

La qualité perçue vient de la réponse immédiate de l'interface. L'utilisateur doit sentir que l'application réagit instantanément à ses actions.

Feedback Optimiste (Optimistic UI)

Le feedback optimiste consiste à mettre à jour l'interface avant même que le serveur n'ait répondu. Cela crée une sensation d'instantanéité.

Avec React et Next.js, on peut utiliser le hook useOptimistic pour implémenter ce pattern :

"use client"

import { useOptimistic } from "react"

type Props = {
postId: string
initialLikes: number
}

function LikeButton({ postId, initialLikes }: Props) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
initialLikes,
(state, newValue: number) => newValue,
)

const handleLike = async () => {
// Mise à jour optimiste immédiate
addOptimisticLike(optimisticLikes + 1)

try {
// Requête serveur en arrière-plan
await fetch(`/api/posts/${postId}/like`, { method: "POST" })
} catch {
// Rollback en cas d'erreur
addOptimisticLike(optimisticLikes)
}
}

// L'UI change instantanément, même si le serveur n'a pas encore répondu
return <button onClick={handleLike}>❤️ {optimisticLikes}</button>
}

Cette approche améliore drastiquement la perception de la réactivité de l'application.

Le combo CVA + Framer Motion

Pour créer des interactions fluides et cohérentes, on peut combiner CVA (Class Variance Authority) pour définir les états d'interaction et Framer Motion pour les animations subtiles.

Comme le souligne Emil Kowalski :

[...], Another purposeful animation is this subtle scale down effect when pressing a button. It's a small thing, but it helps the interface feel more alive and responsive.

Voici comment combiner les deux :

import { cva, type VariantProps } from "class-variance-authority"
import { motion } from "framer-motion"

// CVA pour définir les variants avec plusieurs dimensions
const button = cva(
[
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
],
{
variants: {
intent: {
ghost: "bg-transparent hover:bg-neutral-100",
primary: "bg-blue-500 text-white hover:bg-blue-600",
secondary: "bg-neutral-200 hover:bg-neutral-300",
},
size: {
sm: "h-8 px-3 text-sm",
md: "h-10 px-4",
lg: "h-12 px-6 text-lg",
},
},
defaultVariants: {
intent: "primary",
size: "md",
},
},
)

type ButtonProps = VariantProps<typeof button> & {
children: React.ReactNode
onClick?: () => void
}

// Framer Motion pour l'animation subtile
function Button({ intent, size, children, onClick }: ButtonProps) {
return (
<motion.button
className={button({ intent, size })}
whileTap={{ scale: 0.95 }}
onClick={onClick}
>
{children}
</motion.button>
)
}

Cet exemple montre la puissance de CVA : on définit clairement les états d'interaction (intent, size) avec des classes cohérentes, tout en gardant le code maintenable.

Cette combinaison permet de créer des composants avec des états d'interaction clairement définis.

Les animations renforcent le feedback visuel.

Pour approfondir la création d'un design system avec CVA, consultez mon article sur la création d'un Design System.

Skeletons

Au lieu d'afficher un spinner générique, les skeletons imitent exactement la structure finale de la donnée. Cela réduit l'anxiété de l'attente et améliore la perception du temps de chargement.

// Utilisation qui imite la structure réelle
<section className="space-y-2">
<div className="h-4 w-3/4 animate-pulse bg-neutral-200" />
{/* Titre */}
<div className="h-4 w-1/2 animate-pulse bg-neutral-200" />
{/* Sous-titre */}
</section>

Évidemment, il convient de créer un composant Skeleton pour encapsuler cette logique.

Pour une transition encore plus fluide, on peut ajouter une animation de transition entre le skeleton et le contenu réel avec Framer Motion :

{
isLoading ? (
<div className="h-20 w-full animate-pulse bg-neutral-200" />
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.2 }}
>
{content}
</motion.div>
)
}

Cette transition subtile évite le "flash" de contenu et crée une expérience plus fluide.

Performance perçue : optimiser ce que l'utilisateur ressent

L'UI Engineering optimise ce que l'utilisateur ressent, pas juste les métriques Lighthouse.

Les métriques techniques sont importantes pour plein de raisons, mais la perception de l'utilisateur est ce qui compte vraiment.

Image Priority & LCP Optimization (Next.js)

Le LCP (Largest Contentful Paint) mesure le temps nécessaire pour que le plus grand élément visible de la page (image, vidéo, bloc de texte) soit rendu. C'est une métrique cruciale pour la performance perçue.

Ce qui dégrade le LCP :

Comment l'améliorer, exemple avec Next.js :

// Image hero avec priority pour LCP
<Image src="/hero.jpg" priority alt="Hero" width={1200} height={630} />

La prop priority indique à Next.js que cette image est critique et doit être chargée en priorité.

Le décodage d'image asynchrone de Next.js évite le "flash" de contenu qui dégrade la qualité perçue. L'image se charge de manière optimisée sans bloquer le rendu.

Layout Instability (CLS) - Next.js

Le CLS (Cumulative Layout Shift) mesure la stabilité visuelle. Il quantifie les décalages inattendus d'éléments pendant le chargement de la page.

Exemple de CLS : Une image sans dimensions définies se charge et "pousse" le contenu en dessous vers le bas. L'utilisateur voit la page "sauter", ce qui est un marqueur fort de non-professionnalisme.

Comment éviter le CLS avec Next.js :

  1. Réserver l'espace avec next/image en spécifiant width et height
  2. Utiliser aspect-ratio pour les conteneurs dynamiques
// ❌ Sans dimensions : provoque un CLS
<Image src="/hero.jpg" alt="Hero" />

// ✅ Avec aspect-ratio : évite le CLS
<div className="relative aspect-video">
<Image src="/hero.jpg" alt="Hero" fill />
</div>

Cette approche garantit que l'espace est réservé avant le chargement de l'image, évitant tout décalage visuel.

Hydration Impact (Next.js)

L'hydratation React peut créer un "flash" de contenu qui dégrade la qualité perçue. Ce phénomène survient quand le HTML statique rendu côté serveur diffère du rendu initial côté client, forçant React à "réparer" l'interface.

Le problème : Un composant qui utilise useState ou useEffect pour modifier le rendu initial crée une différence entre le HTML serveur et le HTML client. L'utilisateur voit brièvement le contenu serveur, puis un "saut" quand React prend le contrôle.

Next.js offre plusieurs solutions pour minimiser cet impact :

Server Components : éviter l'hydratation

Les Server Components s'exécutent uniquement côté serveur et ne sont jamais hydratés. Ils réduisent drastiquement la quantité de JavaScript envoyée au client :

// Server Component : pas d'hydratation, pas de JavaScript client
export default async function Page() {
const data = await fetchData()
return <Content data={data} />
}

Quand utiliser Server Components :

Quand utiliser Client Components :

Streaming SSR : affichage progressif

Le Streaming SSR permet d'afficher le contenu progressivement, améliorant la perception du temps de chargement :

import { Suspense } from "react"

export default function Page() {
return (
<>
<Header />
<Suspense fallback={<Skeleton />}>
<SlowContent />
</Suspense>
</>
)
}

Le contenu critique (Header) s'affiche immédiatement, tandis que SlowContent se charge en arrière-plan.

Impact sur le bundle JavaScript

En privilégiant les Server Components, on réduit significativement la taille du bundle JavaScript :

Cette réduction améliore non seulement la performance perçue, mais aussi les métriques réelles (Time to Interactive, First Input Delay).

En utilisant correctement ces techniques, on réduit l'impact de l'hydratation sur la qualité perçue et la quantité de JavaScript à charger côté client.

Accessibilité "invisible" (A11y Engineering)

Une interface qui réagit parfaitement au clavier est une interface "haut de gamme". L'accessibilité ne doit pas être une contrainte, mais une opportunité d'améliorer l'expérience pour tous.

Focus Management

Un focus trap empêche le focus de sortir de la modale : quand l'utilisateur appuie sur Tab, le focus reste à l'intérieur de la modale au lieu de revenir à la page en arrière-plan.

Exemple concret : Sans focus trap, un utilisateur qui navigue au clavier peut "sortir" de la modale et interagir avec des éléments inaccessibles visuellement mais toujours présents dans le DOM.

Avec des librairies comme Radix UI ou Headless UI, cette gestion est automatique :

// Radix UI Dialog gère automatiquement le focus trap
<Dialog.Root>
<Dialog.Trigger>Ouvrir</Dialog.Trigger>
<Dialog.Content>
<Dialog.Title>Titre</Dialog.Title>
<Dialog.Description>Description</Dialog.Description>
{/* Le focus reste piégé dans cette modale */}
<button>Action</button>
</Dialog.Content>
</Dialog.Root>

Cette attention aux détails d'accessibilité améliore l'expérience pour tous les utilisateurs, pas seulement ceux qui utilisent le clavier.

Reduced Motion

Respecter la préférence système prefers-reduced-motion est crucial. Certaines animations peuvent rendre les utilisateurs malades. Framer Motion respecte automatiquement cette préférence :

// Framer Motion respecte automatiquement prefers-reduced-motion
<motion.div animate={{ opacity: 1 }} transition={{ duration: 0.3 }} />

Si l'utilisateur a activé prefers-reduced-motion, Framer Motion réduit ou désactive automatiquement les animations.

Accessibilité visuelle

L'accessibilité passe aussi par les détails visuels : contraste suffisant, focus states visibles, navigation au clavier fluide. Pour approfondir ce sujet, je vous invite à consulter mon guide complet sur l'accessibilité Next.js.

Typographie : éviter les "flashs" de texte

La typographie est souvent négligée par les développeurs, mais elle est cruciale pour les UI Engineers. Un texte qui "clignote" ou qui change d'apparence au chargement dégrade immédiatement la qualité perçue.

FOIT et FOUT : les ennemis de la qualité perçue

Quand une page charge, deux problèmes peuvent survenir avec les polices web :

Ces flashs créent une sensation de non-professionnalisme. L'UI Engineer doit les éviter.

Optimiser le chargement avec next/font

Avec Next.js, next/font optimise automatiquement le chargement des polices. Il peut charger des polices depuis Google Fonts ou depuis des fichiers locaux.

Exemple avec Google Fonts :

import { Inter } from "next/font/google"

const inter = Inter({
subsets: ["latin"],
display: "swap",
preload: true,
variable: "--font-inter",
})

export default function Layout({ children }) {
return (
<html className={inter.variable}>
<body>{children}</body>
</html>
)
}

Les options importantes :

Exemple avec une police locale :

import localFont from "next/font/local"

const customFont = localFont({
src: "./fonts/custom-font.woff2",
display: "swap",
variable: "--font-custom",
})

next/font optimise automatiquement les polices : il génère les formats nécessaires (woff2, woff), ajoute les preloads, et évite les requêtes externes inutiles.

Antialiasing : lisser le rendu du texte

L'antialiasing est une technique qui lisse les bords des caractères pour éviter l'effet de "pixelisation" sur écran. Sur certains navigateurs (notamment macOS), le texte peut paraître trop fin ou trop épais selon le background.

La propriété CSS font-smoothing permet de contrôler ce rendu :

.text {
-webkit-font-smoothing: antialiased;
/* Sur macOS, rend le texte plus lisible sur fond clair */
}

Cette propriété est souvent ajoutée globalement dans le CSS global de Next.js pour garantir un rendu cohérent. C'est un détail qui améliore la qualité perçue du texte, surtout sur les écrans haute résolution.

Animations

Au-delà du simple hover, les animations peuvent raconter une histoire et guider l'utilisateur. L'UI Engineer sait quand et comment animer pour améliorer l'expérience.

Mais attention : toutes les animations ne sont pas bénéfiques. Une animation mal choisie peut dégrader la performance, distraire l'utilisateur, ou même provoquer des nausées chez certaines personnes.

Quand ne pas animer

Évitez les animations dans ces cas :

Règle d'or : Si l'animation n'apporte pas de valeur claire à l'expérience utilisateur, ne l'ajoutez pas.

Alternatives à Framer Motion

Framer Motion est puissant, mais parfois une simple transition CSS suffit :

// ✅ Simple transition CSS pour un hover
<button className="transition-transform duration-200 hover:scale-105">
Cliquer
</button>

// ✅ Animation CSS pour un fade-in
<div className="animate-fade-in">
{content}
</div>

Quand utiliser CSS vs Framer Motion :

Pour des animations simples, privilégiez CSS. Pour des interactions complexes, Framer Motion est justifié.

Impact sur les performances

Les animations peuvent impacter les performances, surtout sur mobile :

Bonnes pratiques :

// ✅ Bon : utilise transform (GPU-accelerated)
<motion.div animate={{ x: 100 }} />

// ❌ Éviter : utilise left (reflow)
<motion.div animate={{ left: 100 }} />

Framer Motion optimise automatiquement en utilisant transform et opacity quand c'est possible, mais il est important de comprendre ces principes.

Shared Layout Transitions (Framer Motion)

Les shared layout transitions créent une continuité mentale pour l'utilisateur. Avec Framer Motion et la propriété layoutId, on peut créer des transitions fluides entre différentes vues :

// Sur la liste de produits
<motion.img
layoutId={`product-${product.id}`}
src={product.image}
alt={product.name}
/>

// Sur la page détail du même produit
<motion.img
layoutId={`product-${product.id}`}
src={product.image}
alt={product.name}
/>

Quand l'utilisateur clique sur un produit dans la liste, Framer Motion détecte que les deux images partagent le même layoutId.

Il crée automatiquement une transition fluide entre les deux positions. L'image semble "se déplacer" de la liste vers la page de détail, créant une expérience mémorable.

Cf. la documentation de Framer motion.

Stagger Effects

Les stagger effects (effets en cascade) font apparaître les éléments d'une liste avec un léger décalage, guidant l'œil de l'utilisateur :

// Stagger avec Framer Motion
<motion.ul>
{items.map((item, i) => (
<motion.li
key={item.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
{item.name}
</motion.li>
))}
</motion.ul>

Cette approche crée un rythme visuel qui rend l'interface plus agréable à parcourir. Cf. Framer motion.

State-Driven Animations

Les animations peuvent également refléter l'état de l'application. Au lieu d'afficher brutalement un changement d'état, une transition animée guide l'utilisateur et améliore la compréhension du système.

Exemple concret : Un système de filtres où les résultats changent selon les critères sélectionnés. Sans animation, les éléments disparaissent et réapparaissent brutalement. Avec une animation, l'utilisateur comprend visuellement quels éléments correspondent aux filtres :

const [filters, setFilters] = useState<string[]>([])
const filteredItems = items.filter(item =>
filters.length === 0 || filters.some(filter => item.tags.includes(filter))
)

<motion.ul layout>
{filteredItems.map((item) => (
<motion.li
key={item.id}
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
{item.name}
</motion.li>
))}
</motion.ul>

La propriété layout de Framer Motion détecte automatiquement les changements de position et crée une transition fluide. Les éléments qui disparaissent s'estompent, ceux qui apparaissent s'animent, et ceux qui changent de position se déplacent en douceur.

L'animation transforme un changement d'état technique en feedback visuel compréhensible qui guide l'attention de l'utilisateur.

Design tokens et cohérence visuelle

Les design tokens sont le pont entre le design et le code. Ils garantissent la cohérence visuelle à travers toute l'application.

Design tokens : pont entre design et code

Les design tokens définissent les valeurs de base du design system : couleurs, espacements, typographie, ombres, etc.

Ils permettent de maintenir la cohérence même quand le design évolue.

Il existe des outils pour exporter automatiquement les design tokens depuis Figma, comme Figma Tokens ou Style Dictionary.

Ces outils permettent de synchroniser le design et le code, réduisant les erreurs et les incohérences.

Dans Next.js avec Tailwind, on peut définir les tokens dans tailwind.config.js :

// Design tokens dans tailwind.config.js
module.exports = {
theme: {
spacing: {
xs: "4px",
sm: "8px",
md: "16px",
lg: "24px",
},
colors: {
primary: {
50: "#...",
500: "#...",
},
},
},
}

Synchroniser design et code

Les design tokens sont le pont entre le design et le code. Mais si les tokens du code ne correspondent pas exactement à ceux de la maquette, les maquettes ne servent pratiquement à rien.

Le problème classique : Le designer définit primary: #3B82F6 dans Figma, mais le développeur utilise bg-blue-500 (qui vaut #3B82F6... ou pas ?). Résultat : des incohérences subtiles qui s'accumulent.

Les tokens essentiels à synchroniser :

// Design tokens dans tailwind.config.js
module.exports = {
theme: {
colors: {
primary: {
50: "#EFF6FF", // Exactement comme dans Figma
500: "#3B82F6", // Pas "blue-500", mais la vraie valeur
900: "#1E3A8A",
},
},
borderRadius: {
sm: "4px", // Pas "rounded-sm" arbitraire
md: "8px", // Mais les valeurs exactes du design
lg: "12px",
},
spacing: {
xs: "4px", // Cohérence avec les espacements Figma
sm: "8px",
md: "16px",
lg: "24px",
},
},
}

L'UI Engineer s'assure que :

Sans cette synchronisation, chaque développeur interprète différemment le design, et l'interface dérive progressivement de l'intention initiale.

Il existe des outils pour exporter automatiquement les tokens depuis Figma (comme Figma Tokens) vers le code, garantissant cette synchronisation.

Conclusion

L'UI Engineering optimise ce que l'utilisateur ressent, pas juste les métriques Lighthouse. C'est un rôle qui se concentre sur les détails qui font la différence : feedback immédiat, animations subtiles, performance perçue, accessibilité invisible.

Un UI Engineer doit parler les deux langues : design et code. Il doit comprendre l'intention du designer tout en étant capable de l'implémenter de manière technique et durable.

Avec Next.js, nous avons accès à des outils puissants pour l'UI Engineering : Server Components pour réduire l'hydratation, next/image pour optimiser le LCP, Streaming SSR pour améliorer la performance perçue, et bien plus encore.

Les techniques présentées dans cet article (feedback optimiste, skeleton screens, fluid typography, shared layout transitions, etc.) sont autant de moyens d'améliorer la qualité perçue d'une interface.

Mais le plus important reste de comprendre pourquoi on fait ces choix et comment ils impactent l'expérience utilisateur.

Pour aller plus loin, je recommande d'explorer les travaux d'Emil Kowalski qui illustrent parfaitement l'art de l'UI Engineering.