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
+96 -63
View File
@@ -14,6 +14,7 @@ import StorefrontFooter from "@/components/storefront/StorefrontFooter";
import BrandStylesProvider from "@/components/storefront/BrandStylesProvider"; import BrandStylesProvider from "@/components/storefront/BrandStylesProvider";
import { TUXEDO_IMAGES as WP_IMAGES } from "@/components/storefront/tuxedo-images"; import { TUXEDO_IMAGES as WP_IMAGES } from "@/components/storefront/tuxedo-images";
import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations"; import { ScrollReveal, ParallaxLayer, FadeOnScroll } from "@/components/ui/ScrollAnimations";
import { AppleReveal, AppleStagger, AppleStickySection, AppleCountUp } from "@/components/ui/ScrollAnimations";
import { supabase } from "@/lib/supabase"; import { supabase } from "@/lib/supabase";
function scrollToStops() { function scrollToStops() {
@@ -167,15 +168,22 @@ function reducer(state: State, action: Action): State {
} }
} }
// ── Pull-quote band — full-bleed editorial interlude ──────────────── // ── Pull-quote band — sticky-scroll editorial interlude ────────────────
// A single Fraunces italic quote floating over the pre-storm field. //
// No chrome. The image does the talking. // 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() { function PullQuoteBand() {
return ( return (
<section <section
className="relative isolate overflow-hidden" className="relative isolate overflow-hidden"
style={{ minHeight: "min(86vh, 820px)" }} style={{ minHeight: "min(95vh, 900px)" }}
> >
<AppleStickySection top="0">
<div className="relative w-full h-screen">
<Image <Image
src={WP_IMAGES.stormField} src={WP_IMAGES.stormField}
alt="Pre-storm corn rows receding to the mountains at dusk" alt="Pre-storm corn rows receding to the mountains at dusk"
@@ -192,11 +200,14 @@ function PullQuoteBand() {
aria-hidden="true" aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/40 -z-10" 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="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"> <div className="mx-auto w-full max-w-5xl px-6 sm:px-10 text-center w-full">
<FadeOnScroll from="up"> <AppleStagger>
<p <p
data-apple-stagger
className="font-display text-white text-[clamp(1.85rem,4.4vw,3.75rem)] leading-[1.08] tracking-[-0.015em] italic" className="font-display text-white text-[clamp(1.85rem,4.4vw,3.75rem)] leading-[1.08] tracking-[-0.015em] italic"
style={{ textWrap: "balance" }} style={{ textWrap: "balance" }}
> >
@@ -204,15 +215,11 @@ function PullQuoteBand() {
<br className="hidden sm:block" /> <br className="hidden sm:block" />
<span className="text-amber-200/95"> It shouldn&rsquo;t have to.</span> <span className="text-amber-200/95"> It shouldn&rsquo;t have to.</span>
</p> </p>
</FadeOnScroll> <div data-apple-stagger className="mt-10 mx-auto h-px w-12 bg-amber-300/70" />
<FadeOnScroll from="up" delay={0.15}> <p data-apple-stagger className="mt-6 text-[10px] sm:text-[11px] font-medium uppercase tracking-[0.32em] text-stone-300">
<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">
Tuxedo Corn · Olathe, Colorado Tuxedo Corn · Olathe, Colorado
</p> </p>
</FadeOnScroll> </AppleStagger>
</div> </div>
</div> </div>
</section> </section>
@@ -223,6 +230,12 @@ function PullQuoteBand() {
// One paragraph. One image. The feature-card grid read as a templated // One paragraph. One image. The feature-card grid read as a templated
// list of icons; an honest photograph of a single ear communicates // list of icons; an honest photograph of a single ear communicates
// everything those eight tiles couldn't. // 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() { function TheCorn() {
return ( return (
<section <section
@@ -233,44 +246,46 @@ function TheCorn() {
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-16 items-center">
{/* Left: copy column, 5/12 */} {/* Left: copy column, 5/12 */}
<div className="lg:col-span-5 order-2 lg:order-1"> <div className="lg:col-span-5 order-2 lg:order-1">
<FadeOnScroll from="left"> <AppleStagger>
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5"> <p
data-apple-stagger
className="text-[11px] font-semibold uppercase tracking-[0.32em] text-emerald-700 mb-5"
>
The Corn The Corn
</p> </p>
</FadeOnScroll>
<FadeOnScroll from="left" delay={0.1}>
<h2 <h2
data-apple-stagger
className="font-display text-stone-950 text-[clamp(2.25rem,4.8vw,3.75rem)] leading-[1.04] tracking-[-0.018em]" className="font-display text-stone-950 text-[clamp(2.25rem,4.8vw,3.75rem)] leading-[1.04] tracking-[-0.018em]"
style={{ textWrap: "balance" }} style={{ textWrap: "balance" }}
> >
You can taste<br className="hidden sm:block" /> You can taste<br className="hidden sm:block" />
<span className="italic text-amber-700/90">the altitude.</span> <span className="italic text-amber-700/90">the altitude.</span>
</h2> </h2>
</FadeOnScroll> <div data-apple-stagger className="mt-7 h-px w-12 bg-emerald-700/60" />
<FadeOnScroll from="left" delay={0.2}> <p
<div className="mt-7 h-px w-12 bg-emerald-700/60" /> data-apple-stagger
</FadeOnScroll> className="mt-7 text-stone-700 text-lg leading-[1.65]"
<FadeOnScroll from="left" delay={0.3}> >
<p className="mt-7 text-stone-700 text-lg leading-[1.65]">
Olathe Sweet was developed for the Uncompahgre Valley five thousand, Olathe Sweet was developed for the Uncompahgre Valley five thousand,
three hundred and sixty feet above the sea, where the daytime sun hits 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 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 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. kitchen can replicate it, and nothing in a greenhouse can grow it.
</p> </p>
</FadeOnScroll> <p
<FadeOnScroll from="left" delay={0.4}> data-apple-stagger
<p className="mt-5 text-stone-600 leading-[1.65]"> className="mt-5 text-stone-600 leading-[1.65]"
>
The ear in the photograph was picked the morning it was shot. The 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 silk on the right is still damp. We don&rsquo;t airbrush the field
because we don&rsquo;t need to. because we don&rsquo;t need to.
</p> </p>
</FadeOnScroll> </AppleStagger>
</div> </div>
{/* Right: macro photo, 7/12 — the photograph carries the section. */} {/* Right: macro photo, 7/12 — the photograph carries the section. */}
<div className="lg:col-span-7 order-1 lg:order-2"> <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"> <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 <Image
src={WP_IMAGES.earMacro} src={WP_IMAGES.earMacro}
@@ -291,7 +306,7 @@ function TheCorn() {
Olathe Sweet · Day of pick Olathe Sweet · Day of pick
</div> </div>
</div> </div>
</FadeOnScroll> </AppleReveal>
</div> </div>
</div> </div>
</LayoutContainer> </LayoutContainer>
@@ -316,7 +331,7 @@ function FounderStrip() {
<LayoutContainer> <LayoutContainer>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-14 items-center"> <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"> <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 <Image
src={WP_IMAGES.founderPortrait} src={WP_IMAGES.founderPortrait}
@@ -336,41 +351,45 @@ function FounderStrip() {
John Harold · Olathe, CO John Harold · Olathe, CO
</div> </div>
</div> </div>
</FadeOnScroll> </AppleReveal>
<div className="lg:col-span-7"> <div className="lg:col-span-7">
<FadeOnScroll from="right"> <AppleStagger startDelay={0.2}>
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/80 mb-5"> <p
data-apple-stagger
className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/80 mb-5"
>
The family behind the row The family behind the row
</p> </p>
</FadeOnScroll>
<FadeOnScroll from="right" delay={0.1}>
<h3 <h3
data-apple-stagger
className="font-display text-white text-[clamp(1.85rem,3.6vw,2.85rem)] leading-[1.1] tracking-[-0.012em]" className="font-display text-white text-[clamp(1.85rem,3.6vw,2.85rem)] leading-[1.1] tracking-[-0.012em]"
style={{ textWrap: "balance" }} style={{ textWrap: "balance" }}
> >
John Harold&rsquo;s great-grandchildren still walk John Harold&rsquo;s great-grandchildren still walk
the same rows he cleared in 1982. the same rows he cleared in 1982.
</h3> </h3>
</FadeOnScroll> <p
<FadeOnScroll from="right" delay={0.2}> data-apple-stagger
<p className="mt-7 text-stone-300 leading-[1.7] text-lg max-w-xl"> 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 The Olathe Sweet parent line was rescued from a retiring farmer
who believed his valley could grow sugar in a husk. We brought 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 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 kept the seed by hand, ear by ear for forty summers. The
name on the cooler is the name on the deed. name on the cooler is the name on the deed.
</p> </p>
</FadeOnScroll> <div
<FadeOnScroll from="right" delay={0.3}> data-apple-stagger
<div className="mt-9 flex flex-wrap items-center gap-x-8 gap-y-3 text-[11px] uppercase tracking-[0.28em]"> 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-amber-200/80">Est. 1982</span>
<span className="text-stone-700">·</span> <span className="text-stone-700">·</span>
<span className="text-stone-400">3 generations on the row</span> <span className="text-stone-400">3 generations on the row</span>
<span className="text-stone-700">·</span> <span className="text-stone-700">·</span>
<span className="text-stone-400">~60 hands at peak</span> <span className="text-stone-400">~60 hands at peak</span>
</div> </div>
</FadeOnScroll> </AppleStagger>
</div> </div>
</div> </div>
</LayoutContainer> </LayoutContainer>
@@ -400,31 +419,39 @@ function HarvestEditorial() {
/> />
<LayoutContainer> <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"> <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"> <AppleStagger className="lg:col-span-7">
<p className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-5"> <p
data-apple-stagger
className="text-[11px] font-semibold uppercase tracking-[0.32em] text-amber-300/85 mb-5"
>
Harvest · 2026 Harvest · 2026
</p> </p>
<h2 <h2
id="harvest-heading" 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]" className="font-display text-stone-100 text-[clamp(2rem,4.4vw,3.5rem)] leading-[1.04] tracking-[-0.02em]"
style={{ textWrap: "balance" }} style={{ textWrap: "balance" }}
> >
The shape of an<br className="hidden sm:block" /> The shape of an<br className="hidden sm:block" />
<span className="italic text-amber-200/85">Olathe summer.</span> <span className="italic text-amber-200/85">Olathe summer.</span>
</h2> </h2>
</div> </AppleStagger>
<div className="lg:col-span-5"> <AppleReveal delay={0.2} className="lg:col-span-5">
<p className="text-stone-400 text-base leading-[1.7] lg:max-w-md lg:ml-auto"> <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 Forty summers of the same seed, the same soil, and the same
row. The diurnal swing that loads the kernel with sugar is row. The diurnal swing that loads the kernel with sugar is
a property of this valley not a recipe we can take with us. a property of this valley not a recipe we can take with us.
</p> </p>
</div> </AppleReveal>
</header> </header>
{/* Full-bleed packshot */} {/* Full-bleed packshot */}
<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"> <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 <Image
src={WP_IMAGES.piledCobs} src={WP_IMAGES.piledCobs}
@@ -452,11 +479,14 @@ function HarvestEditorial() {
Frame 014 · zane Frame 014 · zane
</div> </div>
</figure> </figure>
</AppleReveal>
{/* Magazine data strip — four columns, hairline-ruled, tabular {/* Magazine data strip — four columns, hairline-ruled, tabular
numerals count up on scroll-in (uses the existing .counter-animate numerals count up on scroll-in (AppleCountUp replaces the
GSAP setup). Hairline above + below gives it the magazine- older power2.out / 2s animation with the refined expo-out
sidebar treatment without any colored boxes or icons. */} 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"> <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 <DataPoint
value={40} value={40}
@@ -527,9 +557,7 @@ function DataPoint({
{prefix ? ( {prefix ? (
<span className="text-stone-500 mr-0.5">{prefix}</span> <span className="text-stone-500 mr-0.5">{prefix}</span>
) : null} ) : null}
<span className="counter-animate" data-target={value}> <AppleCountUp end={value} duration={1.6} />
0
</span>
{suffix ? ( {suffix ? (
<span className="text-amber-300 ml-0.5">{suffix}</span> <span className="text-amber-300 ml-0.5">{suffix}</span>
) : null} ) : null}
@@ -575,18 +603,23 @@ function StopsSection({ stops, showSchedulePdf }: { stops: Stop[]; showScheduleP
<div className="mb-14 flex items-end justify-between"> <div className="mb-14 flex items-end justify-between">
<div> <div>
<FadeOnScroll from="left"> <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>
<FadeOnScroll from="up" delay={0.1}> <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]"> <h2
Upcoming<br className="hidden md:block" /> Stops 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> </h2>
</FadeOnScroll> </FadeOnScroll>
<FadeOnScroll from="up" delay={0.15}> <FadeOnScroll from="up" delay={0.18}>
<div className="mt-6 h-px w-12 bg-emerald-600" /> <div className="mt-7 h-px w-12 bg-emerald-700/60" />
</FadeOnScroll> </FadeOnScroll>
<FadeOnScroll from="up" delay={0.2}> <FadeOnScroll from="up" delay={0.26}>
<p className="mt-6 max-w-2xl text-lg text-stone-500 leading-relaxed"> <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. Find a nearby stop and preorder your corn. Pickup is easy and guaranteed.
</p> </p>
</FadeOnScroll> </FadeOnScroll>
+75 -28
View File
@@ -98,13 +98,21 @@ export default function TuxedoVideoHero({
if (v && !v.paused) v.pause(); if (v && !v.paused) v.pause();
}, []); }, []);
// ─── GSAP SCROLL ANIMATIONS ─────────────────────────────────────────────── // ─── APPLE-QUALITY MOTION PASS ──────────────────────────────────────────
// The motion here is intentionally restrained. Hero entrances are short // Choreographed entrance — logo → eyebrow → rule → title-words →
// and small (no positional shift on scroll, no parallax scale on video). // description → CTAs → stats. Each item: opacity 0→1 + Y 16→0 +
// Decorative orbs float on a slow 7s CSS keyframe (ambient, not jittery). // 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(() => { useEffect(() => {
if (typeof window === "undefined" || !sectionRef.current) return; if (typeof window === "undefined" || !sectionRef.current) return;
const prefersReduced =
typeof window !== "undefined" &&
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
ScrollTrigger.create({ ScrollTrigger.create({
trigger: sectionRef.current, trigger: sectionRef.current,
@@ -116,45 +124,74 @@ 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) { if (contentRef.current) {
gsap.to(contentRef.current, { gsap.to(contentRef.current, {
opacity: 0, opacity: 0,
ease: "power1.out", ease: "none",
scrollTrigger: { scrollTrigger: {
trigger: sectionRef.current, trigger: sectionRef.current,
start: "top top", start: "top top",
end: "40% top", end: "bottom top",
scrub: 1.2, scrub: 0.6,
}, },
}); });
} }
const revealEls = gsap.utils.toArray<Element>(".hero-reveal"); if (prefersReduced) {
revealEls.forEach((el) => { // 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( gsap.fromTo(
el, el,
{ opacity: 0, y: 8 }, { opacity: 0, y: 16, filter: "blur(6px)" },
{ {
opacity: 1, opacity: 1,
y: 0, y: 0,
duration: 0.32, filter: "blur(0px)",
ease: "power1.out", duration: 0.55,
scrollTrigger: { delay: 0.05 + i * 0.08,
trigger: el, ease: "power3.out",
start: "top 95%", }
toggleActions: "play none none none",
},
},
); );
}); });
// 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", { gsap.to(".scroll-indicator", {
opacity: 0, opacity: 0,
scrollTrigger: { scrollTrigger: {
trigger: sectionRef.current, trigger: sectionRef.current,
start: "10% top", start: "10% top",
end: "30% top", end: "30% top",
scrub: 1, scrub: 0.5,
}, },
}); });
}, sectionRef); }, sectionRef);
@@ -435,12 +472,7 @@ function HeroContent({
function HeroLogo({ logoSrc }: { logoSrc: string | null }) { function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
return ( return (
<motion.div <div className="hero-reveal mb-8 md:mb-10 relative h-20 md:h-24 w-[320px] md:w-[400px]">
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="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" /> <div className="absolute -inset-8 rounded-full bg-emerald-500/10 blur-2xl" />
{logoSrc ? ( {logoSrc ? (
<Image <Image
@@ -453,7 +485,7 @@ function HeroLogo({ logoSrc }: { logoSrc: string | null }) {
priority priority
/> />
) : null} ) : null}
</motion.div> </div>
); );
} }
@@ -461,15 +493,30 @@ function HeroTitle({ title }: { title: string }) {
// Subtle white/cool text shadow replaces the yellow tint (was reading // Subtle white/cool text shadow replaces the yellow tint (was reading
// neon). Letter-spacing tightened on the largest sizes so the words // neon). Letter-spacing tightened on the largest sizes so the words
// sit closer — feels like a printed farm-stand sign at highway scale. // 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 ( return (
<h1 <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={{ style={{
textShadow: textShadow:
"0 4px 30px rgba(0,0,0,0.45), 0 1px 1px rgba(0,0,0,0.25)", "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> </h1>
); );
} }
+302
View File
@@ -19,6 +19,23 @@ function prefersReducedMotion(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches === true; 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 // SCROLL-REVEAL WRAPPER - Layers content as user scrolls
// Motion-reduced: max 12px translation (was 60px), 320ms (was 1s), // Motion-reduced: max 12px translation (was 60px), 320ms (was 1s),
@@ -322,3 +339,288 @@ export function FadeOnScroll({
</div> </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>
);
}