Patterns d'animation utilises dans Speedcube Master : Framer Motion, animations CSS, transitions de page, micro-interactions et performance.
Framer Motion est la librairie principale pour les animations complexes. Elle permet des animations declaratives avec motion.div, des transitions de presence avec AnimatePresence, et des animations de layout.
"use client";
import { motion, AnimatePresence } from "framer-motion";
// Animation d'entree basique
function FadeInCard({ children }: { children: React.ReactNode }) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, ease: "easeOut" }}
>
{children}
</motion.div>
);
}
// Animation de sortie avec AnimatePresence
function SolveList({ solves }: { solves: Solve[] }) {
return (
<AnimatePresence mode="popLayout">
{solves.map((solve) => (
<motion.div
key={solve.id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95, transition: { duration: 0.15 } }}
transition={{ duration: 0.25, ease: "easeOut" }}
layout
>
<SolveCard solve={solve} />
</motion.div>
))}
</AnimatePresence>
);
}
// Animation de layout (reordonnancement fluide)
function ReorderableList({ items }: { items: Item[] }) {
return (
<div className="space-y-2">
{items.map((item) => (
<motion.div
key={item.id}
layout
transition={{
layout: { duration: 0.3, ease: "easeInOut" },
}}
>
<Card>{item.content}</Card>
</motion.div>
))}
</div>
);
}Extrait de src/components/timer-display.tsx. Le composant Timer utilise des animations Framer Motion dynamiques : la couleur de la bordure et du texte changent instantanement (50ms) selon l'etat du timer (pret, en cours, arrete), tandis que l'animation de scale utilise un easing plus lent pour le feedback visuel.
"use client";
import { memo, useState, useEffect } from "react";
import { motion } from "framer-motion";
import { formatTime } from "@/lib/time";
export const TimerDisplay = memo(function TimerDisplay({
time, isRunning, isSpacePressed, isSpaceHeldLong,
timerStopped, onTimerClick,
}: TimerDisplayProps) {
const [showFlash, setShowFlash] = useState(false);
const [showStopAnimation, setShowStopAnimation] = useState(false);
// Flash au demarrage du timer
useEffect(() => {
if (isRunning) {
setShowFlash(true);
const timer = setTimeout(() => setShowFlash(false), 300);
return () => clearTimeout(timer);
}
}, [isRunning]);
// Vibration a l'arret
useEffect(() => {
if (timerStopped) {
setShowStopAnimation(true);
const timer = setTimeout(() => setShowStopAnimation(false), 400);
return () => clearTimeout(timer);
}
}, [timerStopped]);
// Couleurs dynamiques selon l'etat
const getColors = () => {
// Espace maintenu : rouge (court) โ vert (long)
if (isSpacePressed && !isRunning) {
return isSpaceHeldLong
? { border: "rgba(34, 197, 94, 0.6)", text: "rgb(34, 197, 94)" }
: { border: "rgba(239, 68, 68, 0.6)", text: "rgb(239, 68, 68)" };
}
// Timer en cours : bleu
if (isRunning) {
return { border: "rgba(59, 130, 246, 0.6)", text: "rgb(96, 165, 250)" };
}
// Timer arrete : bleu primaire
if (timerStopped) {
return { border: "rgba(37, 99, 235, 0.6)", text: "hsl(var(--foreground))" };
}
// Defaut
return { border: "hsl(var(--border))", text: "hsl(var(--foreground))" };
};
const colors = getColors();
return (
<motion.div
className="border-2 border-dashed rounded-lg p-2 sm:p-4 text-center"
animate={{
// Animation de vibration au stop : sequence de valeurs
scale: showStopAnimation ? [1, 0.98, 1.01, 1] : 1,
borderColor: colors.border,
backgroundColor: colors.bg,
}}
transition={{
// Duree differente par propriete
scale: {
duration: showStopAnimation ? 0.4 : 0,
ease: "easeOut",
},
// Transitions instantanees pour les couleurs (pas de lag percu)
borderColor: { duration: 0.05, ease: "linear" },
backgroundColor: { duration: 0.05, ease: "linear" },
}}
onClick={onTimerClick}
role="button"
tabIndex={0}
>
<motion.div
className="text-5xl xs:text-6xl sm:text-7xl lg:text-8xl xl:text-9xl
font-mono font-bold"
animate={{
scale: showFlash ? [1, 1.02, 1] : 1,
color: colors.text,
}}
transition={{
scale: { duration: 0.3 },
color: { duration: 0.05, ease: "linear" },
}}
style={{
// textShadow dynamique selon l'etat (glow colore)
textShadow: isRunning
? "0 0 30px rgba(59, 130, 246, 0.6), 0 0 60px rgba(59, 130, 246, 0.4)"
: "0 0 30px rgba(37, 99, 235, 0.5), 0 0 60px rgba(37, 99, 235, 0.3)",
}}
>
{formatTime(time)}
</motion.div>
</motion.div>
);
});Extrait de src/components/bottom-nav.tsx. La navigation mobile utilise des animations spring pour les icones actives et un layoutId pour animer le deplacement de l'indicateur actif entre les onglets.
"use client";
import { motion } from "framer-motion";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { Timer, Zap, BarChart3, GraduationCap } from "lucide-react";
import { cn } from "@/lib/utils";
const mainItems = [
{ href: "/timer", icon: Timer, label: "Timer" },
{ href: "/algos", icon: Zap, label: "Algos" },
{ href: "/dashboard", icon: BarChart3, label: "Dashboard" },
{ href: "/learning/algorithm-trainer", icon: GraduationCap, label: "Training" },
];
export function BottomNav() {
const pathname = usePathname();
const isActive = (href: string) => pathname?.startsWith(href);
return (
<nav className="fixed bottom-0 left-0 right-0 z-50 lg:hidden
bg-background/95 backdrop-blur-md border-t border-border"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
>
<div className="flex items-center justify-around h-16 px-2">
{mainItems.map((item) => {
const active = isActive(item.href);
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex flex-col items-center justify-center gap-0.5",
"px-3 py-2 rounded-lg transition-all duration-200",
"min-w-[64px] min-h-[48px]",
active
? "text-primary"
: "text-muted-foreground hover:text-foreground"
)}
>
{/* Icone animee avec spring (rebond naturel) */}
<motion.div
animate={{
scale: active ? 1.1 : 1,
y: active ? -2 : 0,
}}
transition={{
type: "spring",
stiffness: 400, // Rigidite elevee = rapide
damping: 20, // Amortissement moyen = leger rebond
}}
>
<item.icon className="h-5 w-5" />
</motion.div>
<span className="text-[10px] font-medium leading-tight">
{item.label}
</span>
{/* Indicateur anime qui suit l'onglet actif */}
{active && (
<motion.div
layoutId="bottomNavIndicator"
className="absolute -bottom-0.5 w-1 h-1 rounded-full bg-primary"
transition={{
type: "spring",
stiffness: 400,
damping: 30,
}}
/>
)}
</Link>
);
})}
</div>
</nav>
);
}Pour les animations simples et performantes, on prefere les animations CSS pures. Elles sont definies dans les styles globaux et appliquees via des classes utilitaires.
/* Animations CSS definies dans globals.css */
/* Fade in avec translation vers le haut */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.3s ease-out forwards;
}
/* Effet de flottement (decoratif) */
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-6px); }
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
/* Shimmer pour les skeletons */
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.animate-shimmer {
background: linear-gradient(
90deg,
transparent 25%,
hsl(var(--muted-foreground) / 0.1) 50%,
transparent 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s ease-in-out infinite;
}
/* Gradient anime (fond decoratif) */
@keyframes gradient-x {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.animate-gradient-x {
background-size: 200% 200%;
animation: gradient-x 3s ease infinite;
}
/* Glow pulsant (focus, notifications) */
@keyframes glow {
0%, 100% { box-shadow: 0 0 5px hsl(var(--primary) / 0.3); }
50% { box-shadow: 0 0 20px hsl(var(--primary) / 0.6); }
}
.animate-glow {
animation: glow 2s ease-in-out infinite;
}Les transitions entre les pages durent entre 250ms et 350ms avec un easing ease-out. On utilise un wrapper Framer Motion dans le layout pour animer les changements de route.
"use client";
import { motion } from "framer-motion";
import { usePathname } from "next/navigation";
// Variantes de transition de page
const pageVariants = {
initial: {
opacity: 0,
y: 8,
},
animate: {
opacity: 1,
y: 0,
transition: {
duration: 0.3, // 300ms
ease: "easeOut",
},
},
exit: {
opacity: 0,
y: -8,
transition: {
duration: 0.25, // 250ms
ease: "easeIn",
},
},
};
// Wrapper de transition de page
export function PageTransition({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<motion.div
key={pathname}
variants={pageVariants}
initial="initial"
animate="animate"
exit="exit"
>
{children}
</motion.div>
);
}
// Utilisation dans le layout
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<AnimatePresence mode="wait">
<PageTransition>{children}</PageTransition>
</AnimatePresence>
);
}Extrait de src/app/(pages)/learning/blind/page.tsx. Le hero de la page utilise une animation d'entree subtile (fade + translate de 8px) pour donner une sensation de fluidity a la navigation.
import { motion } from "framer-motion";
import { Eye } from "lucide-react";
// Animation d'entree du hero section
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
className="mb-8"
>
<div className="flex items-center gap-3 mb-4">
<Eye className="h-8 w-8 text-primary" />
<h1 className="text-4xl font-bold text-foreground">
Apprentissage Blind
</h1>
</div>
<p className="text-muted-foreground text-lg max-w-3xl">
Apprenez le 3x3 Blindfolded avec des outils interactifs
</p>
</motion.div>Les etats hover utilisent des transitions de 120ms pour un retour instantane. On anime principalement les couleurs, opacites et transformations legeres (scale, translate).
// Transition de couleur sur hover (120ms)
<button className="bg-primary text-primary-foreground
hover:bg-primary/90
transition-colors duration-[120ms]">
Action
</button>
// Scale subtil sur hover
<Card className="transition-transform duration-[120ms] hover:scale-[1.02]">
{/* Contenu */}
</Card>
// Elevation sur hover (shadow)
<Card className="transition-shadow duration-[120ms]
hover:shadow-lg hover:shadow-primary/5">
{/* Contenu */}
</Card>
// Translation sur hover
<Link className="inline-flex items-center gap-1 group">
En savoir plus
<ArrowRight className="h-4 w-4 transition-transform duration-[120ms]
group-hover:translate-x-0.5" />
</Link>
// Combinaison avec Framer Motion
<motion.button
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
transition={{ duration: 0.12 }}
>
Bouton interactif
</motion.button>Les animations en cascade revelent les elements un par un avec un delai incremental. Elles sont utilisees pour les listes, les grilles de cartes et les menus.
// Stagger avec Framer Motion
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.08, // 80ms entre chaque enfant
delayChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 15 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
ease: "easeOut",
},
},
};
function StaggeredGrid({ items }: { items: Item[] }) {
return (
<motion.div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{items.map((item) => (
<motion.div key={item.id} variants={itemVariants}>
<Card>{item.content}</Card>
</motion.div>
))}
</motion.div>
);
}
// Stagger avec CSS (classes utilitaires)
// Utiliser animation-delay via les classes stagger-N
<div className="space-y-2">
<div className="animate-fade-in-up stagger-1">Element 1</div>
<div className="animate-fade-in-up stagger-2">Element 2</div>
<div className="animate-fade-in-up stagger-3">Element 3</div>
<div className="animate-fade-in-up stagger-4">Element 4</div>
<div className="animate-fade-in-up stagger-5">Element 5</div>
</div>
// Definition des classes stagger dans globals.css
.stagger-1 { animation-delay: 0.05s; }
.stagger-2 { animation-delay: 0.10s; }
.stagger-3 { animation-delay: 0.15s; }
.stagger-4 { animation-delay: 0.20s; }
.stagger-5 { animation-delay: 0.25s; }Extrait de src/app/(pages)/dashboard/page.tsx. Les variantes du dashboard utilisent des valeurs reduites pour la performance : stagger de 50ms (au lieu de 100ms) et translation de 4px (au lieu de 20px). Cela evite les animations trop lentes qui donnent une impression de lenteur sur les pages riches en contenu.
// Variantes OPTIMISEES pour la performance (dashboard)
// Valeurs reduites par rapport aux valeurs standard
const cardVariants = {
hidden: { opacity: 0, y: 4 }, // y: 4 au lieu de 20 (plus subtil)
visible: { opacity: 1, y: 0 },
};
const sectionVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05, // 50ms au lieu de 80ms (plus rapide)
duration: 0.2, // 200ms au lieu de 300ms
},
},
};
// Utilisation : les sections du dashboard apparaissent rapidement
// sans donner l'impression d'attendre
<motion.div
variants={sectionVariants}
initial="hidden"
animate="visible"
>
<motion.div variants={cardVariants}>
{/* Section Stats */}
</motion.div>
<motion.div variants={cardVariants}>
{/* Section Graphique */}
</motion.div>
<motion.div variants={cardVariants}>
{/* Section Historique */}
</motion.div>
</motion.div>Les animations doivent rester fluides a 60fps. Voici les regles de performance a respecter pour eviter les janks et re-paints couteux.
transform et opacity (composites, GPU-accelerees)will-change: transform sur les elements animes frequemmenttransform: translateZ(0) si necessairewidth, height, top, left (declenchent un reflow)box-shadow directement (utiliser un pseudo-element avec opacity a la place)// Bon : animation GPU-acceleree
<motion.div
animate={{ x: 100, opacity: 0.5 }}
transition={{ duration: 0.3 }}
/>
// Bon : will-change pour les elements animes en continu
<div className="will-change-transform animate-float">
{/* Element decoratif qui flotte en continu */}
</div>
// Mauvais : animation de proprietes layout
<motion.div
animate={{ width: "200px", height: "100px" }} // Reflow !
/>
// Alternative : utiliser scale au lieu de width/height
<motion.div
animate={{ scaleX: 2, scaleY: 1.5 }} // GPU, pas de reflow
/>Les utilisateurs qui ont active prefers-reduced-motion dans leurs preferences systeme doivent voir les animations desactivees ou reduites. Framer Motion le gere automatiquement, et pour le CSS on utilise la media query.
/* Desactiver les animations CSS pour prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
// Avec Tailwind (media query integree)
<div className="animate-fade-in-up motion-reduce:animate-none">
{/* Animation desactivee si prefers-reduced-motion */}
</div>
// Framer Motion respecte automatiquement prefers-reduced-motion
// Mais on peut aussi le gerer manuellement
import { useReducedMotion } from "framer-motion";
function AnimatedComponent() {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, y: shouldReduceMotion ? 0 : 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.3 }}
>
Contenu anime
</motion.div>
);
}120msHover, focus, micro-interactions (instantane)150msTooltips, menus, popovers (apparition rapide)250-350msTransitions de page, modales, panneaux (perceptible)500ms+Animations decoratives, loaders, animations en boucleease-outEasing par defaut pour les entrees (deceleration naturelle)ease-inEasing pour les sorties (acceleration vers la disparition)ease-in-outEasing pour les animations en boucle (mouvement naturel)