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 { 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’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’t airbrush the field
|
||||
because we don’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’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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user