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:
@@ -0,0 +1,163 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user