Patterns de cartes utilises dans Speedcube Master : cartes d'information, de statistiques, de listes, d'actions, de fonctionnalites, et leurs dispositions en grille responsive.
La carte de base affiche un titre, une description et du contenu. Elle utilise le fond bg-card et une bordure border-border.
3x3 - CFOP - Demarree il y a 15 minutes
12 solves effectuees dans cette session. Moyenne actuelle : 14.23s
Les cartes de stats affichent une valeur numerique mise en evidence avec un label descriptif. Elles utilisent la police mono pour les chiffres et sont souvent disposees en grille 2x2 ou 4 colonnes.
Meilleur temps
8.42
secondes
Ao5
12.67
secondes
Ao12
13.45
secondes
Total solves
1,247
cette semaine
Extrait de src/components/timer-stats.tsx. Ce composant affiche une grille de 4 stats animees (PB, Ao5, Ao12, Tendance) avec Framer Motion. Chaque valeur s'anime a chaque mise a jour (scale pulse) et les cartes reagissent au hover avec un zoom subtil. Le texte s'adapte selon la taille de l'ecran via les classes responsive.
"use client";
import { memo } from "react";
import { formatTime } from "@/lib/time";
import { TrendingUp, TrendingDown, Minus } from "lucide-react";
import { motion } from "framer-motion";
interface TimerStatsProps {
total: number;
pb: number | null;
average5: number | null;
average12: number | null;
}
export const TimerStats = memo(function TimerStats({
total, pb, average5, average12,
}: TimerStatsProps) {
// Calculer la tendance (comparaison Ao5 vs Ao12)
const getTrend = () => {
if (!average5 || !average12) return null;
const diff = ((average12 - average5) / average12) * 100;
if (diff > 3) return { type: "improving", value: diff };
if (diff < -3) return { type: "declining", value: Math.abs(diff) };
return { type: "stable", value: 0 };
};
const trend = getTrend();
return (
<motion.div
className="pt-2 sm:pt-4 border-t border-border/60"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<div className="grid grid-cols-4 gap-2 sm:gap-6">
{/* PB - Couleur primaire pour le mettre en valeur */}
<motion.div
className="space-y-0.5 sm:space-y-2 text-center"
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.2 }}
>
<div className="text-[10px] sm:text-sm font-medium text-muted-foreground
uppercase tracking-wide">
PB
</div>
<motion.div
className="text-base sm:text-3xl font-bold text-primary font-mono"
key={pb} // Re-animer a chaque changement de valeur
initial={{ scale: 1.1 }}
animate={{ scale: 1 }}
transition={{ duration: 0.2 }}
>
{pb ? formatTime(pb) : "—"}
</motion.div>
</motion.div>
{/* Ao5 */}
<motion.div
className="space-y-0.5 sm:space-y-2 text-center"
whileHover={{ scale: 1.02 }}
transition={{ duration: 0.2 }}
>
<div className="text-[10px] sm:text-sm font-medium text-muted-foreground
uppercase tracking-wide">
Ao5
</div>
<motion.div
className="text-base sm:text-3xl font-bold text-foreground font-mono"
key={average5}
initial={{ scale: 1.1 }}
animate={{ scale: 1 }}
>
{average5 ? formatTime(average5) : "—"}
</motion.div>
</motion.div>
{/* Ao12 - Couleur accent */}
<motion.div
className="space-y-0.5 sm:space-y-2 text-center"
whileHover={{ scale: 1.02 }}
>
<div className="text-[10px] sm:text-sm font-medium text-muted-foreground
uppercase tracking-wide">
Ao12
</div>
<motion.div
className="text-base sm:text-3xl font-bold text-accent font-mono"
key={average12}
initial={{ scale: 1.1 }}
animate={{ scale: 1 }}
>
{average12 ? formatTime(average12) : "—"}
</motion.div>
</motion.div>
{/* Tendance avec icone conditionnelle */}
<motion.div className="space-y-0.5 sm:space-y-2 text-center">
<div className="text-[10px] sm:text-sm font-medium text-muted-foreground
uppercase tracking-wide">
<span className="hidden sm:inline">Tendance</span>
<span className="sm:hidden">Trend</span>
</div>
{trend?.type === "improving" && (
<div className="flex items-center justify-center gap-1">
<TrendingUp className="h-3 w-3 sm:h-5 sm:w-5 text-green-500" />
<span className="text-sm sm:text-xl font-bold text-green-500">
-{trend.value.toFixed(1)}%
</span>
</div>
)}
{trend?.type === "declining" && (
<div className="flex items-center justify-center gap-1">
<TrendingDown className="h-3 w-3 sm:h-5 sm:w-5 text-orange-500" />
<span className="text-sm sm:text-xl font-bold text-orange-500">
+{trend.value.toFixed(1)}%
</span>
</div>
)}
</motion.div>
</div>
</motion.div>
);
});Les cartes de liste sont utilisees pour afficher des elements dans une liste verticale (solves, sessions, algorithmes). Elles ont un layout horizontal avec des informations alignees.
R U R' U' R' F R2 U' R' U' R U R' F'
F R U' R' U' R U R' F' R U R' U' R' F R F'
R' U' F' R U R' U' R' F R2 U' R' U' R U R' U R
Le composant src/components/ui/animated-card.tsx est un wrapper autour de Card qui ajoute des animations de hover (shadow, scale, translate) via les classes CSS transition. Il accepte un prop delay pour les animations en cascade et un prop hover pour desactiver l'effet au survol.
'use client';
import React from 'react';
import { Card } from '@/components/ui/card';
import { cn } from '@/lib/utils';
interface AnimatedCardProps extends React.ComponentProps<typeof Card> {
hover?: boolean; // Activer/desactiver l'animation de hover
delay?: number; // Delai en ms pour les animations en cascade
children: React.ReactNode;
}
export function AnimatedCard({
className,
hover = true,
delay = 0,
children,
...props
}: AnimatedCardProps) {
return (
<Card
className={cn(
// Transition fluide de 300ms sur toutes les proprietes
"transition-all duration-300 ease-out",
// Effet hover : shadow + scale + translate vers le haut
hover && "hover:shadow-lg hover:scale-105 hover:-translate-y-1",
className
)}
// Delai pour les animations en cascade (stagger)
style={{ animationDelay: `${delay}ms` }}
{...props}
>
{children}
</Card>
);
}
// Utilisation : grille de cartes avec stagger
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{features.map((feature, index) => (
<AnimatedCard key={feature.id} delay={index * 100}>
<CardHeader>
<CardTitle>{feature.title}</CardTitle>
<CardDescription>{feature.description}</CardDescription>
</CardHeader>
</AnimatedCard>
))}
</div>Extrait de src/components/room-card.tsx. Cette carte combine badges colores (statut de la salle), avatars des participants, informations structurees et un bouton d'action. Elle illustre comment composer une carte riche avec les composants shadcn/ui.
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Users, Crown, Lock, Play } from "lucide-react";
import { motion } from "framer-motion";
import { cn } from "@/lib/utils";
import type { Room } from "@/types/database";
interface RoomCardProps {
room: Room & {
room_participants?: Array<{
id: string;
username: string;
avatar_url: string | null;
is_owner: boolean;
}>;
};
onJoin: (code: string) => void;
isJoining?: boolean;
}
export function RoomCard({ room, onJoin, isJoining = false }: RoomCardProps) {
const participants = room.room_participants || [];
const spotsLeft = room.max_participants - participants.length;
const canJoin = spotsLeft > 0 && room.status === "waiting";
// Couleurs dynamiques selon le statut de la salle
const getStatusColor = (status: string) => {
switch (status) {
case "waiting":
return "bg-green-500/10 text-green-500 border-green-500/20";
case "starting":
return "bg-yellow-500/10 text-yellow-500 border-yellow-500/20";
case "in_progress":
return "bg-blue-500/10 text-blue-500 border-blue-500/20";
case "finished":
return "bg-gray-500/10 text-gray-500 border-gray-500/20";
default:
return "bg-gray-500/10 text-gray-500 border-gray-500/20";
}
};
return (
<motion.div whileHover={{ y: -2 }} transition={{ duration: 0.2 }}>
<Card className="overflow-hidden">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base truncate">{room.name}</CardTitle>
<Badge variant="outline" className={cn("text-xs", getStatusColor(room.status))}>
{room.status === "waiting" ? "En attente" : "En cours"}
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Badge variant="secondary">{room.puzzle_type}</Badge>
<span className="flex items-center gap-1">
<Users className="h-3.5 w-3.5" />
{participants.length}/{room.max_participants}
</span>
{room.is_private && <Lock className="h-3.5 w-3.5" />}
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Avatars des participants */}
<div className="flex -space-x-2">
{participants.slice(0, 5).map((p) => (
<Avatar key={p.id} className="h-8 w-8 border-2 border-background">
<AvatarImage src={p.avatar_url || ""} />
<AvatarFallback className="text-xs">
{p.username.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
))}
{participants.length > 5 && (
<div className="h-8 w-8 rounded-full bg-muted flex items-center
justify-center text-xs border-2 border-background">
+{participants.length - 5}
</div>
)}
</div>
<Button
onClick={() => onJoin(room.code)}
disabled={!canJoin || isJoining}
className="w-full"
size="sm"
>
<Play className="h-4 w-4 mr-2" />
{canJoin ? "Rejoindre" : "Salle pleine"}
</Button>
</CardContent>
</Card>
</motion.div>
);
}Les cartes d'action contiennent des boutons dans le footer. Elles sont utilisees pour les formulaires, confirmations ou elements interactifs.
Cette action est irreversible. Toutes les solves de cette session seront supprimees.
Les cartes de fonctionnalite mettent en avant une fonctionnalite avec une icone, un titre et une description. Elles sont souvent utilisees sur la page d'accueil ou les pages marketing.
Chronometre au millieme avec inspection WCA et stackmat.
Tous les algos OLL, PLL, ZBLL avec visualisation 3D.
Salles en temps reel pour s'affronter entre amis.
Extrait de src/app/(pages)/dashboard/page.tsx. Les cartes de statistiques du dashboard utilisent des variantes Framer Motion pour apparaitre en cascade (stagger). Le container definit le delai entre chaque enfant, et chaque carte utilise les variantes hidden et visible.
import { motion } from "framer-motion";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Timer, TrendingUp, Target, Award } from "lucide-react";
import { formatTime } from "@/lib/time";
// Variantes optimisees pour la performance
const cardVariants = {
hidden: { opacity: 0, y: 4 },
visible: { opacity: 1, y: 0 },
};
const sectionVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05, // Delai reduit pour fluidite
duration: 0.2,
},
},
};
function DashboardStats({ stats, pb }) {
return (
<motion.div
className="grid grid-cols-2 lg:grid-cols-4 gap-4"
variants={sectionVariants}
initial="hidden"
animate="visible"
>
<motion.div variants={cardVariants}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground
flex items-center gap-2">
<Timer className="h-4 w-4" />
Meilleur temps
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono text-primary">
{pb ? formatTime(pb.time_ms) : "—"}
</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={cardVariants}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground
flex items-center gap-2">
<TrendingUp className="h-4 w-4" />
Moyenne Ao5
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono">
{stats.ao5 ? formatTime(stats.ao5) : "—"}
</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={cardVariants}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground
flex items-center gap-2">
<Target className="h-4 w-4" />
Moyenne Ao12
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono">
{stats.ao12 ? formatTime(stats.ao12) : "—"}
</div>
</CardContent>
</Card>
</motion.div>
<motion.div variants={cardVariants}>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground
flex items-center gap-2">
<Award className="h-4 w-4" />
Total solves
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold font-mono">
{stats.totalSolves.toLocaleString("fr-FR")}
</div>
</CardContent>
</Card>
</motion.div>
</motion.div>
);
}Les cartes sont disposees dans des grilles responsives qui s'adaptent a la taille de l'ecran. Voici les patterns de grille les plus courants.
// 1 → 2 → 3 colonnes (defaut pour les cartes de contenu)
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{/* Cartes */}
</div>
// 2 → 4 colonnes (stats compactes)
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Cartes de stats */}
</div>
// 1 → 2 colonnes (formulaires, settings)
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Sections de formulaire */}
</div>
// Carte pleine largeur + grille (layout mixte)
<div className="space-y-6">
{/* Carte hero pleine largeur */}
<Card className="col-span-full">
<CardContent className="pt-6">
<h2>Vue d'ensemble</h2>
</CardContent>
</Card>
{/* Grille de stats en dessous */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{/* Cartes de stats */}
</div>
</div>
// Auto-fill avec min-width (nombre de colonnes flexible)
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,1fr))] gap-4">
{/* Cartes qui s'adaptent automatiquement */}
</div>