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
+103
View File
@@ -0,0 +1,103 @@
import Link from "next/link";
import Image from "next/image";
import { SocialLinks } from "./SocialLinks";
import {
BRAND,
CONTACT_CHANNELS,
LEGAL_LINKS,
NAV_LINKS,
STUDIO,
} from "@/lib/site";
export function Footer() {
return (
<footer className="mt-auto border-t border-border">
<div className="mx-auto grid max-w-7xl gap-12 px-6 py-16 md:grid-cols-4 lg:px-10">
<div className="md:col-span-2">
<Link
href="/"
aria-label={`${STUDIO.name} home`}
className="group inline-block"
>
<Image
src={BRAND.wordmark.src}
alt={STUDIO.name}
width={220}
height={Math.round(
(220 * BRAND.wordmark.height) / BRAND.wordmark.width,
)}
className="transition duration-300 ease-out group-hover:-translate-y-0.5 group-hover:opacity-80"
/>
</Link>
<p className="mt-6 max-w-sm text-sm leading-relaxed text-foreground-muted">
{STUDIO.tagline} An indie studio crafting survival adventures.
</p>
<a
href={`mailto:${CONTACT_CHANNELS[0].email}`}
className="underline-grow mt-4 inline-block break-all text-sm text-foreground-muted transition-colors hover:text-accent"
>
{CONTACT_CHANNELS[0].email}
</a>
</div>
<nav aria-label="Footer">
<h2 className="mb-5 font-display text-sm tracking-[0.25em] text-foreground-muted">
EXPLORE
</h2>
<ul className="space-y-3 text-sm">
{NAV_LINKS.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="group relative inline-flex items-center gap-1.5 text-foreground-muted transition-colors hover:text-foreground"
>
{link.label}
<span
aria-hidden
className="-ml-1 text-xs opacity-0 transition-all duration-300 ease-out group-hover:ml-0 group-hover:opacity-100"
>
</span>
<span
aria-hidden
className="absolute -bottom-0.5 left-0 h-px w-0 bg-accent transition-all duration-300 ease-out group-hover:w-full"
/>
</Link>
</li>
))}
</ul>
</nav>
<div>
<h2 className="mb-5 font-display text-sm tracking-[0.25em] text-foreground-muted">
FOLLOW
</h2>
<SocialLinks />
</div>
</div>
<div className="border-t border-border">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-4 px-6 py-6 text-xs text-foreground-muted sm:flex-row lg:px-10">
<p>
© {new Date().getFullYear()} {STUDIO.name}. All rights reserved.
</p>
<ul className="flex items-center gap-6">
{LEGAL_LINKS.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="underline-grow inline-block transition-colors hover:text-accent"
>
{link.label}
</Link>
</li>
))}
</ul>
<p className="hidden font-display tracking-[0.25em] lg:block">
WHERE SUMMITS BECOME WORLDS
</p>
</div>
</div>
</footer>
);
}
+203
View File
@@ -0,0 +1,203 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Logo } from "../ui/Logo";
import { ScrollProgress } from "../ui/ScrollProgress";
import { NAV_LINKS, STUDIO } from "@/lib/site";
import { EASE_OUT_EXPO } from "@/lib/motion";
export function Navbar() {
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
// The drawer stores the route it was opened on, so any navigation closes it
// by derivation — no effect syncing state to the router.
const [openedOn, setOpenedOn] = useState<string | null>(null);
const open = openedOn === pathname;
const close = () => setOpenedOn(null);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
// Lock the page and allow Escape to dismiss while the drawer is open.
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpenedOn(null);
};
document.addEventListener("keydown", onKeyDown);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = previousOverflow;
};
}, [open]);
const isActive = (href: string) =>
pathname === href || pathname.startsWith(`${href}/`);
return (
<header
className={`fixed inset-x-0 top-0 z-50 transition-colors duration-300 ${
scrolled || open
? "border-b border-border bg-background/85 backdrop-blur-md"
: "border-b border-transparent bg-transparent"
}`}
>
<nav className="mx-auto flex h-16 max-w-7xl items-center justify-between px-6 lg:h-20 lg:px-10">
<Link
href="/"
aria-label={`${STUDIO.name} home`}
className="group flex items-center gap-3"
>
<Logo
width={44}
priority
className="transition-transform duration-300 ease-out group-hover:-translate-y-0.5 group-hover:scale-105"
/>
<span className="hidden font-display text-xl tracking-wider transition-colors group-hover:text-accent sm:block">
{STUDIO.shortName}
</span>
</Link>
<ul className="hidden items-center gap-1 md:flex">
{NAV_LINKS.map((link) => {
const active = isActive(link.href);
return (
<li key={link.href}>
<Link
href={link.href}
aria-current={active ? "page" : undefined}
className={`group/nav relative px-3 py-2 text-sm tracking-wide transition-colors ${
active
? "text-accent"
: "text-foreground-muted hover:text-foreground"
}`}
>
{link.label}
{/* Hover hairline for the pages you are not on. Sits under
the active marker, so the two never stack visibly. */}
{!active && (
<span
aria-hidden
className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-foreground-muted/60 transition-transform duration-300 ease-out group-hover/nav:scale-x-100"
/>
)}
{/* One shared element for every link: Framer moves it from
the old tab to the new one instead of cross-fading two
separate underlines. */}
{active && (
<motion.span
layoutId="nav-active"
aria-hidden
transition={{ type: "spring", stiffness: 420, damping: 34 }}
className="absolute inset-x-3 -bottom-0.5 h-px bg-accent"
/>
)}
</Link>
</li>
);
})}
</ul>
<button
type="button"
aria-label={open ? "Close menu" : "Open menu"}
aria-expanded={open}
aria-controls="mobile-menu"
onClick={() => setOpenedOn(open ? null : pathname)}
className="relative flex h-10 w-10 items-center justify-center text-foreground transition-transform duration-200 hover:text-accent active:scale-90 md:hidden"
>
<span className="relative flex h-4 w-6 flex-col justify-between">
<motion.span
animate={open ? { rotate: 45, y: 7 } : { rotate: 0, y: 0 }}
transition={{ duration: 0.25 }}
className="block h-px origin-center bg-current"
/>
<motion.span
animate={{ opacity: open ? 0 : 1 }}
transition={{ duration: 0.15 }}
className="block h-px bg-current"
/>
<motion.span
animate={open ? { rotate: -45, y: -7 } : { rotate: 0, y: 0 }}
transition={{ duration: 0.25 }}
className="block h-px origin-center bg-current"
/>
</span>
</button>
</nav>
<AnimatePresence>
{open && (
<motion.div
id="mobile-menu"
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.25, ease: EASE_OUT_EXPO }}
className="border-t border-border bg-background/95 backdrop-blur-md md:hidden"
>
{/* The rows deal out one by one. The drawer is the only place on
the site where a whole navigation appears at once, and a block
of four identical lines arriving together reads as a flash. */}
<motion.ul
initial="hidden"
animate="visible"
variants={{
visible: { transition: { staggerChildren: 0.06, delayChildren: 0.05 } },
}}
className="flex flex-col px-6 py-4"
>
{NAV_LINKS.map((link) => (
<motion.li
key={link.href}
variants={{
hidden: { opacity: 0, x: -12 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.3, ease: EASE_OUT_EXPO },
},
}}
>
<Link
href={link.href}
onClick={close}
aria-current={isActive(link.href) ? "page" : undefined}
className={`group flex items-center justify-between border-b border-border py-4 font-display text-2xl tracking-wider transition-colors last:border-b-0 ${
isActive(link.href)
? "text-accent"
: "text-foreground hover:text-accent"
}`}
>
{link.label.toUpperCase()}
<span
aria-hidden
className="text-base opacity-0 transition-all duration-300 ease-out group-hover:translate-x-1 group-hover:opacity-100"
>
</span>
</Link>
</motion.li>
))}
</motion.ul>
</motion.div>
)}
</AnimatePresence>
<ScrollProgress />
</header>
);
}
+70
View File
@@ -0,0 +1,70 @@
import type { ReactNode } from "react";
import { SOCIAL_LINKS } from "@/lib/site";
const icons: Record<string, ReactNode> = {
x: (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
),
discord: (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M20.317 4.3698a19.7913 19.7913 0 0 0-4.8851-1.5152.0741.0741 0 0 0-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 0 0-.0785-.037 19.7363 19.7363 0 0 0-4.8852 1.515.0699.0699 0 0 0-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 0 0 .0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 0 0 .0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 0 0-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 0 1-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 0 1 .0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 0 1 .0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 0 1-.0066.1276 12.2986 12.2986 0 0 1-1.873.8914.0766.0766 0 0 0-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 0 0 .0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 0 0 .0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 0 0-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1569 2.4189Zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
</svg>
),
youtube: (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M23.498 6.186a3.02 3.02 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.02 3.02 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.02 3.02 0 0 0 2.122 2.136C4.495 20.455 12 20.455 12 20.455s7.505 0 9.377-.505a3.02 3.02 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
),
steam: (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M11.979 0C5.678 0 .511 4.86.022 11.037l6.432 2.658c.545-.371 1.203-.59 1.912-.59.063 0 .125.004.188.006l2.861-4.142V8.91c0-2.495 2.028-4.524 4.524-4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525-4.524 4.525h-.105l-4.076 2.911c0 .052.004.105.004.159 0 1.875-1.515 3.396-3.39 3.396-1.635 0-3.016-1.173-3.331-2.727L.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999-5.373 11.999-12S18.605 0 11.979 0zM7.54 18.21l-1.473-.61c.262.543.714.999 1.314 1.25 1.297.539 2.793-.076 3.332-1.375.263-.63.264-1.319.005-1.949s-.75-1.121-1.377-1.383c-.624-.26-1.29-.249-1.878-.03l1.523.63c.956.4 1.409 1.5 1.009 2.456-.397.957-1.497 1.41-2.454 1.012H7.54zm11.415-9.303c0-1.662-1.353-3.015-3.015-3.015-1.665 0-3.015 1.353-3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015-1.35 3.015-3.015zm-5.273-.005c0-1.252 1.013-2.266 2.265-2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251-1.017 2.265-2.266 2.265-1.253 0-2.265-1.014-2.265-2.265z" />
</svg>
),
};
/**
* Every channel is listed, whether or not the account exists yet: the studio
* wants visitors to know where it will be. Accounts without a URL render as
* plain text marked "soon" rather than as a link to `#`, which would look
* functional and go nowhere. Fill in `href` in `lib/site.ts` and the entry
* becomes a real link with no change here.
*/
export function SocialLinks() {
return (
<ul className="space-y-3 text-sm">
{SOCIAL_LINKS.map((social) => {
const icon = (
<span className="block h-4 w-4 shrink-0 transition duration-300 ease-out group-hover:scale-110 group-hover:-rotate-6">
{icons[social.key]}
</span>
);
return (
<li key={social.key}>
{social.href ? (
<a
href={social.href}
target="_blank"
rel="noopener noreferrer"
className="group inline-flex items-center gap-3 text-foreground-muted transition-all duration-300 ease-out hover:translate-x-1 hover:text-foreground [&>span:first-child]:hover:text-accent"
>
{icon}
{social.label}
</a>
) : (
<span className="inline-flex items-center gap-3 text-foreground-muted/55">
{icon}
{social.label}
<span className="text-[0.6rem] uppercase tracking-[0.2em]">
Soon
</span>
</span>
)}
</li>
);
})}
</ul>
);
}