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,53 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
import { EASE_OUT_EXPO } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* Appears once the visitor is far enough down that the navbar is a long way
|
||||
* back — a game page is five full bands tall, and the only way up was the
|
||||
* scrollbar.
|
||||
*
|
||||
* Threshold is in viewport heights rather than pixels, so it does not pop up
|
||||
* on the second scroll tick of a tall desktop screen.
|
||||
*/
|
||||
export function BackToTop() {
|
||||
const reduced = useReducedMotion();
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setVisible(window.scrollY > window.innerHeight * 1.5);
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.button
|
||||
type="button"
|
||||
aria-label="Back to top"
|
||||
onClick={() =>
|
||||
window.scrollTo({ top: 0, behavior: reduced ? "auto" : "smooth" })
|
||||
}
|
||||
initial={{ opacity: 0, scale: 0.8, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8, y: 12 }}
|
||||
transition={{ duration: 0.3, ease: EASE_OUT_EXPO }}
|
||||
whileHover={{ y: -3 }}
|
||||
whileTap={{ scale: 0.92 }}
|
||||
className="group fixed bottom-6 right-6 z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-background/80 text-foreground-muted backdrop-blur-md transition-colors hover:border-accent hover:text-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent lg:bottom-10 lg:right-10"
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className="text-lg leading-none transition-transform duration-300 group-hover:-translate-y-0.5"
|
||||
>
|
||||
↑
|
||||
</span>
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Variant = "primary" | "outline" | "quiet";
|
||||
|
||||
type Props = {
|
||||
href: string;
|
||||
children: ReactNode;
|
||||
variant?: Variant;
|
||||
/** Stretch to full width on mobile — avoids cramped side-by-side buttons. */
|
||||
block?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* `active:scale` is on the base so every variant answers a click, including
|
||||
* the text-only one. The lift is small on purpose: a button that jumps reads
|
||||
* as a card, and these sit in rows of two.
|
||||
*/
|
||||
const base =
|
||||
"group relative inline-flex items-center justify-center gap-2 font-display tracking-widest text-sm transition duration-200 ease-out active:scale-[0.97] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent";
|
||||
|
||||
const variants: Record<Variant, string> = {
|
||||
primary:
|
||||
"sheen overflow-hidden px-8 py-3 bg-accent text-background hover:bg-foreground hover:-translate-y-0.5 hover:shadow-[0_14px_30px_-14px_rgba(196,184,150,0.6)]",
|
||||
outline:
|
||||
"px-8 py-3 border border-border text-foreground hover:border-accent hover:text-accent hover:-translate-y-0.5 hover:bg-accent/[0.06]",
|
||||
quiet: "underline-grow text-accent hover:text-foreground",
|
||||
};
|
||||
|
||||
/**
|
||||
* Every call-to-action on the site. Automatically renders an `<a>` with the
|
||||
* right rel attributes for external URLs, and a Next `<Link>` otherwise.
|
||||
*/
|
||||
export function ButtonLink({
|
||||
href,
|
||||
children,
|
||||
variant = "primary",
|
||||
block = false,
|
||||
className = "",
|
||||
}: Props) {
|
||||
const classes = `${base} ${variants[variant]} ${
|
||||
block ? "w-full sm:w-auto" : ""
|
||||
} ${className}`;
|
||||
|
||||
const isExternal = /^https?:\/\//.test(href) || href.startsWith("mailto:");
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={href.startsWith("mailto:") ? undefined : "_blank"}
|
||||
rel="noopener noreferrer"
|
||||
className={classes}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={href} className={classes}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
|
||||
/**
|
||||
* The tick that confirms a message went out. Drawn rather than faded in: a
|
||||
* stroke that completes is the one moment on the site worth animating for its
|
||||
* own sake, and it gives the success panel something to arrive on.
|
||||
*/
|
||||
export function CheckMark({ className = "" }: { className?: string }) {
|
||||
const reduced = useReducedMotion();
|
||||
const draw = (delay: number) =>
|
||||
reduced
|
||||
? {}
|
||||
: {
|
||||
initial: { pathLength: 0, opacity: 0 },
|
||||
animate: { pathLength: 1, opacity: 1 },
|
||||
transition: { duration: 0.5, delay, ease: "easeOut" as const },
|
||||
};
|
||||
|
||||
return (
|
||||
<svg viewBox="0 0 52 52" fill="none" aria-hidden className={className}>
|
||||
<motion.circle
|
||||
cx="26"
|
||||
cy="26"
|
||||
r="24"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent/35"
|
||||
{...draw(0)}
|
||||
/>
|
||||
<motion.path
|
||||
d="M15.5 26.5 L23 34 L36.5 19"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-accent"
|
||||
{...draw(0.35)}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { animate, useInView, useReducedMotion } from "framer-motion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Props = {
|
||||
/** The number to land on. */
|
||||
value: number;
|
||||
/** Where the count starts. A year should roll a few digits, not run up
|
||||
* from zero, so the caller sets its own starting point. */
|
||||
from?: number;
|
||||
/** Seconds. */
|
||||
duration?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Counts up to a number once, the first time it scrolls into view.
|
||||
*
|
||||
* The animated digits are hidden from assistive tech and the real value is
|
||||
* exposed alongside them, so a screen reader announces "3" rather than every
|
||||
* intermediate frame. Honours `prefers-reduced-motion`: the final value is
|
||||
* rendered immediately.
|
||||
*/
|
||||
export function CountUp({
|
||||
value,
|
||||
from = 0,
|
||||
duration = 1.4,
|
||||
className = "",
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const inView = useInView(ref, { once: true, margin: "-80px" });
|
||||
const reduced = useReducedMotion();
|
||||
const [count, setCount] = useState(from);
|
||||
|
||||
useEffect(() => {
|
||||
if (reduced || !inView) return;
|
||||
|
||||
const controls = animate(from, value, {
|
||||
duration,
|
||||
ease: [0.22, 1, 0.36, 1],
|
||||
onUpdate: (latest) => setCount(Math.round(latest)),
|
||||
});
|
||||
|
||||
return () => controls.stop();
|
||||
}, [inView, reduced, from, value, duration]);
|
||||
|
||||
// Derived rather than stored: with reduced motion there is no animation to
|
||||
// run, so the final value is simply what renders.
|
||||
const display = reduced ? value : count;
|
||||
|
||||
return (
|
||||
<span ref={ref} className={`tabular-nums ${className}`}>
|
||||
<span aria-hidden>{display}</span>
|
||||
<span className="sr-only">{value}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Image from "next/image";
|
||||
import { BRAND } from "@/lib/site";
|
||||
|
||||
type Props = {
|
||||
/** `mark` = mountain only, `wordmark` = mountain + studio name. */
|
||||
variant?: "mark" | "wordmark";
|
||||
/** Rendered width in px. Height is derived from the file's real ratio. */
|
||||
width?: number;
|
||||
priority?: boolean;
|
||||
className?: string;
|
||||
/** Decorative by default — pass a label when the logo carries meaning. */
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a brand asset at the correct aspect ratio.
|
||||
* Previously the 1344×768 mark was hard-coded as 36×36, 120×80, 180×120…
|
||||
* in six different files, which squashed the mountain differently each time.
|
||||
*/
|
||||
export function Logo({
|
||||
variant = "mark",
|
||||
width = 40,
|
||||
priority = false,
|
||||
className = "",
|
||||
alt = "",
|
||||
}: Props) {
|
||||
const asset = BRAND[variant];
|
||||
|
||||
return (
|
||||
<Image
|
||||
src={asset.src}
|
||||
alt={alt}
|
||||
width={width}
|
||||
height={Math.round((width * asset.height) / asset.width)}
|
||||
priority={priority}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { SectionHeading } from "./SectionHeading";
|
||||
|
||||
type Props = {
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
lead?: ReactNode;
|
||||
width?: "narrow" | "default" | "wide";
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
const widths = {
|
||||
narrow: "max-w-3xl",
|
||||
default: "max-w-6xl",
|
||||
wide: "max-w-7xl",
|
||||
};
|
||||
|
||||
/**
|
||||
* Top block of an inner page. Carries the offset for the fixed navbar
|
||||
* (previously a `pt-32` copy-pasted into every page).
|
||||
*/
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
lead,
|
||||
width = "default",
|
||||
children,
|
||||
}: Props) {
|
||||
return (
|
||||
<section className="px-6 pb-12 pt-32 sm:pb-16 sm:pt-40 lg:px-10">
|
||||
<div className={`mx-auto w-full ${widths[width]}`}>
|
||||
<SectionHeading
|
||||
level="page"
|
||||
eyebrow={eyebrow}
|
||||
title={title}
|
||||
lead={lead}
|
||||
/>
|
||||
{children && <div className="mt-10">{children}</div>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion, useScroll, useTransform } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
/** Pixels travelled by the end of `range`. Positive = drifts down. */
|
||||
distance?: number;
|
||||
/** Pixels of page scroll over which the movement happens. */
|
||||
range?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Slow drift on a hero background as the page scrolls away from it.
|
||||
*
|
||||
* Reads the raw page scroll rather than measuring its own position: this is
|
||||
* only ever used at the top of a document, and measuring an element that is
|
||||
* itself being transformed feeds its own output back into the input.
|
||||
*/
|
||||
export function Parallax({
|
||||
children,
|
||||
distance = 90,
|
||||
range = 700,
|
||||
className,
|
||||
}: Props) {
|
||||
const reduced = useReducedMotion();
|
||||
const { scrollY } = useScroll();
|
||||
const y = useTransform(scrollY, [0, range], [0, distance], { clamp: true });
|
||||
|
||||
if (reduced) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div style={{ y }} className={className}>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import { EASE_OUT_EXPO, VIEWPORT } from "@/lib/motion";
|
||||
|
||||
/** Where the element travels from. `up` is the site default. */
|
||||
type Direction = "up" | "down" | "left" | "right" | "scale" | "fade";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
/** Stagger helper — seconds added before the animation starts. */
|
||||
delay?: number;
|
||||
direction?: Direction;
|
||||
/** Seconds. Longer for large blocks, shorter for small ones. */
|
||||
duration?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const from: Record<Direction, Record<string, number>> = {
|
||||
up: { opacity: 0, y: 24 },
|
||||
down: { opacity: 0, y: -24 },
|
||||
left: { opacity: 0, x: -36 },
|
||||
right: { opacity: 0, x: 36 },
|
||||
scale: { opacity: 0, scale: 0.94 },
|
||||
fade: { opacity: 0 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Scroll-triggered entrance. The single animation primitive used across the
|
||||
* site so every section enters the same way — the direction only changes when
|
||||
* the layout gives it a reason to (a panel that sits on the right enters from
|
||||
* the right).
|
||||
* Honours `prefers-reduced-motion`: content is then rendered statically.
|
||||
*/
|
||||
export function Reveal({
|
||||
children,
|
||||
delay = 0,
|
||||
direction = "up",
|
||||
duration = 0.6,
|
||||
className,
|
||||
}: Props) {
|
||||
const reduced = useReducedMotion();
|
||||
|
||||
if (reduced) {
|
||||
return <div className={className}>{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={from[direction]}
|
||||
whileInView={{ opacity: 1, x: 0, y: 0, scale: 1 }}
|
||||
viewport={VIEWPORT}
|
||||
transition={{ duration, delay, ease: EASE_OUT_EXPO }}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion, useScroll, useSpring } from "framer-motion";
|
||||
|
||||
/**
|
||||
* Reading progress, rendered as the navbar's bottom hairline.
|
||||
*
|
||||
* Deliberately not a bar of its own: the header already draws a 1px border
|
||||
* once the page is scrolled, so the progress line replaces something that was
|
||||
* there rather than adding furniture to the top of every page. At the top of
|
||||
* the document it has zero width and is invisible.
|
||||
*/
|
||||
export function ScrollProgress() {
|
||||
const reduced = useReducedMotion();
|
||||
const { scrollYProgress } = useScroll();
|
||||
|
||||
// The spring smooths the jitter of a trackpad; with reduced motion the raw
|
||||
// value is used, which still tracks the scroll exactly — just without easing.
|
||||
const smoothed = useSpring(scrollYProgress, {
|
||||
stiffness: 120,
|
||||
damping: 30,
|
||||
restDelta: 0.001,
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
aria-hidden
|
||||
style={{ scaleX: reduced ? scrollYProgress : smoothed }}
|
||||
className="absolute inset-x-0 bottom-0 h-px origin-left bg-accent/70"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Spacing = "default" | "tight" | "none";
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
id?: string;
|
||||
/** Content max-width. `wide` for galleries, `narrow` for reading columns. */
|
||||
width?: "narrow" | "default" | "wide";
|
||||
/** Top hairline separating this section from the previous one. */
|
||||
divider?: boolean;
|
||||
/** `none` when the section continues the previous block without a break. */
|
||||
top?: Spacing;
|
||||
bottom?: Spacing;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const widths = {
|
||||
narrow: "max-w-3xl",
|
||||
default: "max-w-6xl",
|
||||
wide: "max-w-7xl",
|
||||
};
|
||||
|
||||
const topPadding: Record<Spacing, string> = {
|
||||
default: "pt-20 sm:pt-28",
|
||||
tight: "pt-10 sm:pt-14",
|
||||
none: "pt-0",
|
||||
};
|
||||
|
||||
const bottomPadding: Record<Spacing, string> = {
|
||||
default: "pb-20 sm:pb-28",
|
||||
tight: "pb-10 sm:pb-14",
|
||||
none: "pb-0",
|
||||
};
|
||||
|
||||
/**
|
||||
* Consistent vertical rhythm and horizontal gutters for every page section.
|
||||
* Replaces the ad-hoc `py-24 px-6 lg:px-10 max-w-6xl mx-auto` that used to be
|
||||
* repeated in every page.
|
||||
*/
|
||||
export function Section({
|
||||
children,
|
||||
id,
|
||||
width = "default",
|
||||
divider = false,
|
||||
top = "default",
|
||||
bottom = "default",
|
||||
className = "",
|
||||
}: Props) {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={`px-6 lg:px-10 ${topPadding[top]} ${bottomPadding[bottom]} ${
|
||||
divider ? "border-t border-border" : ""
|
||||
} ${className}`}
|
||||
>
|
||||
<div className={`mx-auto w-full ${widths[width]}`}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type Props = {
|
||||
/** Small uppercase kicker above the title. */
|
||||
eyebrow?: string;
|
||||
title: ReactNode;
|
||||
/** Supporting paragraph under the title. */
|
||||
lead?: ReactNode;
|
||||
/** Optional link/button aligned to the right on wide screens. */
|
||||
action?: ReactNode;
|
||||
align?: "left" | "center";
|
||||
/** `page` for h1-sized headings, `section` for h2. */
|
||||
level?: "page" | "section";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The heading pattern used at the top of every section: eyebrow, title, lead.
|
||||
* Sizes are deliberately smaller on mobile — the previous 6xl/7xl display
|
||||
* type overflowed narrow screens.
|
||||
*/
|
||||
export function SectionHeading({
|
||||
eyebrow,
|
||||
title,
|
||||
lead,
|
||||
action,
|
||||
align = "left",
|
||||
level = "section",
|
||||
className = "",
|
||||
}: Props) {
|
||||
const Tag = level === "page" ? "h1" : "h2";
|
||||
const size =
|
||||
level === "page"
|
||||
? "text-4xl sm:text-6xl lg:text-7xl"
|
||||
: "text-3xl sm:text-4xl lg:text-5xl";
|
||||
const centered = align === "center";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-6 ${
|
||||
action ? "lg:flex-row lg:items-end lg:justify-between" : ""
|
||||
} ${centered ? "items-center text-center" : ""} ${className}`}
|
||||
>
|
||||
<div className={centered ? "max-w-2xl" : ""}>
|
||||
{eyebrow && (
|
||||
<p className="font-display text-xs sm:text-sm tracking-[0.3em] text-accent mb-4">
|
||||
{eyebrow}
|
||||
</p>
|
||||
)}
|
||||
<Tag
|
||||
className={`font-display ${size} tracking-wider leading-[1.05] text-balance`}
|
||||
>
|
||||
{title}
|
||||
</Tag>
|
||||
{lead && (
|
||||
<p
|
||||
className={`mt-6 text-base sm:text-lg text-foreground-muted leading-relaxed max-w-2xl ${
|
||||
centered ? "mx-auto" : ""
|
||||
}`}
|
||||
>
|
||||
{lead}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="shrink-0">{action}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/** Pending indicator for form submit buttons. Decorative — the button label
|
||||
* already says "SENDING…", so a screen reader gets the state from the text. */
|
||||
export function Spinner({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
className={`h-3.5 w-3.5 animate-spin ${className}`}
|
||||
>
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
opacity="0.25"
|
||||
/>
|
||||
<path
|
||||
d="M21 12a9 9 0 0 0-9-9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
/**
|
||||
* A faint glow that follows the pointer across a card.
|
||||
*
|
||||
* Drop it as the last child of any positioned element carrying Tailwind's
|
||||
* `group` class — it finds its own parent, so the card itself can stay a
|
||||
* server component and needs no props threaded through it:
|
||||
*
|
||||
* <Link className="group relative …">
|
||||
* …
|
||||
* <Spotlight />
|
||||
* </Link>
|
||||
*
|
||||
* The paint lives in `.spotlight` (globals.css); this only writes the pointer
|
||||
* position. Nothing runs until the pointer is actually over the card, and the
|
||||
* effect is disabled on touch screens, where `:hover` would keep it lit.
|
||||
*/
|
||||
export function Spotlight() {
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const card = ref.current?.parentElement;
|
||||
if (!card || !window.matchMedia("(hover: hover)").matches) return;
|
||||
|
||||
const onMove = (event: PointerEvent) => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
card.style.setProperty("--spot-x", `${event.clientX - rect.left}px`);
|
||||
card.style.setProperty("--spot-y", `${event.clientY - rect.top}px`);
|
||||
};
|
||||
|
||||
card.addEventListener("pointermove", onMove);
|
||||
return () => card.removeEventListener("pointermove", onMove);
|
||||
}, []);
|
||||
|
||||
return <span ref={ref} aria-hidden className="spotlight" />;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { motion, useReducedMotion, type Variants } from "framer-motion";
|
||||
import type { ReactNode } from "react";
|
||||
import { EASE_OUT_EXPO, VIEWPORT } from "@/lib/motion";
|
||||
|
||||
const containers = {
|
||||
div: motion.div,
|
||||
ul: motion.ul,
|
||||
ol: motion.ol,
|
||||
} as const;
|
||||
|
||||
const items = {
|
||||
div: motion.div,
|
||||
li: motion.li,
|
||||
span: motion.span,
|
||||
} as const;
|
||||
|
||||
type ContainerTag = keyof typeof containers;
|
||||
type ItemTag = keyof typeof items;
|
||||
|
||||
const item: Variants = {
|
||||
hidden: { opacity: 0, y: 18 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
transition: { duration: 0.5, ease: EASE_OUT_EXPO },
|
||||
},
|
||||
};
|
||||
|
||||
type StaggerProps = {
|
||||
children: ReactNode;
|
||||
/** Seconds between two children. */
|
||||
step?: number;
|
||||
/** Seconds before the first child moves. */
|
||||
delay?: number;
|
||||
as?: ContainerTag;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A list or grid whose children enter one after the other instead of all at
|
||||
* once — the difference between "a block appeared" and "these items were
|
||||
* dealt out".
|
||||
*
|
||||
* Use it in place of `Reveal` wherever the content is a collection: cards,
|
||||
* figures, thumbnails. A single block stays on `Reveal`.
|
||||
* Pair with `StaggerItem` — a plain child is not animated.
|
||||
*/
|
||||
export function Stagger({
|
||||
children,
|
||||
step = 0.08,
|
||||
delay = 0,
|
||||
as = "div",
|
||||
className,
|
||||
}: StaggerProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const Tag = containers[as];
|
||||
|
||||
if (reduced) {
|
||||
const Plain = as;
|
||||
return <Plain className={className}>{children}</Plain>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={VIEWPORT}
|
||||
variants={{
|
||||
hidden: {},
|
||||
visible: { transition: { staggerChildren: step, delayChildren: delay } },
|
||||
}}
|
||||
className={className}
|
||||
>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
type StaggerItemProps = {
|
||||
children: ReactNode;
|
||||
as?: ItemTag;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function StaggerItem({
|
||||
children,
|
||||
as = "div",
|
||||
className,
|
||||
}: StaggerItemProps) {
|
||||
const reduced = useReducedMotion();
|
||||
const Tag = items[as];
|
||||
|
||||
if (reduced) {
|
||||
const Plain = as;
|
||||
return <Plain className={className}>{children}</Plain>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tag variants={item} className={className}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { STATUS_LABEL, type GameStatus } from "@/lib/games";
|
||||
|
||||
const tones: Record<GameStatus, string> = {
|
||||
"in-development": "border-accent/50 text-accent",
|
||||
"coming-soon": "border-accent/50 text-accent",
|
||||
released: "border-foreground/40 text-foreground",
|
||||
};
|
||||
|
||||
/** A game still being worked on gets a live dot; a shipped one does not. */
|
||||
const live: Record<GameStatus, boolean> = {
|
||||
"in-development": true,
|
||||
"coming-soon": true,
|
||||
released: false,
|
||||
};
|
||||
|
||||
/** Production status pill. Always on a solid surface, never over artwork. */
|
||||
export function StatusBadge({ status }: { status: GameStatus }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-2 border px-3 py-1 font-display text-[0.7rem] tracking-[0.25em] ${tones[status]}`}
|
||||
>
|
||||
{live[status] && (
|
||||
<span aria-hidden className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
|
||||
</span>
|
||||
)}
|
||||
{STATUS_LABEL[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user