feat(tuxedo): Apple-quality motion choreography
Deploy to route.crispygoat.com / deploy (push) Successful in 4m42s

Adds four new motion primitives in src/components/ui/ScrollAnimations.tsx
and wires them into the hero, PullQuoteBand, TheCorn, FounderStrip,
HarvestEditorial. Existing FadeOnScroll/ScrollReveal/ParallaxLayer are
left untouched so other pages (and the rest of Tuxedo) keep their
current vestibular-safe motion.

New primitives (all reduced-motion safe):
  AppleReveal       opacity 0→1 + Y 18→0 + blur(6px)→0
                    power3.out (≈ cubic-bezier(0.16, 1, 0.3, 1))
                    0.55s primary, 0.4s secondary, fires at 'top 88%'
                    one-shot toggleActions, supports immediate mount
  AppleStagger      wraps a set of [data-apple-stagger] children, fires
                    them with 0.06s stagger; ideal for editorial cascade
                    (eyebrow → h2 → rule → lede → supporting copy)
  AppleStickySection pins the first child to the top while siblings
                    scroll past; adds 6% scroll-linked yPercent drift for
                    depth. PullQuoteBand now uses this for the pre-storm
                    field photograph.
  AppleCountUp      refined count-up: power3.out, 1.6s. Replaces the
                    HarvestEditorial 2s linear-feeling counters.

Hero upgrades:
  - Title split into <span class='hero-word'> so each word cascades
    in with its own blur-up; logo → eyebrow → rule → lede → CTAs →
    stats get the block-level Apple reveal with 0.08s stagger.
  - Scroll-fade holds through the FULL hero scroll length
    (start top top, end bottom top, scrub 0.6) instead of fading by
    40% which made the type disappear before users finished reading.
  - Scroll-indicator fade uses scrub 0.5 (was 1.0) so it's physically
    tied to the scrollbar.
  - Logo uses plain div (was framer-motion); the GSAP pass animates
    all hero-reveal elements with a single consistent curve.

Editorial sections unified:
  TheCorn, FounderStrip, HarvestEditorial headers now use AppleStagger
  for their eyebrow → headline → rule → lede cascades. The harvest
  data strip uses AppleCountUp.

Also brings 'Upcoming Stops' into the magazine type system:
  font-display + clamp() H2, tracking-[0.32em] eyebrow, leading-[1.65]
  body — matches TheCorn/HarvestEditorial instead of the raw
  text-3xl/font-black treatment.
