diff --git a/src/app/tuxedo/page.tsx b/src/app/tuxedo/page.tsx index b8ab9e7..b1da55b 100644 --- a/src/app/tuxedo/page.tsx +++ b/src/app/tuxedo/page.tsx @@ -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 (
- Pre-storm corn rows receding to the mountains at dusk - {/* Layered tints: top darkens the sky, bottom grounds the type. */} -
- {/* One warm amber rim along the lower edge to echo the corn. */} -
@@ -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 (
{/* Left: copy column, 5/12 */}
- -

+ +

The Corn

-
-

You can taste
the altitude.

-
- -
- - -

+

+

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.

- - -

+

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.

-
+
{/* Right: macro photo, 7/12 — the photograph carries the section. */}
- +
-
+
@@ -316,7 +331,7 @@ function FounderStrip() {
- +
-
+
- -

+ +

The family behind the row

-
-

John Harold’s great-grandchildren still walk the same rows he cleared in 1982.

-
- -

+

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.

-
- -
+
Est. 1982 · 3 generations on the row · ~60 hands at peak
- +
@@ -400,63 +419,74 @@ function HarvestEditorial() { /> - {/* 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. */}
-
-

+ +

Harvest · 2026

The shape of an
Olathe summer.

-
-
-

+ + +

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.

-
+
{/* Full-bleed packshot */} -
- Husked Olathe Sweet cobs piled on the packing shed table the morning of pick - {/* 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. */} -
+ +
+ Husked Olathe Sweet cobs piled on the packing shed table the morning of pick + {/* 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. */} +
+
{/* 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. */}
    {prefix} ) : null} - - 0 - + {suffix ? ( {suffix} ) : null} @@ -575,18 +603,23 @@ function StopsSection({ stops, showSchedulePdf }: { stops: Stop[]; showScheduleP
    -

    Delivery Stops

    +

    + Delivery Stops +

    -

    - Upcoming
    Stops +

    + Upcoming
    Stops

    - -
    + +
    - -

    + +

    Find a nearby stop and preorder your corn. Pickup is easy and guaranteed.

    diff --git a/src/components/storefront/TuxedoVideoHero.tsx b/src/components/storefront/TuxedoVideoHero.tsx index 9ae76ab..74834c1 100644 --- a/src/components/storefront/TuxedoVideoHero.tsx +++ b/src/components/storefront/TuxedoVideoHero.tsx @@ -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(".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(".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(".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 ( - +
    {logoSrc ? ( ) : null} - +
    ); } @@ -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 (

    - {title} + {title.split(" ").map((word, i) => ( + + {word} + + ))}

    ); } diff --git a/src/components/ui/ScrollAnimations.tsx b/src/components/ui/ScrollAnimations.tsx index e889894..456ef65 100644 --- a/src/components/ui/ScrollAnimations.tsx +++ b/src/components/ui/ScrollAnimations.tsx @@ -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}
    ); -} \ No newline at end of file +} +// ───────────────────────────────────────────────────────────────────────────── +// 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(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 ( +
    + {children} +
    + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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(null); + + useEffect(() => { + if (typeof window === "undefined" || !ref.current) return; + + if (prefersReducedMotion()) { + const items = ref.current.querySelectorAll("[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("[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 ( +
    + {children} +
    + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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: +// +// +// +// +// +// 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(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 ( +
    + {children} +
    + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 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(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 ( + + 0 + + ); +}