Ajoute une couche de micro-animations sur tout le site
Deploy / deploy (push) Successful in 1m13s

Deux courbes partagées dans lib/motion.ts, pour que le CSS et Framer
aient le même feeling, et quatre primitives que Tailwind ne sait pas
exprimer : reflet de bouton, halo qui suit le curseur, soulignement qui
se dessine, secousse d'erreur.

Nouveaux composants : Stagger (les collections se distribuent une par
une), Spotlight (trouve son parent tout seul, donc les cartes restent
des composants serveur), ScrollProgress, BackToTop, Parallax, Spinner,
CheckMark. Reveal accepte maintenant une direction.

Côté rendu : le trait de nav glisse d'un onglet à l'autre, les cartes
se soulèvent, le hero réagit au scroll, les piliers entrent en zig-zag,
la lightbox glisse dans le sens demandé et les formulaires répondent.

prefers-reduced-motion est respecté partout : Framer coupe via
useReducedMotion, le CSS via la media query déjà en place.

Le commit embarque aussi la réorganisation des composants par domaine
qui était en cours dans l'arbre de travail — les deux étaient trop
imbriquées pour être séparées proprement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 16:04:41 +02:00
parent 8fd9ca88cc
commit 9b6638eb00
66 changed files with 3938 additions and 1469 deletions
+237
View File
@@ -0,0 +1,237 @@
"use client";
import Image from "next/image";
import { useCallback, useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Stagger, StaggerItem } from "../ui/Stagger";
import { EASE_OUT_EXPO } from "@/lib/motion";
import type { Screenshot } from "@/lib/games";
type Props = {
screenshots: Screenshot[];
/** Marks the captures as work in progress. */
wip?: boolean;
};
/**
* Screenshot grid with a lightbox.
*
* Captions live under the thumbnail and on a solid bar inside the lightbox —
* never floating over the image, which is far too bright to carry text.
*/
/**
* Which way the next capture slides in. Stored rather than derived: after the
* index has changed there is no way to tell whether it went up or wrapped
* around from the last shot to the first.
*/
const slide = {
enter: (direction: number) => ({ opacity: 0, x: direction * 48 }),
center: { opacity: 1, x: 0 },
exit: (direction: number) => ({ opacity: 0, x: direction * -48 }),
};
export function ScreenshotGallery({ screenshots, wip = true }: Props) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [direction, setDirection] = useState(1);
const triggersRef = useRef<(HTMLButtonElement | null)[]>([]);
const dialogRef = useRef<HTMLDivElement>(null);
const close = useCallback(() => {
setOpenIndex((current) => {
if (current !== null) triggersRef.current[current]?.focus();
return null;
});
}, []);
const step = useCallback(
(delta: number) => {
setDirection(delta);
setOpenIndex((current) =>
current === null
? current
: (current + delta + screenshots.length) % screenshots.length,
);
},
[screenshots.length],
);
// Keyboard controls + scroll lock while the lightbox is open.
useEffect(() => {
if (openIndex === null) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") close();
else if (event.key === "ArrowRight") step(1);
else if (event.key === "ArrowLeft") step(-1);
};
document.addEventListener("keydown", onKeyDown);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
dialogRef.current?.focus();
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [openIndex, close, step]);
if (screenshots.length === 0) return null;
const active = openIndex === null ? null : screenshots[openIndex];
return (
<>
<Stagger as="ul" step={0.06} className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{screenshots.map((shot, index) => (
<StaggerItem as="li" key={shot.src}>
<button
type="button"
ref={(node) => {
triggersRef.current[index] = node;
}}
onClick={() => {
setDirection(1);
setOpenIndex(index);
}}
className="group block w-full text-left transition-transform duration-200 ease-out active:scale-[0.98] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
>
<span className="relative block aspect-video overflow-hidden rounded-2xl border border-border bg-surface-elevated transition-colors duration-300 group-hover:border-accent/60">
<Image
src={shot.src}
alt={shot.caption}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform duration-700 ease-out group-hover:scale-[1.04]"
/>
{wip && (
<span className="absolute left-3 top-3 bg-background/90 px-2.5 py-1 font-display text-[0.65rem] tracking-[0.2em] text-accent">
WIP
</span>
)}
{/* The label rises into place rather than appearing, so the
overlay reads as one gesture instead of a flash. */}
<span
aria-hidden
className="absolute inset-0 flex items-center justify-center bg-background/50 opacity-0 transition-opacity duration-300 group-hover:opacity-100"
>
<span className="translate-y-2 border border-accent px-4 py-2 font-display text-xs tracking-[0.25em] text-accent transition-transform duration-300 ease-out group-hover:translate-y-0">
VIEW
</span>
</span>
</span>
<span className="mt-3 block text-sm text-foreground-muted transition-colors duration-300 group-hover:text-foreground">
{shot.caption}
</span>
</button>
</StaggerItem>
))}
</Stagger>
<AnimatePresence>
{active && (
<motion.div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={active.caption}
tabIndex={-1}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={close}
className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-background/95 p-4 backdrop-blur-sm sm:p-8"
>
<motion.div
initial={{ opacity: 0, scale: 0.96, y: 8 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 4 }}
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
className="relative w-full max-w-6xl"
onClick={(event) => event.stopPropagation()}
>
<div className="relative aspect-video w-full overflow-hidden rounded-t-2xl border border-border bg-surface-elevated">
{/* Each capture slides in from the side you asked for. Without
it, stepping through a gallery of similar screenshots gives
no sense of having moved at all. */}
<AnimatePresence initial={false} custom={direction}>
<motion.div
key={active.src}
custom={direction}
variants={slide}
initial="enter"
animate="center"
exit="exit"
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
className="absolute inset-0"
>
<Image
src={active.src}
alt={active.caption}
fill
sizes="100vw"
className="object-contain"
priority
/>
</motion.div>
</AnimatePresence>
</div>
<div className="flex items-center justify-between gap-4 rounded-b-2xl border border-t-0 border-border bg-surface px-4 py-3">
<p className="text-sm text-foreground-muted">
{active.caption}
</p>
<p className="shrink-0 font-display text-xs tracking-[0.25em] text-foreground-muted">
{(openIndex ?? 0) + 1} / {screenshots.length}
</p>
</div>
</motion.div>
{screenshots.length > 1 && (
<div
className="flex items-center gap-3"
onClick={(event) => event.stopPropagation()}
>
<button
type="button"
onClick={() => step(-1)}
aria-label="Previous screenshot"
className="group border border-border px-5 py-2 font-display text-sm tracking-widest text-foreground transition duration-200 hover:border-accent hover:text-accent active:scale-95"
>
<span
aria-hidden
className="inline-block transition-transform duration-300 ease-out group-hover:-translate-x-1"
>
</span>
</button>
<button
type="button"
onClick={() => step(1)}
aria-label="Next screenshot"
className="group border border-border px-5 py-2 font-display text-sm tracking-widest text-foreground transition duration-200 hover:border-accent hover:text-accent active:scale-95"
>
<span
aria-hidden
className="inline-block transition-transform duration-300 ease-out group-hover:translate-x-1"
>
</span>
</button>
</div>
)}
<button
type="button"
onClick={close}
aria-label="Close gallery"
className="absolute right-4 top-4 border border-border bg-background/80 px-4 py-2 font-display text-xs tracking-[0.25em] text-foreground transition duration-200 hover:border-accent hover:text-accent active:scale-95 sm:right-8 sm:top-8"
>
CLOSE
</button>
</motion.div>
)}
</AnimatePresence>
</>
);
}