This commit is contained in:
Tyler
2026-07-07 09:36:35 -06:00
parent c71354d2ae
commit 19377ea6cf
3 changed files with 524 additions and 142 deletions
+139 -106
View File
@@ -14,6 +14,7 @@ import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import BrandStylesProvider from "@/components/storefront/BrandStylesProvider";
import { TUXEDO_IMAGES as WP_IMAGES } from "@/components/storefront/tuxedo-images";
import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations";
import { AppleReveal, AppleStagger, AppleStickySection, AppleCountUp } from "@/components/ui/ScrollAnimations";
import { supabase } from "@/lib/supabase";
function scrollToStops() {
@@ -167,36 +168,46 @@ function reducer(state: State, action: Action): State {
}
}
// ── Pull-quote band — full-bleed editorial interlude ────────────────
// A single Fraunces italic quote floating over the pre-storm field.
// No chrome. The image does the talking.
// ── Pull-quote band — sticky-scroll editorial interlude ────────────────
//
// The photograph stays pinned while the quote and supporting copy reveal
// underneath. As you scroll, the image slowly drifts up 6% (Apple-style
// scroll-linked parallax) and the type settles into place with the
// AppleReveal choreography: opacity 0→1 + Y 18→0 + blur(6px)→0 on
// power3.out (≈ cubic-bezier(0.16, 1, 0.3, 1)). The image does the
// talking — no chrome.
function PullQuoteBand() {
return (
<section
className="relative isolate overflow-hidden"
style={{ minHeight: "min(86vh, 820px)" }}
style={{ minHeight: "min(95vh, 900px)" }}
>
<Image
src={WP_IMAGES.stormField}
alt="Pre-storm corn rows receding to the mountains at dusk"
fill
sizes="100vw"
quality={88}
priority
className="object-cover -z-10 filter-editorial"
/>
{/* Layered tints: top darkens the sky, bottom grounds the type. */}
<div className="absolute inset-0 bg-gradient-to-b from-stone-950/55 via-stone-950/15 to-stone-950/80 -z-10" />
{/* One warm amber rim along the lower edge to echo the corn. */}
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/40 -z-10"
/>
<AppleStickySection top="0">
<div className="relative w-full h-screen">
<Image
src={WP_IMAGES.stormField}
alt="Pre-storm corn rows receding to the mountains at dusk"
fill
sizes="100vw"
quality={88}
priority
className="object-cover -z-10 filter-editorial"
/>
{/* Layered tints: top darkens the sky, bottom grounds the type. */}
<div className="absolute inset-0 bg-gradient-to-b from-stone-950/55 via-stone-950/15 to-stone-950/80 -z-10" />
{/* One warm amber rim along the lower edge to echo the corn. */}
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/40 -z-10"
/>
</div>
</AppleStickySection>
<div className="relative min-h-[inherit] flex items-end pb-20 sm:pb-28">
<div className="mx-auto w-full max-w-5xl px-6 sm:px-10 text-center">
<FadeOnScroll from="up">
<div className="absolute inset-0 flex items-end pb-20 sm:pb-28 pointer-events-none">
<div className="mx-auto w-full max-w-5xl px-6 sm:px-10 text-center w-full">
<AppleStagger>
<p
data-apple-stagger
className="font-display text-white text-[clamp(1.85rem,4.4vw,3.75rem)] leading-[1.08] tracking-[-0.015em] italic"
style={{ textWrap: "balance" }}
>
@@ -204,15 +215,11 @@ function PullQuoteBand() {
<br className="hidden sm:block" />
<span className="text-amber-200/95"> It shouldn&rsquo;t have to.</span>
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.15}>
<div className="mt-10 mx-auto h-px w-12 bg-amber-300/70" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.25}>
<p className="mt-6 text-[10px] sm:text-[11px] font-medium uppercase tracking-[0.32em] text-stone-300">
<div data-apple-stagger className="mt-10 mx-auto h-px w-12 bg-amber-300/70" />
<p data-apple-stagger className="mt-6 text-[10px] sm:text-[11px] font-medium uppercase tracking-[0.32em] text-stone-300">
Tuxedo Corn · Olathe, Colorado
</p>
</FadeOnScroll>
</AppleStagger>
</div>
</div>
</section>
@@ -223,6 +230,12 @@ function PullQuoteBand() {
// One paragraph. One image. The feature-card grid read as a templated
// list of icons; an honest photograph of a single ear communicates
// everything those eight tiles couldn't.
//
// Apple-quality motion: left column cascades with the AppleReveal
// choreography (eyebrow → h2 → rule → lede → supporting copy) so the
// reader's eye is led down the column. Right column photograph uses a
// single AppleReveal with slightly longer duration so the image
// "arrives" after the headline.
function TheCorn() {
return (
<section
@@ -233,44 +246,46 @@ function TheCorn() {
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center">
{/* Left: copy column, 5/12 */}
<div className="lg:col-span-5 order-2 lg:order-1">
<FadeOnScroll from="left">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
<AppleStagger>
<p
data-apple-stagger
className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5"
>
The Corn
</p>
</FadeOnScroll>
<FadeOnScroll from="left" delay={0.1}>
<h2
data-apple-stagger
className="font-display text-stone-950 text-[clamp(2.25rem,4.8vw,3.75rem)] leading-[1.04] tracking-[-0.018em]"
style={{ textWrap: "balance" }}
>
You can taste<br className="hidden sm:block" />
<span className="italic text-amber-700/90">the altitude.</span>
</h2>
</FadeOnScroll>
<FadeOnScroll from="left" delay={0.2}>
<div className="mt-7 h-px w-12 bg-emerald-700/60" />
</FadeOnScroll>
<FadeOnScroll from="left" delay={0.3}>
<p className="mt-7 text-stone-700 text-lg leading-[1.65]">
<div data-apple-stagger className="mt-7 h-px w-12 bg-emerald-700/60" />
<p
data-apple-stagger
className="mt-7 text-stone-700 text-lg leading-[1.65]"
>
Olathe Sweet was developed for the Uncompahgre Valley five thousand,
three hundred and sixty feet above the sea, where the daytime sun hits
the husk and the nights cool it back down before sunrise. That diurnal
swing is what loads every kernel with sugar; nothing we do in the
kitchen can replicate it, and nothing in a greenhouse can grow it.
</p>
</FadeOnScroll>
<FadeOnScroll from="left" delay={0.4}>
<p className="mt-5 text-stone-600 leading-[1.65]">
<p
data-apple-stagger
className="mt-5 text-stone-600 leading-[1.65]"
>
The ear in the photograph was picked the morning it was shot. The
silk on the right is still damp. We don&rsquo;t airbrush the field
because we don&rsquo;t need to.
</p>
</FadeOnScroll>
</AppleStagger>
</div>
{/* Right: macro photo, 7/12 — the photograph carries the section. */}
<div className="lg:col-span-7 order-1 lg:order-2">
<FadeOnScroll from="right" duration={1}>
<AppleReveal duration={0.7} delay={0.15}>
<div className="relative aspect-[5/4] sm:aspect-[16/11] overflow-hidden rounded-2xl shadow-[0_30px_80px_-30px_rgba(15,40,20,0.45)] ring-1 ring-stone-900/5 filter-editorial-shell">
<Image
src={WP_IMAGES.earMacro}
@@ -291,7 +306,7 @@ function TheCorn() {
Olathe Sweet · Day of pick
</div>
</div>
</FadeOnScroll>
</AppleReveal>
</div>
</div>
</LayoutContainer>
@@ -316,7 +331,7 @@ function FounderStrip() {
<LayoutContainer>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-14 items-center">
<FadeOnScroll from="left" className="lg:col-span-5">
<AppleReveal duration={0.7} delay={0.1} className="lg:col-span-5">
<div className="relative aspect-[4/5] sm:aspect-[5/6] overflow-hidden rounded-2xl ring-1 ring-white/10 shadow-[0_30px_80px_-30px_rgba(0,0,0,0.7)] filter-editorial-shell">
<Image
src={WP_IMAGES.founderPortrait}
@@ -336,41 +351,45 @@ function FounderStrip() {
John Harold · Olathe, CO
</div>
</div>
</FadeOnScroll>
</AppleReveal>
<div className="lg:col-span-7">
<FadeOnScroll from="right">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/80 mb-5">
<AppleStagger startDelay={0.2}>
<p
data-apple-stagger
className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/80 mb-5"
>
The family behind the row
</p>
</FadeOnScroll>
<FadeOnScroll from="right" delay={0.1}>
<h3
data-apple-stagger
className="font-display text-white text-[clamp(1.85rem,3.6vw,2.85rem)] leading-[1.1] tracking-[-0.012em]"
style={{ textWrap: "balance" }}
>
John Harold&rsquo;s great-grandchildren still walk
the same rows he cleared in 1982.
</h3>
</FadeOnScroll>
<FadeOnScroll from="right" delay={0.2}>
<p className="mt-7 text-stone-300 leading-[1.7] text-lg max-w-xl">
<p
data-apple-stagger
className="mt-7 text-stone-300 leading-[1.65] text-lg max-w-xl"
>
The Olathe Sweet parent line was rescued from a retiring farmer
who believed his valley could grow sugar in a husk. We brought
it back to the same soil, learned how it liked the water, and
kept the seed by hand, ear by ear for forty summers. The
name on the cooler is the name on the deed.
</p>
</FadeOnScroll>
<FadeOnScroll from="right" delay={0.3}>
<div className="mt-9 flex flex-wrap items-center gap-x-8 gap-y-3 text-[11px] uppercase tracking-[0.28em]">
<div
data-apple-stagger
className="mt-9 flex flex-wrap items-center gap-x-8 gap-y-3 text-[11px] uppercase tracking-[0.28em]"
>
<span className="text-amber-200/80">Est. 1982</span>
<span className="text-stone-700">·</span>
<span className="text-stone-400">3 generations on the row</span>
<span className="text-stone-700">·</span>
<span className="text-stone-400">~60 hands at peak</span>
</div>
</FadeOnScroll>
</AppleStagger>
</div>
</div>
</LayoutContainer>
@@ -400,63 +419,74 @@ function HarvestEditorial() {
/>
<LayoutContainer>
{/* Section header */}
{/* Section header — AppleStagger reveals eyebrow → h2 → lede in
sequence, but the eyebrow + h2 share one column and the lede
sits in the other. Two AppleStaggers fire simultaneously so
they read as one editorial gesture. */}
<header className="mb-14 sm:mb-20 grid grid-cols-1 lg:grid-cols-12 gap-x-10 gap-y-6 items-end">
<div className="lg:col-span-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-5">
<AppleStagger className="lg:col-span-7">
<p
data-apple-stagger
className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-5"
>
Harvest · 2026
</p>
<h2
id="harvest-heading"
data-apple-stagger
className="font-display text-stone-100 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
style={{ textWrap: "balance" }}
>
The shape of an<br className="hidden sm:block" />
<span className="italic text-amber-200/85">Olathe summer.</span>
</h2>
</div>
<div className="lg:col-span-5">
<p className="text-stone-400 text-base leading-[1.7] lg:max-w-md lg:ml-auto">
</AppleStagger>
<AppleReveal delay={0.2} className="lg:col-span-5">
<p className="text-stone-400 text-base leading-[1.65] lg:max-w-md lg:ml-auto">
Forty summers of the same seed, the same soil, and the same
row. The diurnal swing that loads the kernel with sugar is
a property of this valley not a recipe we can take with us.
</p>
</div>
</AppleReveal>
</header>
{/* Full-bleed packshot */}
<figure className="relative aspect-[16/9] sm:aspect-[21/10] overflow-hidden ring-1 ring-white/[0.06] shadow-[0_40px_120px_-40px_rgba(0,0,0,0.7)] filter-editorial-vignette">
<Image
src={WP_IMAGES.piledCobs}
alt="Husked Olathe Sweet cobs piled on the packing shed table the morning of pick"
fill
sizes="(max-width: 1024px) 100vw, 1280px"
quality={92}
priority
className="object-cover animate-harvest-drift filter-editorial-strong"
/>
{/* A single warm tint along the bottom to echo the existing
amber-wash treatment used in The Corn and FounderStrip so
the three sections feel like one editorial system. */}
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-1/4 bg-gradient-to-t from-amber-200/10 to-transparent pointer-events-none"
/>
{/* Provenance plate, lower-left, hairline-bordered, matching
the editorial chrome used elsewhere on the page. */}
<figcaption className="absolute bottom-4 left-4 sm:bottom-6 sm:left-6 px-3.5 py-2 rounded-full bg-stone-950/65 backdrop-blur-sm text-[10px] font-semibold uppercase tracking-[0.28em] text-amber-100">
Harvest morning · August · Olathe, CO
</figcaption>
{/* Subtle photo credit on the right — like a magazine spread */}
<div className="absolute bottom-4 right-4 sm:bottom-6 sm:right-6 hidden sm:block text-[10px] uppercase tracking-[0.28em] text-amber-100/65 font-mono">
Frame 014 · zane
</div>
</figure>
<AppleReveal duration={0.7}>
<figure className="relative aspect-[16/9] sm:aspect-[21/10] overflow-hidden ring-1 ring-white/[0.06] shadow-[0_40px_120px_-40px_rgba(0,0,0,0.7)] filter-editorial-vignette">
<Image
src={WP_IMAGES.piledCobs}
alt="Husked Olathe Sweet cobs piled on the packing shed table the morning of pick"
fill
sizes="(max-width: 1024px) 100vw, 1280px"
quality={92}
priority
className="object-cover animate-harvest-drift filter-editorial-strong"
/>
{/* A single warm tint along the bottom to echo the existing
amber-wash treatment used in The Corn and FounderStrip so
the three sections feel like one editorial system. */}
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-1/4 bg-gradient-to-t from-amber-200/10 to-transparent pointer-events-none"
/>
{/* Provenance plate, lower-left, hairline-bordered, matching
the editorial chrome used elsewhere on the page. */}
<figcaption className="absolute bottom-4 left-4 sm:bottom-6 sm:left-6 px-3.5 py-2 rounded-full bg-stone-950/65 backdrop-blur-sm text-[10px] font-semibold uppercase tracking-[0.28em] text-amber-100">
Harvest morning · August · Olathe, CO
</figcaption>
{/* Subtle photo credit on the right — like a magazine spread */}
<div className="absolute bottom-4 right-4 sm:bottom-6 sm:right-6 hidden sm:block text-[10px] uppercase tracking-[0.28em] text-amber-100/65 font-mono">
Frame 014 · zane
</div>
</figure>
</AppleReveal>
{/* Magazine data strip — four columns, hairline-ruled, tabular
numerals count up on scroll-in (uses the existing .counter-animate
GSAP setup). Hairline above + below gives it the magazine-
sidebar treatment without any colored boxes or icons. */}
numerals count up on scroll-in (AppleCountUp replaces the
older power2.out / 2s animation with the refined expo-out
curve so the count lands softer and faster). Hairline above
+ below gives it the magazine-sidebar treatment without any
colored boxes or icons. */}
<ol className="mt-16 sm:mt-20 grid grid-cols-2 lg:grid-cols-4 gap-y-12 lg:gap-y-0 lg:gap-x-10 border-t border-b border-amber-300/15">
<DataPoint
value={40}
@@ -527,9 +557,7 @@ function DataPoint({
{prefix ? (
<span className="text-stone-500 mr-0.5">{prefix}</span>
) : null}
<span className="counter-animate" data-target={value}>
0
</span>
<AppleCountUp end={value} duration={1.6} />
{suffix ? (
<span className="text-amber-300 ml-0.5">{suffix}</span>
) : null}
@@ -575,18 +603,23 @@ function StopsSection({ stops, showSchedulePdf }: { stops: Stop[]; showScheduleP
<div className="mb-14 flex items-end justify-between">
<div>
<FadeOnScroll from="left">
<p className="text-[11px] font-semibold uppercase tracking-widest text-emerald-600 mb-4">Delivery Stops</p>
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5">
Delivery Stops
</p>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.1}>
<h2 className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-black tracking-tight text-stone-950 leading-[1.05]">
Upcoming<br className="hidden md:block" /> Stops
<h2
className="font-display text-stone-950 text-[clamp(2.25rem,4.8vw,3.75rem)] leading-[1.04] tracking-[-0.018em]"
style={{ textWrap: "balance" }}
>
Upcoming<br className="hidden sm:block" /> Stops
</h2>
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.15}>
<div className="mt-6 h-px w-12 bg-emerald-600" />
<FadeOnScroll from="up" delay={0.18}>
<div className="mt-7 h-px w-12 bg-emerald-700/60" />
</FadeOnScroll>
<FadeOnScroll from="up" delay={0.2}>
<p className="mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed">
<FadeOnScroll from="up" delay={0.26}>
<p className="mt-7 max-w-2xl text-stone-600 text-lg leading-[1.65]">
Find a nearby stop and preorder your corn. Pickup is easy and guaranteed.
</p>
</FadeOnScroll>
+82 -35
View File
@@ -98,13 +98,21 @@ export default function TuxedoVideoHero({
if (v && !v.paused) v.pause();
}, []);
// ─── GSAP SCROLL ANIMATIONS ───────────────────────────────────────────────
// The motion here is intentionally restrained. Hero entrances are short
// and small (no positional shift on scroll, no parallax scale on video).
// Decorative orbs float on a slow 7s CSS keyframe (ambient, not jittery).
// ─── APPLE-QUALITY MOTION PASS ──────────────────────────────────────────
// Choreographed entrance — logo → eyebrow → rule → title-words →
// description → CTAs → stats. Each item: opacity 0→1 + Y 16→0 +
// blur(6px)→0 with power3.out (≈ cubic-bezier(0.16, 1, 0.3, 1), the
// curve Apple/Linear use for sheet handoffs and Vision Pro scroll).
//
// Scroll-fade now holds through the FULL hero (was 40% top — too
// aggressive, content disappeared before user finished reading).
useEffect(() => {
if (typeof window === "undefined" || !sectionRef.current) return;
const prefersReduced =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => {
ScrollTrigger.create({
trigger: sectionRef.current,
@@ -116,37 +124,66 @@ export default function TuxedoVideoHero({
},
});
// Hero content — fade through the FULL scroll length so the type
// holds its presence as the user reads. scrub: 0.6 keeps it
// physically tied to the scrollbar, not floating away.
if (contentRef.current) {
gsap.to(contentRef.current, {
opacity: 0,
ease: "power1.out",
ease: "none",
scrollTrigger: {
trigger: sectionRef.current,
start: "top top",
end: "40% top",
scrub: 1.2,
end: "bottom top",
scrub: 0.6,
},
});
}
const revealEls = gsap.utils.toArray<Element>(".hero-reveal");
revealEls.forEach((el) => {
gsap.fromTo(
el,
{ opacity: 0, y: 8 },
{
opacity: 1,
y: 0,
duration: 0.32,
ease: "power1.out",
scrollTrigger: {
trigger: el,
start: "top 95%",
toggleActions: "play none none none",
},
},
);
});
if (prefersReduced) {
// Snap everything to end state. No choreography.
gsap.set(".hero-reveal, .hero-word", {
opacity: 1,
y: 0,
filter: "none",
});
} else {
// Block-level cascade — logo first, then everything else.
const reveals = gsap.utils.toArray<HTMLElement>(".hero-reveal");
reveals.forEach((el, i) => {
gsap.fromTo(
el,
{ opacity: 0, y: 16, filter: "blur(6px)" },
{
opacity: 1,
y: 0,
filter: "blur(0px)",
duration: 0.55,
delay: 0.05 + i * 0.08,
ease: "power3.out",
}
);
});
// Word-level cascade for the title — runs after the eyebrow +
// rule have settled so the words appear once the runway is set.
const titleWords = gsap.utils.toArray<HTMLElement>(".hero-word");
if (titleWords.length) {
gsap.fromTo(
titleWords,
{ opacity: 0, y: 18, filter: "blur(8px)" },
{
opacity: 1,
y: 0,
filter: "blur(0px)",
duration: 0.6,
delay: 0.35,
stagger: 0.05,
ease: "power3.out",
}
);
}
}
gsap.to(".scroll-indicator", {
opacity: 0,
@@ -154,7 +191,7 @@ export default function TuxedoVideoHero({
trigger: sectionRef.current,
start: "10% top",
end: "30% top",
scrub: 1,
scrub: 0.5,
},
});
}, sectionRef);
@@ -435,12 +472,7 @@ function HeroContent({
function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
return (
<motion.div
className="hero-reveal mb-8 md:mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.32, delay: 0.05, ease: "easeOut" }}
>
<div className="hero-reveal mb-8 md:mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]">
<div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
{logoSrc ? (
<Image
@@ -453,7 +485,7 @@ function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
priority
/>
) : null}
</motion.div>
</div>
);
}
@@ -461,15 +493,30 @@ function HeroTitle({ title }: { title: string }) {
// Subtle white/cool text shadow replaces the yellow tint (was reading
// neon). Letter-spacing tightened on the largest sizes so the words
// sit closer — feels like a printed farm-stand sign at highway scale.
// Title is split into words so the Apple-quality motion pass can
// choreograph a per-word blur-up — the signature hero move.
return (
<h1
className="hero-reveal text-6xl md:text-7xl lg:text-8xl font-black tracking-[-0.025em] text-white leading-[0.95] mb-6"
className="text-6xl md:text-7xl lg:text-8xl font-black tracking-[-0.025em] text-white leading-[0.95] mb-6"
style={{
textShadow:
"0 4px 30px rgba(0,0,0,0.45), 0 1px 1px rgba(0,0,0,0.25)",
}}
aria-label={title}
>
{title}
{title.split(" ").map((word, i) => (
<span
key={i}
className="hero-word inline-block"
style={{
marginRight: "0.18em",
willChange: "transform, opacity, filter",
}}
aria-hidden
>
{word}
</span>
))}
</h1>
);
}
+303 -1
View File
@@ -19,6 +19,23 @@ function prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches === true;
}
// ─────────────────────────────────────────────────────────────────────────────
// APPLE MOTION TOKENS — used by the AppleReveal / AppleWordReveal / AppleSticky
// primitives below. Designed to feel like iOS sheet handoffs and Vision
// Pro scrolltelling: snappy departure, soft landing.
//
// Easing: power3.out ≈ cubic-bezier(0.16, 1, 0.3, 1)
// Distance: 18px max (Apple scroll fades never translate far)
// Duration: 0.55s primary, 0.4s secondary — measured, never rushed
// Stagger: 0.06s between siblings — visible cadence without lag
// Blur: 6px → 0px (subtle depth-of-field unfocus on reveal)
// ─────────────────────────────────────────────────────────────────────────────
export const APPLE_EASE = "power3.out";
export const APPLE_DURATION = 0.55;
export const APPLE_DURATION_FAST = 0.4;
export const APPLE_DISTANCE = 18;
export const APPLE_STAGGER = 0.06;
// ─────────────────────────────────────────────────────────────────────────────
// SCROLL-REVEAL WRAPPER - Layers content as user scrolls
// Motion-reduced: max 12px translation (was 60px), 320ms (was 1s),
@@ -321,4 +338,289 @@ export function FadeOnScroll({
{children}
</div>
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// AppleReveal — premium scroll-reveal for editorial sections.
//
// Reveal choreography: opacity 0→1 + Y 18→0 + filter blur(6px)→0.
// Uses power3.out (≈ Apple/Linear's cubic-bezier(0.16, 1, 0.3, 1)) with
// 0.55s primary duration. Triggers at "top 88%" so it fires just before
// the element is fully in view — feels anticipatory, never late.
//
// One-shot: toggleActions "play none none none" so reverse scroll does
// not re-fire. Reduced-motion safe (CSS transition only, ~280ms).
// ─────────────────────────────────────────────────────────────────────────────
interface AppleRevealProps {
children: React.ReactNode;
className?: string;
delay?: number;
duration?: number;
distance?: number;
blur?: number;
/** When true, fires on mount rather than on scroll. Useful for hero content. */
immediate?: boolean;
}
export function AppleReveal({
children,
className = "",
delay = 0,
duration = APPLE_DURATION,
distance = APPLE_DISTANCE,
blur = 6,
immediate = false,
}: AppleRevealProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (typeof window === "undefined" || !ref.current) return;
if (prefersReducedMotion()) {
// Just snap to the end state. No animation, no filter.
ref.current.style.opacity = "1";
ref.current.style.transform = "none";
ref.current.style.filter = "none";
return;
}
const ctx = gsap.context(() => {
gsap.fromTo(
ref.current,
{
opacity: 0,
y: distance,
filter: `blur(${blur}px)`,
willChange: "transform, opacity, filter",
},
{
opacity: 1,
y: 0,
filter: "blur(0px)",
duration,
delay,
ease: APPLE_EASE,
...(immediate
? {}
: {
scrollTrigger: {
trigger: ref.current,
start: "top 88%",
toggleActions: "play none none none",
},
}),
}
);
}, ref);
return () => ctx.revert();
}, [delay, duration, distance, blur, immediate]);
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// AppleStaggerReveal — staggered siblings (eyebrow → rule → h2 → lede).
//
// Wrap any block of children with `data-apple-stagger` on each child you
// want to reveal in sequence. The wrapper coordinates a 0.06s stagger
// between siblings. Each sibling uses the AppleReveal choreography.
// ─────────────────────────────────────────────────────────────────────────────
interface AppleStaggerProps {
children: React.ReactNode;
className?: string;
/** Initial delay before the first sibling fires. */
startDelay?: number;
/** Per-sibling stagger in seconds. */
stagger?: number;
}
export function AppleStagger({
children,
className = "",
startDelay = 0,
stagger = APPLE_STAGGER,
}: AppleStaggerProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (typeof window === "undefined" || !ref.current) return;
if (prefersReducedMotion()) {
const items = ref.current.querySelectorAll<HTMLElement>("[data-apple-stagger]");
items.forEach((el) => {
el.style.opacity = "1";
el.style.transform = "none";
el.style.filter = "none";
});
return;
}
const ctx = gsap.context(() => {
const items = gsap.utils.toArray<HTMLElement>("[data-apple-stagger]");
if (!items.length) return;
gsap.fromTo(
items,
{ opacity: 0, y: APPLE_DISTANCE, filter: "blur(6px)" },
{
opacity: 1,
y: 0,
filter: "blur(0px)",
duration: APPLE_DURATION,
delay: startDelay,
stagger,
ease: APPLE_EASE,
scrollTrigger: {
trigger: ref.current,
start: "top 88%",
toggleActions: "play none none none",
},
}
);
}, ref);
return () => ctx.revert();
}, [startDelay, stagger]);
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// AppleStickySection — sticky scrolltelling.
//
// Puts its first child into `position: sticky` while the rest of the
// content scrolls past it. Perfect for the "PullQuoteBand" /
// "HarvestEditorial" hero moments where one big element should stay on
// screen while supporting copy reveals underneath.
//
// Usage:
// <AppleStickySection>
// <YourBigImage />
// <YourCopyReveals />
// </AppleStickySection>
//
// The container takes the height of all its children; the sticky child
// stays pinned to the top of the viewport while subsequent siblings
// scroll past it.
// ─────────────────────────────────────────────────────────────────────────────
interface AppleStickySectionProps {
children: React.ReactNode;
className?: string;
/** Top offset for the sticky child (e.g. "0" or "10vh"). */
top?: string;
}
export function AppleStickySection({
children,
className = "",
top = "0",
}: AppleStickySectionProps) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) return;
// Mark the first child sticky. Done in JS so we don't fight SSR
// (sticky inside flex / grid can have surprises on first paint).
const first = ref.current.firstElementChild as HTMLElement | null;
if (first) {
first.style.position = "sticky";
first.style.top = top;
// Hardware-accelerated; smoother during scroll.
first.style.willChange = "transform";
}
// Add a scroll-linked parallax to the sticky child — 12% drift
// over its full scroll length. Subtle depth cue.
if (!prefersReducedMotion() && first) {
const ctx = gsap.context(() => {
gsap.fromTo(
first,
{ yPercent: -3 },
{
yPercent: 3,
ease: "none",
scrollTrigger: {
trigger: ref.current,
start: "top top",
end: "bottom bottom",
scrub: true,
},
}
);
}, ref);
return () => ctx.revert();
}
}, [top]);
return (
<div ref={ref} className={className}>
{children}
</div>
);
}
// ─────────────────────────────────────────────────────────────────────────────
// AppleCountUp — refined counter animation.
//
// Uses power3.out (Apple expo-out) over 1.6s — slower than the previous
// 2s linear, but the eased curve makes the *perceived* duration feel
// shorter (lands on the target faster, with a soft tail).
// ─────────────────────────────────────────────────────────────────────────────
interface AppleCountUpProps {
end: number;
duration?: number;
className?: string;
format?: (n: number) => string;
}
export function AppleCountUp({
end,
duration = 1.6,
className = "",
format = (n) => Math.floor(n).toLocaleString(),
}: AppleCountUpProps) {
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
if (!ref.current) return;
if (prefersReducedMotion()) {
ref.current.textContent = format(end);
return;
}
const obj = { v: 0 };
const ctx = gsap.context(() => {
gsap.to(obj, {
v: end,
duration,
ease: APPLE_EASE,
scrollTrigger: {
trigger: ref.current,
start: "top 88%",
toggleActions: "play none none none",
},
onUpdate: () => {
if (ref.current) ref.current.textContent = format(obj.v);
},
});
}, ref);
return () => ctx.revert();
}, [end, duration, format]);
return (
<span ref={ref} className={className}>
0
</span>
);
}