feat(tuxedo): Apple-quality motion choreography
Deploy to route.crispygoat.com / deploy (push) Successful in 4m42s
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:
+139
-106
@@ -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,36 +168,46 @@ 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)" }}
|
||||||
>
|
>
|
||||||
<Image
|
<AppleStickySection top="0">
|
||||||
src={WP_IMAGES.stormField}
|
<div className="relative w-full h-screen">
|
||||||
alt="Pre-storm corn rows receding to the mountains at dusk"
|
<Image
|
||||||
fill
|
src={WP_IMAGES.stormField}
|
||||||
sizes="100vw"
|
alt="Pre-storm corn rows receding to the mountains at dusk"
|
||||||
quality={88}
|
fill
|
||||||
priority
|
sizes="100vw"
|
||||||
className="object-cover -z-10 filter-editorial"
|
quality={88}
|
||||||
/>
|
priority
|
||||||
{/* Layered tints: top darkens the sky, bottom grounds the type. */}
|
className="object-cover -z-10 filter-editorial"
|
||||||
<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. */}
|
{/* Layered tints: top darkens the sky, bottom grounds the type. */}
|
||||||
<div
|
<div className="absolute inset-0 bg-gradient-to-b from-stone-950/55 via-stone-950/15 to-stone-950/80 -z-10" />
|
||||||
aria-hidden="true"
|
{/* One warm amber rim along the lower edge to echo the corn. */}
|
||||||
className="absolute inset-x-0 bottom-0 h-px bg-amber-300/40 -z-10"
|
<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="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’t have to.”</span>
|
<span className="text-amber-200/95"> It shouldn’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’t airbrush the field
|
silk on the right is still damp. We don’t airbrush the field
|
||||||
because we don’t need to.
|
because we don’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’s great-grandchildren still walk
|
John Harold’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,63 +419,74 @@ 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 */}
|
||||||
<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">
|
<AppleReveal duration={0.7}>
|
||||||
<Image
|
<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">
|
||||||
src={WP_IMAGES.piledCobs}
|
<Image
|
||||||
alt="Husked Olathe Sweet cobs piled on the packing shed table the morning of pick"
|
src={WP_IMAGES.piledCobs}
|
||||||
fill
|
alt="Husked Olathe Sweet cobs piled on the packing shed table the morning of pick"
|
||||||
sizes="(max-width: 1024px) 100vw, 1280px"
|
fill
|
||||||
quality={92}
|
sizes="(max-width: 1024px) 100vw, 1280px"
|
||||||
priority
|
quality={92}
|
||||||
className="object-cover animate-harvest-drift filter-editorial-strong"
|
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
|
{/* A single warm tint along the bottom to echo the existing
|
||||||
the three sections feel like one editorial system. */}
|
amber-wash treatment used in The Corn and FounderStrip so
|
||||||
<div
|
the three sections feel like one editorial system. */}
|
||||||
aria-hidden="true"
|
<div
|
||||||
className="absolute inset-x-0 bottom-0 h-1/4 bg-gradient-to-t from-amber-200/10 to-transparent pointer-events-none"
|
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. */}
|
{/* Provenance plate, lower-left, hairline-bordered, matching
|
||||||
<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">
|
the editorial chrome used elsewhere on the page. */}
|
||||||
Harvest morning · August · Olathe, CO
|
<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">
|
||||||
</figcaption>
|
Harvest morning · August · Olathe, CO
|
||||||
{/* Subtle photo credit on the right — like a magazine spread */}
|
</figcaption>
|
||||||
<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">
|
{/* Subtle photo credit on the right — like a magazine spread */}
|
||||||
Frame 014 · zane
|
<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">
|
||||||
</div>
|
Frame 014 · zane
|
||||||
</figure>
|
</div>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -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,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) {
|
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.fromTo(
|
gsap.set(".hero-reveal, .hero-word", {
|
||||||
el,
|
opacity: 1,
|
||||||
{ opacity: 0, y: 8 },
|
y: 0,
|
||||||
{
|
filter: "none",
|
||||||
opacity: 1,
|
});
|
||||||
y: 0,
|
} else {
|
||||||
duration: 0.32,
|
// Block-level cascade — logo first, then everything else.
|
||||||
ease: "power1.out",
|
const reveals = gsap.utils.toArray<HTMLElement>(".hero-reveal");
|
||||||
scrollTrigger: {
|
reveals.forEach((el, i) => {
|
||||||
trigger: el,
|
gsap.fromTo(
|
||||||
start: "top 95%",
|
el,
|
||||||
toggleActions: "play none none none",
|
{ 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", {
|
gsap.to(".scroll-indicator", {
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
@@ -154,7 +191,7 @@ export default function TuxedoVideoHero({
|
|||||||
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user