Files
highland_games_website/app/components/home/HomeHero.tsx
T
Mathew 9b6638eb00
Deploy / deploy (push) Successful in 1m13s
Ajoute une couche de micro-animations sur tout le site
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>
2026-08-02 16:04:41 +02:00

164 lines
5.9 KiB
TypeScript

"use client";
import {
motion,
useReducedMotion,
useScroll,
useTransform,
} from "framer-motion";
import { Logo } from "../ui/Logo";
import { ButtonLink } from "../ui/ButtonLink";
import { HERO_VIDEO, STUDIO } from "@/lib/site";
import { EASE_OUT_EXPO } from "@/lib/motion";
/**
* Full-height hero.
*
* The dark plate sits under the video, so the headline is readable from the
* first paint — before the background has loaded, and for visitors who have
* reduced motion enabled (they get no video at all).
*
* Layout note: everything is in normal flow — the scroll hint is NOT absolutely
* positioned. On a laptop (wide but only ~900px tall) an absolute hint sat on
* top of the buttons. Spacing and type are clamped against `vh` so the block
* shrinks with the viewport height instead of overflowing it.
*/
export function HomeHero() {
const reduced = useReducedMotion();
// Skip entrance animations entirely when reduced motion is requested.
const fade = (delay: number) =>
reduced
? {}
: {
initial: { opacity: 0, y: 24 },
animate: { opacity: 1, y: 0 },
transition: { duration: 0.8, delay, ease: "easeOut" as const },
};
/**
* Scroll response. The video drifts slower than the page and the copy
* dissolves before the section ends, so the hero hands over to the next band
* instead of sliding out of frame intact. Page scroll is read directly —
* the hero is always at offset 0, so there is nothing to measure.
*/
const { scrollY } = useScroll();
const videoY = useTransform(scrollY, [0, 800], [0, 150], { clamp: true });
const contentY = useTransform(scrollY, [0, 600], [0, 70], { clamp: true });
// Holds at full strength for the first 100px: a visitor who nudges the wheel
// to see whether the page moves should not find the headline already faded.
const contentOpacity = useTransform(scrollY, [100, 560], [1, 0], {
clamp: true,
});
const hintOpacity = useTransform(scrollY, [0, 180], [1, 0], { clamp: true });
return (
<section className="relative isolate flex min-h-[100svh] flex-col overflow-hidden bg-background px-6 pt-[max(6rem,10vh)] pb-[clamp(1.5rem,4vh,2.5rem)] lg:px-10">
{!reduced && (
// Taller than the section and pulled up, so the parallax never
// uncovers the bottom edge of the frame.
<motion.video
autoPlay
muted
loop
playsInline
preload="metadata"
poster={HERO_VIDEO.poster ?? undefined}
aria-hidden
tabIndex={-1}
style={{ y: videoY }}
className="absolute -top-[10%] left-0 z-0 h-[120%] w-full object-cover"
>
<source src={HERO_VIDEO.src} type="video/mp4" />
</motion.video>
)}
{/* Readability scrim: flat tint + vignette, in a single layer */}
<div
aria-hidden
className="absolute inset-0 z-10 bg-background/65"
style={{
backgroundImage:
"radial-gradient(ellipse at 50% 45%, rgba(10,10,10,0) 25%, rgba(10,10,10,0.9) 100%)",
}}
/>
<motion.div
style={reduced ? undefined : { y: contentY, opacity: contentOpacity }}
className="relative z-20 mx-auto flex w-full max-w-4xl flex-1 flex-col items-center justify-center text-center"
>
<motion.div
{...(reduced
? {}
: {
initial: { opacity: 0, scale: 0.94 },
animate: { opacity: 1, scale: 1 },
transition: { duration: 1, ease: EASE_OUT_EXPO },
})}
className="mb-[clamp(1.5rem,4vh,2.5rem)]"
>
<Logo
width={200}
priority
className="h-auto w-[clamp(110px,13vh,200px)] drop-shadow-[0_0_40px_rgba(196,184,150,0.2)]"
/>
</motion.div>
<motion.h1
{...fade(0.25)}
className="font-display text-[min(3rem,12vh)] leading-[0.95] tracking-wider text-balance sm:text-[min(4.5rem,12vh)] lg:text-[min(6rem,12vh)]"
>
HIGHLAND GAMES
<br />
<span className="text-accent">STUDIO</span>
</motion.h1>
<motion.p
{...fade(0.45)}
className="mt-[clamp(1.25rem,3vh,2rem)] max-w-xl text-lg tracking-wide text-foreground-muted text-balance sm:text-xl"
>
{STUDIO.tagline}
</motion.p>
<motion.div
{...fade(0.65)}
className="mt-[clamp(1.75rem,4vh,3rem)] flex w-full flex-col items-center gap-4 sm:w-auto sm:flex-row"
>
<ButtonLink href="/games" block>
EXPLORE OUR GAMES
</ButtonLink>
<ButtonLink href="/studio" variant="outline" block>
MEET THE STUDIO
</ButtonLink>
</motion.div>
</motion.div>
{!reduced && (
// Fades on the first flick of the wheel: an invitation to scroll that
// is still there once you have is just decoration. Two layers, because
// one element cannot hold both a load-time `animate` opacity and a
// scroll-driven one.
<motion.div
style={{ opacity: hintOpacity }}
aria-hidden
className="relative z-20 mt-[clamp(1.5rem,3vh,2.5rem)] flex shrink-0 flex-col items-center self-center"
>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 1.4, duration: 0.8 }}
className="flex flex-col items-center gap-2 text-xs tracking-widest text-foreground-muted"
>
<span className="font-display">SCROLL</span>
<motion.span
animate={{ y: [0, 8, 0] }}
transition={{ duration: 1.8, repeat: Infinity, ease: "easeInOut" }}
className="block h-[clamp(1.25rem,3vh,2rem)] w-px bg-foreground-muted"
/>
</motion.div>
</motion.div>
)}
</section>
);
}