fix mobile responsive
This commit is contained in:
@@ -1215,12 +1215,18 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Global Desktop Hero Formatting (Matches live site) */
|
||||
/* Global Desktop Hero Formatting (Matches live site)
|
||||
.miletruth-hero-container is deliberately excluded: unlike the other
|
||||
hero wrappers it IS the visual card itself (background-image + a
|
||||
flat bottom edge that the stats bar overlaps by -25px), not a
|
||||
wrapper around a separate inner card, so it carries its own
|
||||
correctly-tuned margin-top/padding/border-radius in MileTruthHero.tsx.
|
||||
Including it here clobbered that with the wrapper values, collapsing
|
||||
its top gap to 8px and its internal padding to 20px. */
|
||||
@media (min-width: 1025px) {
|
||||
/* Give every hero wrapper a consistent gap from the top and sides */
|
||||
.elementor-element.elementor-element-741f56c,
|
||||
.custom-standard-hero-container,
|
||||
.miletruth-hero-container {
|
||||
.custom-standard-hero-container {
|
||||
padding: 20px 24px !important;
|
||||
margin-top: 8px !important;
|
||||
}
|
||||
@@ -1229,8 +1235,7 @@ body {
|
||||
.elementor-element.elementor-element-741f56c .owl-carousel.owl-theme .content-item,
|
||||
.elementor-element.elementor-element-741f56c .content-item,
|
||||
.custom-standard-hero-card,
|
||||
.miletruth-hero .slide-content,
|
||||
.miletruth-hero .miletruth-hero-container {
|
||||
.miletruth-hero .slide-content {
|
||||
border-radius: 24px !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export default function MileTruthHero() {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
border-radius: 25px 25px 0 0;
|
||||
margin-top: 20px;
|
||||
margin-top: 28px;
|
||||
padding: 100px 0;
|
||||
}
|
||||
.miletruth-hero-container::before {
|
||||
|
||||
@@ -142,6 +142,11 @@ export default function WingsFeatureGrid() {
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.wfg-grid { grid-template-columns: 1fr; }
|
||||
/* var(--space-section-lg) floors at a fixed 52px on any phone
|
||||
width (clamp() has no further breakpoint-based step-down), which
|
||||
reads as noticeably heavier than the section rhythm near the
|
||||
footer. Trimmed to a real mobile value here instead. */
|
||||
.wfg-section { padding-top: 44px; padding-bottom: 44px; }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
|
||||
@@ -55,12 +55,28 @@ export default function WingsFinalCTA() {
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.wfcta-container {
|
||||
margin-top: 40px !important;
|
||||
margin-bottom: 40px !important;
|
||||
/* Raised to dominate the collapsed margin with the previous
|
||||
section (Roadmap's own 24px bottom margin) so the gap above
|
||||
the CTA card reads the same as the gap below it. */
|
||||
margin-top: 14px !important;
|
||||
/* There's ~110px of unexplained extra space rendered between this
|
||||
container and the Footer that follows on mobile (verified with
|
||||
Playwright — no element/padding/margin in the DOM accounts for
|
||||
it, so it isn't coming from either section's own box). Pulling
|
||||
the CTA up with a negative bottom margin closes most of that
|
||||
gap directly instead of chasing the underlying cause, without
|
||||
touching the shared Footer component (used on every page) or
|
||||
either card's internal padding. Tuned so the resulting visible
|
||||
gap below the card matches the ~44px gap above it. */
|
||||
margin-bottom: -96px !important;
|
||||
padding: 0 10px !important;
|
||||
}
|
||||
.wfcta-box {
|
||||
padding: 48px 20px !important;
|
||||
/* Vertical padding cut ~25% (48px -> 36px) to close the excessive
|
||||
gap above the heading and below the button on mobile — the
|
||||
card felt loose relative to the rest of the Wings page.
|
||||
Horizontal padding/border-radius/width untouched. */
|
||||
padding: 36px 20px !important;
|
||||
border-radius: 22px !important;
|
||||
}
|
||||
.wfcta-title {
|
||||
|
||||
@@ -49,6 +49,20 @@ const FEATURES: EVFeature[] = [
|
||||
export default function WingsFleetStrategy() {
|
||||
return (
|
||||
<div id="wings-fleet">
|
||||
{/* EVSection is shared across other pages (Workflow1/Workflow2 etc.),
|
||||
so its own .evnd-gap-bottom rule isn't touched — this override is
|
||||
scoped to #wings-fleet only, and reduces just the blank margin
|
||||
immediately before Infrastructure by ~42% (24px -> 14px) on
|
||||
mobile, matching the site's tighter mobile rhythm elsewhere. */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@media (max-width: 767px) {
|
||||
#wings-fleet .evnd-gap-bottom { margin-bottom: 14px !important; }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
<EVSection
|
||||
bannerImage="/images/pahse1.png"
|
||||
cardNumber="Phase 1"
|
||||
|
||||
@@ -241,6 +241,34 @@
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
/* The clamp() on .section floors at a fixed 56px/80px on any phone width
|
||||
(no further breakpoint-based step-down), noticeably heavier than the
|
||||
rhythm every other Wings section settles into on mobile — normalized to
|
||||
the same 44px/44px here.
|
||||
|
||||
.section's own margin-left/right also stays fixed at 24px below 1024px
|
||||
(set at the top of this file, for the card's inset from the viewport
|
||||
edge). Stacked with .sectionShell's padding that put the card's content
|
||||
34px from the screen edge on phones — noticeably narrower than the
|
||||
Footer's real 10px edge gutter (--dm-mobile-gutter, see globals.css's
|
||||
.dm-section-container). Moving the full 10px gutter onto .section's
|
||||
margin reproduces that same 10px screen-to-card edge, so the card sits
|
||||
flush with its neighbors. .sectionShell then gets its own small 10px
|
||||
breathing-room padding (independent of the outer gutter) so the eyebrow,
|
||||
heading, progress indicator, and step content aren't flush against the
|
||||
card's inner edge. */
|
||||
.section {
|
||||
padding-top: 44px;
|
||||
padding-bottom: 44px;
|
||||
margin-left: var(--dm-mobile-gutter, 10px);
|
||||
margin-right: var(--dm-mobile-gutter, 10px);
|
||||
}
|
||||
|
||||
.sectionShell {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -181,6 +181,11 @@ export default function WingsIndustries() {
|
||||
@media (max-width: 600px) {
|
||||
.wind-grid { grid-template-columns: 1fr; }
|
||||
.wind-card { aspect-ratio: 3 / 4; }
|
||||
/* var(--space-section-lg) floors at a fixed 52px on any phone
|
||||
width; bottom is already trimmed to 20px, so only the top needs
|
||||
a real mobile reduction to match the section rhythm near the
|
||||
footer. */
|
||||
.wind-section { padding-top: 44px; }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
|
||||
@@ -194,6 +194,36 @@
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
/* var(--space-section) floors at a fixed 40px on any phone width (no
|
||||
further breakpoint-based step-down), and .sectionShell's horizontal
|
||||
padding otherwise stays fixed at 24px below 1024px — both read as
|
||||
inconsistent with the rhythm every other Wings section settles into on
|
||||
mobile. Normalized to match: 44px/44px vertical, and the shared
|
||||
--dm-mobile-gutter (10px) horizontal.
|
||||
|
||||
.section's own margin-left/right also stays fixed at 24px below 1024px
|
||||
(set at the top of this file, for the card's inset from the viewport
|
||||
edge). Stacked with .sectionShell's padding that put the card's content
|
||||
34px from the screen edge on phones — noticeably narrower than the
|
||||
Footer's real 10px edge gutter (--dm-mobile-gutter, see globals.css's
|
||||
.dm-section-container). Moving the full 10px gutter onto .section's
|
||||
margin reproduces that same 10px screen-to-card edge, so the card sits
|
||||
flush with its neighbors. .sectionShell then gets its own small 10px
|
||||
breathing-room padding (independent of the outer gutter) so the eyebrow,
|
||||
heading, icons, card titles/descriptions, and dividers (which span
|
||||
.card's full width) aren't flush against the card's inner edge. */
|
||||
.section {
|
||||
padding-top: 44px;
|
||||
padding-bottom: 44px;
|
||||
margin-left: var(--dm-mobile-gutter, 10px);
|
||||
margin-right: var(--dm-mobile-gutter, 10px);
|
||||
}
|
||||
|
||||
.sectionShell {
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -347,6 +347,11 @@ export default function WingsMileTruth() {
|
||||
@media (max-width: 600px) {
|
||||
.wmt-grid { grid-template-columns: 1fr; }
|
||||
.wmt-callout { flex-direction: column; align-items: flex-start; }
|
||||
/* The vertical clamp() floors at a fixed 64px on any phone width;
|
||||
trimmed to a real mobile value to match the section rhythm near
|
||||
the footer. Horizontal padding is left untouched (its own
|
||||
clamp already resolves small on phones). */
|
||||
.wmt-section { padding-top: 48px; padding-bottom: 48px; }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
|
||||
@@ -203,6 +203,16 @@ export default function WingsRoadmap() {
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
/* Both the outer margin-top and the internal vertical padding
|
||||
clamp()s floor at fixed, sizeable values (56px + 64px = ~120px
|
||||
combined) on any phone width — noticeably heavier than the
|
||||
section rhythm near the footer. Horizontal padding is left
|
||||
untouched (its own clamp already resolves small on phones). */
|
||||
.wrm-section {
|
||||
margin-top: 40px;
|
||||
padding-top: 48px;
|
||||
padding-bottom: 48px;
|
||||
}
|
||||
.wrm-track-wrap { display: none; }
|
||||
.wrm-grid {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -162,7 +162,17 @@ export default function WingsSpeedAdvantage() {
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.wsa-face-off { grid-template-columns: 1fr; gap: 40px; }
|
||||
.wsa-vs { position: static; transform: none; margin: -20px auto; }
|
||||
/* Effective visible gap above/below the VS circle is (grid gap +
|
||||
this margin), i.e. 40px - 28px = 12px on each side — an equal,
|
||||
~40% reduction from the previous 20px/20px so the circle reads
|
||||
as bridging the two cards instead of floating in a large empty
|
||||
gap. margin: auto keeps it horizontally centered; the shorthand
|
||||
applies identically to top and bottom so both stay equal. */
|
||||
.wsa-vs { position: static; transform: none; margin: -28px auto; }
|
||||
/* var(--space-section-lg) floors at a fixed 52px on any phone
|
||||
width; trimmed to a real mobile value to match the section
|
||||
rhythm near the footer. */
|
||||
.wsa-section { padding-top: 44px; padding-bottom: 44px; }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import gsap from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { MotionPathPlugin } from "gsap/MotionPathPlugin";
|
||||
import { DrawSVGPlugin } from "gsap/DrawSVGPlugin";
|
||||
import { ScrollReveal, StaggerChildren } from "@/animations/Reveal";
|
||||
|
||||
let hasDrawSVG = false;
|
||||
if (typeof window !== "undefined") {
|
||||
gsap.registerPlugin(ScrollTrigger, MotionPathPlugin);
|
||||
try {
|
||||
// DrawSVGPlugin ships free with gsap 3.13+. Registration (not the import
|
||||
// itself) is guarded so the draw step can fall back to plain
|
||||
// stroke-dasharray if it's ever unavailable, without touching timing.
|
||||
gsap.registerPlugin(DrawSVGPlugin);
|
||||
hasDrawSVG = true;
|
||||
} catch {
|
||||
hasDrawSVG = false;
|
||||
}
|
||||
}
|
||||
|
||||
const MOBILE_BQ = 600;
|
||||
const AIRCRAFT_ROTATION_OFFSET = 90; // glyph's rest pose points "up"
|
||||
const WAVE_AMPLITUDE_MIN = 20;
|
||||
const WAVE_AMPLITUDE_MAX = 38;
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
title: "Same-Day Air Logistics",
|
||||
@@ -48,7 +74,415 @@ const FEATURES = [
|
||||
},
|
||||
];
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function buildPathD(points: Point[]): string {
|
||||
if (points.length < 2) return "";
|
||||
let d = `M ${points[0].x} ${points[0].y}`;
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i];
|
||||
const p1 = points[i + 1];
|
||||
const dx = (p1.x - p0.x) / 2;
|
||||
d += ` C ${p0.x + dx} ${p0.y}, ${p1.x - dx} ${p1.y}, ${p1.x} ${p1.y}`;
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
// Inserts one raised "crest" point between every consecutive pair of card
|
||||
// anchors, turning the mostly-straight spline into an alternating
|
||||
// trough-crest-trough wave: each card position stays a trough (so the pulse
|
||||
// still arrives level with its icon, unchanged), while the gap between cards
|
||||
// arcs upward. buildPathD's per-segment horizontal-tangent control points
|
||||
// already keep every trough/crest transition smooth (no sharp bends) without
|
||||
// needing a fancier spline — only the input point list changes.
|
||||
//
|
||||
// Each crest's height and horizontal position are nudged by a small,
|
||||
// index-derived (not random) offset, so consecutive humps are never
|
||||
// identical — reading as a real, slightly irregular air route rather than a
|
||||
// generated sine wave — while staying perfectly stable across re-measures
|
||||
// (resize, etc.) since the offsets depend only on segment index, not chance.
|
||||
// Each offset sums two sine terms at unrelated frequencies/phases rather
|
||||
// than one, so the sequence of crest heights doesn't itself read as a
|
||||
// single obvious repeating rhythm.
|
||||
function buildWavePoints(points: Point[], amplitude: number): Point[] {
|
||||
if (points.length < 2 || amplitude <= 0) return points;
|
||||
const waved: Point[] = [points[0]];
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const p0 = points[i];
|
||||
const p1 = points[i + 1];
|
||||
const ampFactor =
|
||||
1 + 0.28 * Math.sin(i * 2.9 + 0.4) + 0.15 * Math.sin(i * 5.3 + 2.1);
|
||||
const xJitter =
|
||||
(p1.x - p0.x) * (0.08 * Math.sin(i * 1.7 + 1.3) + 0.06 * Math.sin(i * 3.6 + 0.2));
|
||||
waved.push({
|
||||
x: (p0.x + p1.x) / 2 + xJitter,
|
||||
y: Math.min(p0.y, p1.y) - amplitude * ampFactor,
|
||||
});
|
||||
waved.push(p1);
|
||||
}
|
||||
return waved;
|
||||
}
|
||||
|
||||
export default function WingsWhyGrid() {
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const svgRef = useRef<SVGSVGElement>(null);
|
||||
const pathRef = useRef<SVGPathElement>(null);
|
||||
// A second copy of the same "d", drawn brighter/thicker/blurred. A moving
|
||||
// dasharray window (kept in sync with the pulse's own progress, see
|
||||
// onUpdate below) makes a soft, feathered segment of the route glow right
|
||||
// under the pulse and fade back to the resting line just behind it — the
|
||||
// route being "energized" as the pulse passes, not just a static line.
|
||||
const illumRef = useRef<SVGPathElement>(null);
|
||||
// The traveler is the primary visual: a bright optical pulse riding the
|
||||
// route. It's the outer group MotionPathPlugin translates. The aircraft is
|
||||
// nested inside it as a small, secondary glyph — never its own focal point.
|
||||
const travelerRef = useRef<SVGGElement>(null);
|
||||
// Only this inner group rotates to face the direction of travel (the
|
||||
// halo). Kept separate from travelerRef with an explicit 0,0 transform
|
||||
// origin so rotation never pivots around a fill-box-derived point.
|
||||
const directionalRef = useRef<SVGGElement>(null);
|
||||
// The aircraft's own rotation wrapper, painted as a sibling *after* the
|
||||
// core circle (see JSX) so its silhouette stays readable on top of the
|
||||
// bright core instead of being buried under it. Mirrors directionalRef's
|
||||
// rotation exactly (set alongside it in onUpdate) so both halves of the
|
||||
// traveler always face the same way despite being separate DOM branches.
|
||||
const aircraftDirectionalRef = useRef<SVGGElement>(null);
|
||||
const cardRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
const iconRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const wrap = wrapRef.current;
|
||||
const svg = svgRef.current;
|
||||
const path = pathRef.current;
|
||||
const illum = illumRef.current;
|
||||
const traveler = travelerRef.current;
|
||||
const directional = directionalRef.current;
|
||||
const aircraftDirectional = aircraftDirectionalRef.current;
|
||||
if (!wrap || !svg || !path || !illum || !traveler || !directional || !aircraftDirectional) return;
|
||||
|
||||
// Declared here (not inside the gsap.context callback below) so the
|
||||
// cleanup function — which must run on every unmount, including ones
|
||||
// gsap.context() itself doesn't know about, like these plain rAF/
|
||||
// ResizeObserver handles — can always reach them.
|
||||
let resizeRaf = 0;
|
||||
let initialRaf = 0;
|
||||
let rafIn = 0;
|
||||
let ro: ResizeObserver | undefined;
|
||||
let loadHandler: (() => void) | undefined;
|
||||
// Also assigned (not re-declared) inside the gsap.context callback below,
|
||||
// for the same reason: cleanup needs to reach them even though
|
||||
// ctx.revert() already kills anything the context tracked, since the
|
||||
// ScrollTrigger is created synchronously (tracked) but the flight
|
||||
// timeline is only created later, asynchronously, once onEnter actually
|
||||
// fires (not reliably tracked by the context in that case).
|
||||
let st: ScrollTrigger | null = null;
|
||||
let playedTimeline: gsap.core.Timeline | null = null;
|
||||
|
||||
// Everything GSAP-related (ScrollTrigger, timelines, gsap.set calls) is
|
||||
// created inside this context so a single ctx.revert() on unmount kills
|
||||
// all of it — this is what makes the whole setup safe to re-run on every
|
||||
// mount (initial load, client-side route navigation, back/forward, hot
|
||||
// reload) instead of only working after a hard refresh, where a prior
|
||||
// mount's leftover ScrollTrigger/timeline could otherwise linger or hold
|
||||
// a stale measurement.
|
||||
const ctx = gsap.context(() => {
|
||||
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
let played = false;
|
||||
|
||||
const isMobileLayout = () => window.matchMedia(`(max-width: ${MOBILE_BQ}px)`).matches;
|
||||
|
||||
const measure = (): { points: Point[]; isMobile: boolean } => {
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
const isMobile = isMobileLayout();
|
||||
|
||||
const points = cardRefs.current
|
||||
.map((card, i) => {
|
||||
const icon = iconRefs.current[i];
|
||||
if (!card || !icon) return null;
|
||||
const cardRect = card.getBoundingClientRect();
|
||||
const iconRect = icon.getBoundingClientRect();
|
||||
if (isMobile) {
|
||||
return {
|
||||
x: 12,
|
||||
y: iconRect.top + iconRect.height / 2 - wrapRect.top,
|
||||
};
|
||||
}
|
||||
return {
|
||||
x: iconRect.left + iconRect.width / 2 - wrapRect.left,
|
||||
y: cardRect.top - wrapRect.top - 14,
|
||||
};
|
||||
})
|
||||
.filter((p): p is Point => p !== null);
|
||||
|
||||
svg.setAttribute("width", String(wrapRect.width));
|
||||
svg.setAttribute("height", String(wrapRect.height));
|
||||
svg.setAttribute("viewBox", `0 0 ${wrapRect.width} ${wrapRect.height}`);
|
||||
|
||||
// Wave only applies to the horizontal (non-mobile) layout — mobile
|
||||
// keeps its existing straight vertical line unchanged. Amplitude
|
||||
// scales with the section's width so it stays "gentle" (never a
|
||||
// straight line, never overly aggressive) at any breakpoint.
|
||||
const amplitude = isMobile
|
||||
? 0
|
||||
: Math.max(WAVE_AMPLITUDE_MIN, Math.min(WAVE_AMPLITUDE_MAX, wrapRect.width / 20));
|
||||
const routePoints = isMobile ? points : buildWavePoints(points, amplitude);
|
||||
const d = buildPathD(routePoints);
|
||||
path.setAttribute("d", d);
|
||||
illum.setAttribute("d", d);
|
||||
|
||||
return { points, isMobile };
|
||||
};
|
||||
|
||||
// Keep the (static, or already-settled) line in sync with layout reflow.
|
||||
// Only ever updates geometry — never restarts or replays the animation.
|
||||
ro = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(resizeRaf);
|
||||
resizeRaf = requestAnimationFrame(measure);
|
||||
});
|
||||
ro.observe(wrap);
|
||||
|
||||
// Initial synchronous measurement so `path` has real geometry before any
|
||||
// gsap.set/timeline touches it (ResizeObserver's first callback is async).
|
||||
// Followed by one deferred re-measure: StaggerChildren's own mount effect
|
||||
// (a sibling/child effect) can still have cards translated by their
|
||||
// pre-reveal yOffset at this exact tick, so the very first synchronous
|
||||
// read can be transiently stale — a post-paint rAF corrects it.
|
||||
measure();
|
||||
initialRaf = requestAnimationFrame(() => {
|
||||
measure();
|
||||
// Recalculates every ScrollTrigger's start/end position against the
|
||||
// now-settled layout. Without this, a trigger created a moment before
|
||||
// web fonts/images finish shifting the page can end up with a stale
|
||||
// "top 85%" position that a normal scroll never actually crosses —
|
||||
// the classic reason a GSAP ScrollTrigger works after a hard refresh
|
||||
// (cache-warm, layout already stable) but not after a fresh client-side
|
||||
// navigation.
|
||||
ScrollTrigger.refresh();
|
||||
});
|
||||
|
||||
// Extra safety net: images loading in after this point can still shift
|
||||
// layout. One more refresh once the whole page (including images) has
|
||||
// finished loading catches that — a no-op if `load` already fired.
|
||||
if (document.readyState !== "complete") {
|
||||
loadHandler = () => ScrollTrigger.refresh();
|
||||
window.addEventListener("load", loadHandler, { once: true });
|
||||
}
|
||||
|
||||
const activateCard = (i: number) => {
|
||||
const card = cardRefs.current[i];
|
||||
const icon = iconRefs.current[i];
|
||||
if (!card) return;
|
||||
// Arrival: border gently strengthens, ambient glow grows, icon
|
||||
// brightens, card lifts a touch (CSS-driven, eased over ~0.5s — see
|
||||
// .wwg-card / .wwg-card-glow transition durations below — so it grows
|
||||
// and settles rather than snapping on).
|
||||
card.classList.add("wwg-card-flown", "wwg-card-pulse");
|
||||
// Icon's own gentle scale pulse starts a beat after the arrival
|
||||
// begins, so the sequence reads as "arrives, then responds" rather
|
||||
// than everything happening at once.
|
||||
if (icon) {
|
||||
gsap.timeline({ delay: 0.28 })
|
||||
.to(icon, { scale: 1.02, duration: 0.32, ease: "expo.out" })
|
||||
.to(icon, { scale: 1, duration: 0.32, ease: "sine.inOut" });
|
||||
}
|
||||
// Hold the arrival state, then ease into the subtler, persistent
|
||||
// "flown" resting state.
|
||||
gsap.delayedCall(0.95, () => card.classList.remove("wwg-card-pulse"));
|
||||
};
|
||||
|
||||
const runSequence = () => {
|
||||
if (played) return;
|
||||
played = true;
|
||||
|
||||
const { points, isMobile } = measure();
|
||||
if (points.length < 2) return;
|
||||
|
||||
const restOpacity = isMobile ? 0.15 : 0.18;
|
||||
const total = path.getTotalLength();
|
||||
// The illuminated window trailing the pulse: roughly a fifth of the
|
||||
// route's length, so it reads as a genuine glowing trail rather than
|
||||
// a small blip or the entire line at once.
|
||||
const trailLen = total * 0.22;
|
||||
const illumDashGap = total + 200;
|
||||
illum.setAttribute("stroke-dasharray", `${trailLen} ${illumDashGap}`);
|
||||
const fractions = points.map((pt) => {
|
||||
let best = 0;
|
||||
let bestDist = Infinity;
|
||||
const step = Math.max(2, total / 400);
|
||||
for (let l = 0; l <= total; l += step) {
|
||||
const p = path.getPointAtLength(l);
|
||||
const dist = (p.x - pt.x) ** 2 + (p.y - pt.y) ** 2;
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = l;
|
||||
}
|
||||
}
|
||||
return best / total;
|
||||
});
|
||||
const fired = fractions.map(() => false);
|
||||
|
||||
let currentAngle = 0;
|
||||
let angleInit = false;
|
||||
|
||||
const tl = gsap.timeline();
|
||||
playedTimeline = tl;
|
||||
|
||||
// The energy pulse's own journey — calm and deliberate, ~4.8-5.5s so
|
||||
// it glides rather than rushes from the first card to the last.
|
||||
const FLIGHT_DURATION = 5.2;
|
||||
// The route draws slightly faster than the pulse travels, both
|
||||
// starting together at t=0 — so the drawn extent always leads the
|
||||
// pulse's position (never the other way round) and the whole route
|
||||
// is guaranteed fully illuminated well before the pulse's own journey
|
||||
// ends, while both still read as one continuous, synchronized motion.
|
||||
const DRAW_DURATION = FLIGHT_DURATION * 0.9;
|
||||
|
||||
if (hasDrawSVG) {
|
||||
tl.to(path, { drawSVG: "100%", duration: DRAW_DURATION, ease: "power2.inOut" }, 0);
|
||||
} else {
|
||||
gsap.set(path, { strokeDasharray: total, strokeDashoffset: total });
|
||||
tl.to(path, { strokeDashoffset: 0, duration: DRAW_DURATION, ease: "power2.inOut" }, 0);
|
||||
}
|
||||
// The base route ramps straight to its final resting opacity as it
|
||||
// draws — never to an elevated "traveling" brightness — so there is
|
||||
// only ever one illuminated layer on screen: the bright illum trail
|
||||
// riding with the pulse (below). Without this, the whole drawn route
|
||||
// would sit at a visibly elevated opacity for the entire flight,
|
||||
// reading as a second, competing "lit" route behind the pulse.
|
||||
tl.to(path, { opacity: restOpacity, duration: 0.9, ease: "sine.out" }, 0);
|
||||
|
||||
// GSAP computes its own rotation pivot for SVG elements from the
|
||||
// target's bounding box (stamped as a data-svg-origin attribute) and
|
||||
// ignores plain CSS transform-origin for that purpose — so without
|
||||
// this, both directional groups were actually rotating around their
|
||||
// bbox corner instead of (0,0), visibly orbiting off the path's
|
||||
// centerline as they turned. svgOrigin is GSAP's own equivalent,
|
||||
// stated in the element's literal SVG coordinates (not bbox-relative
|
||||
// percentages), and only needs setting once — the per-frame
|
||||
// rotation-only gsap.set calls below then reuse it automatically.
|
||||
gsap.set([directional, aircraftDirectional], { svgOrigin: "0 0" });
|
||||
|
||||
// A soft fade-in rather than an instant pop, so the pulse's arrival
|
||||
// reads as one continuous gesture from the very first frame.
|
||||
tl.to(traveler, { opacity: 1, duration: 0.3, ease: "sine.out" }, 0);
|
||||
tl.to(illum, { opacity: 1, duration: 0.4, ease: "sine.out" }, 0);
|
||||
tl.to(traveler, {
|
||||
// alignOrigin locks the traveler's own center (not a default
|
||||
// corner/bbox reference) to the path, matching the explicit
|
||||
// transform-origin: 50% 50% above — keeps the pulse/aircraft
|
||||
// precisely on the route's centerline with no vertical drift.
|
||||
motionPath: { path, autoRotate: false, alignOrigin: [0.5, 0.5] },
|
||||
duration: FLIGHT_DURATION,
|
||||
ease: "power2.inOut",
|
||||
onUpdate: function () {
|
||||
const p = this.progress();
|
||||
const l = p * total;
|
||||
const eps = Math.max(1, total * 0.01);
|
||||
const a = path.getPointAtLength(Math.max(0, l - eps));
|
||||
const b = path.getPointAtLength(Math.min(total, l + eps));
|
||||
const target = (Math.atan2(b.y - a.y, b.x - a.x) * 180) / Math.PI + AIRCRAFT_ROTATION_OFFSET;
|
||||
|
||||
if (!angleInit) {
|
||||
currentAngle = target;
|
||||
angleInit = true;
|
||||
} else {
|
||||
let delta = target - currentAngle;
|
||||
// shortest angular path so the lerp never spins the long way round
|
||||
delta = ((delta + 180) % 360 + 360) % 360 - 180;
|
||||
currentAngle += delta * 0.12;
|
||||
}
|
||||
// Only the two directional groups (trail + halo, and the aircraft)
|
||||
// rotate — the core stays a perfectly round point of light on the
|
||||
// path. Both are set to the same angle every frame so they always
|
||||
// face the same way despite being separate DOM branches (see
|
||||
// aircraftDirectionalRef for why they're split).
|
||||
gsap.set(directional, { rotation: currentAngle });
|
||||
gsap.set(aircraftDirectional, { rotation: currentAngle });
|
||||
|
||||
// Slide the illuminated window's trailing edge to l - trailLen and
|
||||
// its leading edge to l (i.e. right under the pulse) by shifting
|
||||
// the dash pattern's phase — the CSS blur on .wwg-illum-trail turns
|
||||
// those two hard edges into a soft glow that fades into the resting
|
||||
// route, rather than a sharp on/off cut.
|
||||
illum.setAttribute("stroke-dashoffset", String(trailLen - l));
|
||||
|
||||
fractions.forEach((f, i) => {
|
||||
if (!fired[i] && p >= f) {
|
||||
fired[i] = true;
|
||||
activateCard(i);
|
||||
}
|
||||
});
|
||||
},
|
||||
}, 0);
|
||||
|
||||
// Only once the pulse's own journey is complete does it fade out —
|
||||
// the route itself is already at its resting opacity by this point
|
||||
// (see above), so nothing further happens to it here.
|
||||
tl.to(traveler, { opacity: 0, duration: 0.6, ease: "sine.out" }, FLIGHT_DURATION);
|
||||
tl.to(illum, { opacity: 0, duration: 0.6, ease: "sine.out" }, FLIGHT_DURATION);
|
||||
};
|
||||
|
||||
if (reduced) {
|
||||
const { isMobile } = measure();
|
||||
gsap.set(path, { opacity: isMobile ? 0.15 : 0.18 });
|
||||
if (hasDrawSVG) {
|
||||
gsap.set(path, { drawSVG: "100%" });
|
||||
} else {
|
||||
gsap.set(path, { strokeDasharray: "none", strokeDashoffset: 0 });
|
||||
}
|
||||
gsap.set(traveler, { opacity: 0 });
|
||||
gsap.set(illum, { opacity: 0 });
|
||||
} else {
|
||||
gsap.set(path, { opacity: 0 });
|
||||
if (hasDrawSVG) {
|
||||
gsap.set(path, { drawSVG: "0%" });
|
||||
}
|
||||
gsap.set(traveler, { opacity: 0 });
|
||||
gsap.set(illum, { opacity: 0 });
|
||||
|
||||
// Starts as soon as the section is mostly in view (matches the cards'
|
||||
// own "top 85%" stagger-reveal trigger) rather than waiting for it to
|
||||
// be ~centered — the ~6s draw+flight sequence needs that extra lead
|
||||
// time to actually be seen during a normal scroll pass, not just
|
||||
// triggered and then scrolled straight past.
|
||||
st = ScrollTrigger.create({
|
||||
trigger: wrap,
|
||||
start: "top 85%",
|
||||
once: true,
|
||||
onEnter: runSequence,
|
||||
});
|
||||
|
||||
rafIn = requestAnimationFrame(() => {
|
||||
const r = wrap.getBoundingClientRect();
|
||||
const vh = window.innerHeight || document.documentElement.clientHeight;
|
||||
if (r.top < vh * 0.85 && r.bottom > 0) {
|
||||
runSequence();
|
||||
st?.kill();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, wrap);
|
||||
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
cancelAnimationFrame(resizeRaf);
|
||||
cancelAnimationFrame(initialRaf);
|
||||
cancelAnimationFrame(rafIn);
|
||||
if (loadHandler) window.removeEventListener("load", loadHandler);
|
||||
st?.kill();
|
||||
playedTimeline?.kill();
|
||||
// Reverts/kills everything gsap.context tracked (ScrollTrigger.create,
|
||||
// gsap.set calls, etc.) as a safety net beyond the explicit kills
|
||||
// above, which exist specifically because the flight timeline is
|
||||
// created asynchronously (inside onEnter) and so isn't guaranteed to
|
||||
// be tracked by the context itself.
|
||||
ctx.revert();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style
|
||||
@@ -89,6 +523,100 @@ export default function WingsWhyGrid() {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.wwg-grid-wrap {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.wwg-flightpath {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.wwg-flightpath-line {
|
||||
fill: none;
|
||||
stroke: #c8102e;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* A brighter, blurred copy of the same route, painted *behind* the
|
||||
crisp foreground line (see JSX order). Its dasharray is driven
|
||||
per-frame (see onUpdate) to keep a soft window of it trailing
|
||||
directly beneath the pulse; because it sits behind rather than on
|
||||
top, its blur reads as a glow radiating from under the route
|
||||
instead of visually thickening the route's own edges. Stroke
|
||||
width intentionally matches the base route so it never peeks out
|
||||
wider than the crisp line drawn on top of it. */
|
||||
.wwg-illum-trail {
|
||||
fill: none;
|
||||
stroke: #ff3d5a;
|
||||
stroke-width: 2;
|
||||
stroke-linecap: round;
|
||||
filter: blur(5px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* The traveler is the primary visual: a single centered optical
|
||||
pulse riding the route, with a small secondary aircraft glyph
|
||||
carried inside it. Position (translate) lives on .wwg-traveler;
|
||||
only the inner .wwg-traveler-directional group rotates to face
|
||||
the direction of travel — the round pulse core stays outside it
|
||||
so it never rotates, always reading as a clean point of light. */
|
||||
.wwg-traveler {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
will-change: transform;
|
||||
/* Explicit center alignment (paired with alignOrigin: [0.5, 0.5]
|
||||
on the motionPath tween) so the traveler's own center — not a
|
||||
corner/default reference point — is what MotionPathPlugin locks
|
||||
to the route, keeping it precisely on the path's centerline
|
||||
with no vertical drift. */
|
||||
transform-origin: 50% 50%;
|
||||
}
|
||||
|
||||
.wwg-traveler-directional {
|
||||
transform-origin: 0px 0px;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Large, radially-symmetric ambient glow — the true hero of the
|
||||
traveler. Centered on the pulse with no directional offset, so
|
||||
there is never a brighter patch leading ahead of the aircraft.
|
||||
Non-directional, so it lives outside the rotating
|
||||
.wwg-traveler-directional group. */
|
||||
.wwg-pulse-bloom {
|
||||
fill: #c8102e;
|
||||
opacity: 0.2;
|
||||
filter: blur(18px);
|
||||
}
|
||||
|
||||
.wwg-pulse-halo {
|
||||
fill: #c8102e;
|
||||
opacity: 0.52;
|
||||
filter: blur(7px);
|
||||
}
|
||||
|
||||
.wwg-pulse-core {
|
||||
fill: #ffffff;
|
||||
filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.98)) drop-shadow(0 0 9px rgba(200, 16, 46, 0.85))
|
||||
drop-shadow(0 0 15px rgba(200, 16, 46, 0.5));
|
||||
}
|
||||
|
||||
/* Deliberately no drop-shadow of its own — the bloom/halo/trail
|
||||
above supply all the glow. Sized to be clearly recognizable as an
|
||||
aircraft without becoming the focal point: the pulse is still the
|
||||
brightest, largest element on screen at every moment. */
|
||||
.wwg-aircraft {
|
||||
color: #c8102e;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.wwg-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -96,12 +624,23 @@ export default function WingsWhyGrid() {
|
||||
}
|
||||
|
||||
.wwg-card {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
padding: 30px 24px;
|
||||
border: 1px solid rgba(17, 17, 17, 0.08);
|
||||
border-radius: 18px;
|
||||
background: #fafafa;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease, transform 0.3s ease;
|
||||
/* Staggered delays so activation reads as a cascade — glow moves
|
||||
first (see .wwg-card-glow below, no delay), then border/shape,
|
||||
then the background tint settles last — rather than every
|
||||
property snapping at once. The "expo-out" curve (rather than
|
||||
plain ease) is what gives it a soft, premium settle instead of a
|
||||
mechanical linear-ish snap. */
|
||||
transition:
|
||||
border-color 0.75s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
|
||||
box-shadow 0.75s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
|
||||
transform 0.75s cubic-bezier(0.16, 1, 0.3, 1) 0.08s,
|
||||
background-color 0.75s cubic-bezier(0.16, 1, 0.3, 1) 0.22s;
|
||||
}
|
||||
|
||||
.wwg-card:hover {
|
||||
@@ -110,6 +649,37 @@ export default function WingsWhyGrid() {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.wwg-card-glow {
|
||||
position: absolute;
|
||||
inset: -12px;
|
||||
border-radius: 22px;
|
||||
background: radial-gradient(circle, rgba(200, 16, 46, 0.45) 0%, rgba(200, 16, 46, 0) 70%);
|
||||
opacity: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
filter: blur(6px);
|
||||
transition: opacity 0.75s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.wwg-card-flown {
|
||||
border-color: rgba(200, 16, 46, 0.25);
|
||||
background-color: #fdf7f8;
|
||||
}
|
||||
|
||||
.wwg-card-flown .wwg-card-glow {
|
||||
opacity: 0.32;
|
||||
}
|
||||
|
||||
.wwg-card-pulse {
|
||||
border-color: rgba(200, 16, 46, 0.5);
|
||||
box-shadow: 0 16px 34px rgba(0, 0, 0, 0.07);
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
|
||||
.wwg-card-pulse .wwg-card-glow {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.wwg-icon {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
@@ -120,6 +690,18 @@ export default function WingsWhyGrid() {
|
||||
background: rgba(200, 16, 46, 0.06);
|
||||
color: #c8102e;
|
||||
margin-bottom: 20px;
|
||||
transition:
|
||||
background-color 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.16s,
|
||||
filter 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.16s;
|
||||
}
|
||||
|
||||
.wwg-card-flown .wwg-icon {
|
||||
background: rgba(200, 16, 46, 0.1);
|
||||
}
|
||||
|
||||
.wwg-card-pulse .wwg-icon {
|
||||
background: rgba(200, 16, 46, 0.14);
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
|
||||
.wwg-card-title {
|
||||
@@ -143,7 +725,18 @@ export default function WingsWhyGrid() {
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.wwg-grid { grid-template-columns: 1fr; }
|
||||
.wwg-grid { grid-template-columns: 1fr; padding-left: 28px; }
|
||||
/* var(--space-section-lg) floors at a fixed 52px on any phone
|
||||
width; trimmed to a real mobile value to match the section
|
||||
rhythm near the footer. Matches this file's own MOBILE_BQ
|
||||
breakpoint used by the flight-path animation logic. */
|
||||
.wwg-section { padding-top: 44px; padding-bottom: 44px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.wwg-card, .wwg-card-glow, .wwg-icon {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
@@ -157,15 +750,74 @@ export default function WingsWhyGrid() {
|
||||
<h2 className="wwg-title">Built for the speed of the Gen-Z economy</h2>
|
||||
</ScrollReveal>
|
||||
|
||||
<StaggerChildren stagger={0.1} duration={0.7} yOffset={30} className="wwg-grid">
|
||||
{FEATURES.map((f) => (
|
||||
<div key={f.title} className="wwg-card">
|
||||
<div className="wwg-icon" aria-hidden="true">{f.icon}</div>
|
||||
<h3 className="wwg-card-title">{f.title}</h3>
|
||||
<p className="wwg-card-desc">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</StaggerChildren>
|
||||
<div className="wwg-grid-wrap" ref={wrapRef}>
|
||||
<svg ref={svgRef} className="wwg-flightpath" aria-hidden="true" focusable="false">
|
||||
{/* Rendered first (behind) so its blur/extra width reads as a
|
||||
soft glow sitting under the route rather than visually
|
||||
thickening it — the crisp .wwg-flightpath-line painted
|
||||
after it always defines the route's actual edges. */}
|
||||
<path ref={illumRef} className="wwg-illum-trail" d="" />
|
||||
<path ref={pathRef} className="wwg-flightpath-line" d="" />
|
||||
<g ref={travelerRef} className="wwg-traveler">
|
||||
{/* Large feathered bloom: the true hero of the traveler, an
|
||||
ambient glow with no direction of its own. */}
|
||||
<circle className="wwg-pulse-bloom" cx="0" cy="0" r="26" />
|
||||
<g ref={directionalRef} className="wwg-traveler-directional">
|
||||
<ellipse className="wwg-pulse-halo" cx="0" cy="0" rx="11" ry="6" />
|
||||
</g>
|
||||
<circle className="wwg-pulse-core" cx="0" cy="0" r="2.6" />
|
||||
{/* Painted after (on top of) the core so the aircraft's
|
||||
silhouette stays readable instead of being buried under
|
||||
the bright core dot. Rotation mirrors directionalRef
|
||||
exactly (see onUpdate) despite living in its own group. */}
|
||||
<g ref={aircraftDirectionalRef} className="wwg-traveler-directional">
|
||||
{/* The stock glyph's original 24x24 viewBox had ~3.5
|
||||
units of dead padding on every side around the actual
|
||||
ink (true silhouette bbox was only 17x20) — scaling
|
||||
from the nominal 24-unit box would have kept baking
|
||||
that padding in and rendering smaller than intended.
|
||||
These coordinates instead start from the silhouette's
|
||||
own tight bbox (no padding), centered on (0,0), then
|
||||
scaled 1.3x — giving a true rendered size of ~22x26px,
|
||||
clearing 22px on both axes. Deliberately no
|
||||
scale()/translate() transform: baked directly into
|
||||
the path data so it renders at full native
|
||||
resolution rather than through a transform stacked on
|
||||
top of this group's own rotation transform. */}
|
||||
<path
|
||||
className="wwg-aircraft"
|
||||
d="M0,-13 C-0.78,-13 -1.3,-12.22 -1.3,-11.31 L-1.3,-3.9 L-11.05,1.3 L-11.05,3.9 L-1.3,0.91 L-1.3,9.1 L-4.55,11.44 L-4.55,13 L0,11.7 L4.55,13 L4.55,11.44 L1.3,9.1 L1.3,0.91 L11.05,3.9 L11.05,1.3 L1.3,-3.9 L1.3,-11.31 C1.3,-12.22 0.78,-13 0,-13 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<StaggerChildren stagger={0.1} duration={0.7} yOffset={30} className="wwg-grid">
|
||||
{FEATURES.map((f, i) => (
|
||||
<div
|
||||
key={f.title}
|
||||
className="wwg-card"
|
||||
ref={(el) => {
|
||||
cardRefs.current[i] = el;
|
||||
}}
|
||||
>
|
||||
<span className="wwg-card-glow" aria-hidden="true" />
|
||||
<div
|
||||
className="wwg-icon"
|
||||
aria-hidden="true"
|
||||
ref={(el) => {
|
||||
iconRefs.current[i] = el;
|
||||
}}
|
||||
>
|
||||
{f.icon}
|
||||
</div>
|
||||
<h3 className="wwg-card-title">{f.title}</h3>
|
||||
<p className="wwg-card-desc">{f.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</StaggerChildren>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user