fix mobile responsive

This commit is contained in:
2026-07-08 19:13:36 +05:30
parent f7bab1fe70
commit d639415d89
12 changed files with 801 additions and 21 deletions

View File

@@ -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) { @media (min-width: 1025px) {
/* Give every hero wrapper a consistent gap from the top and sides */ /* Give every hero wrapper a consistent gap from the top and sides */
.elementor-element.elementor-element-741f56c, .elementor-element.elementor-element-741f56c,
.custom-standard-hero-container, .custom-standard-hero-container {
.miletruth-hero-container {
padding: 20px 24px !important; padding: 20px 24px !important;
margin-top: 8px !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 .owl-carousel.owl-theme .content-item,
.elementor-element.elementor-element-741f56c .content-item, .elementor-element.elementor-element-741f56c .content-item,
.custom-standard-hero-card, .custom-standard-hero-card,
.miletruth-hero .slide-content, .miletruth-hero .slide-content {
.miletruth-hero .miletruth-hero-container {
border-radius: 24px !important; border-radius: 24px !important;
overflow: hidden !important; overflow: hidden !important;
} }

View File

@@ -68,7 +68,7 @@ export default function MileTruthHero() {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
border-radius: 25px 25px 0 0; border-radius: 25px 25px 0 0;
margin-top: 20px; margin-top: 28px;
padding: 100px 0; padding: 100px 0;
} }
.miletruth-hero-container::before { .miletruth-hero-container::before {

View File

@@ -142,6 +142,11 @@ export default function WingsFeatureGrid() {
@media (max-width: 600px) { @media (max-width: 600px) {
.wfg-grid { grid-template-columns: 1fr; } .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; }
} }
`, `,
}} }}

View File

@@ -55,12 +55,28 @@ export default function WingsFinalCTA() {
@media (max-width: 768px) { @media (max-width: 768px) {
.wfcta-container { .wfcta-container {
margin-top: 40px !important; /* Raised to dominate the collapsed margin with the previous
margin-bottom: 40px !important; 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; padding: 0 10px !important;
} }
.wfcta-box { .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; border-radius: 22px !important;
} }
.wfcta-title { .wfcta-title {

View File

@@ -49,6 +49,20 @@ const FEATURES: EVFeature[] = [
export default function WingsFleetStrategy() { export default function WingsFleetStrategy() {
return ( return (
<div id="wings-fleet"> <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 <EVSection
bannerImage="/images/pahse1.png" bannerImage="/images/pahse1.png"
cardNumber="Phase 1" cardNumber="Phase 1"

View File

@@ -241,6 +241,34 @@
} }
@media (max-width: 600px) { @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 { .flow {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -181,6 +181,11 @@ export default function WingsIndustries() {
@media (max-width: 600px) { @media (max-width: 600px) {
.wind-grid { grid-template-columns: 1fr; } .wind-grid { grid-template-columns: 1fr; }
.wind-card { aspect-ratio: 3 / 4; } .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; }
} }
`, `,
}} }}

View File

@@ -194,6 +194,36 @@
} }
@media (max-width: 600px) { @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 { .grid {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -347,6 +347,11 @@ export default function WingsMileTruth() {
@media (max-width: 600px) { @media (max-width: 600px) {
.wmt-grid { grid-template-columns: 1fr; } .wmt-grid { grid-template-columns: 1fr; }
.wmt-callout { flex-direction: column; align-items: flex-start; } .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; }
} }
`, `,
}} }}

View File

@@ -203,6 +203,16 @@ export default function WingsRoadmap() {
} }
@media (max-width: 767px) { @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-track-wrap { display: none; }
.wrm-grid { .wrm-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -162,7 +162,17 @@ export default function WingsSpeedAdvantage() {
@media (max-width: 767px) { @media (max-width: 767px) {
.wsa-face-off { grid-template-columns: 1fr; gap: 40px; } .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; }
} }
`, `,
}} }}

View File

@@ -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"; 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 = [ const FEATURES = [
{ {
title: "Same-Day Air Logistics", 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() { 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 ( return (
<> <>
<style <style
@@ -89,6 +523,100 @@ export default function WingsWhyGrid() {
margin: 0 auto; 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 { .wwg-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -96,12 +624,23 @@ export default function WingsWhyGrid() {
} }
.wwg-card { .wwg-card {
position: relative;
min-width: 0; min-width: 0;
padding: 30px 24px; padding: 30px 24px;
border: 1px solid rgba(17, 17, 17, 0.08); border: 1px solid rgba(17, 17, 17, 0.08);
border-radius: 18px; border-radius: 18px;
background: #fafafa; 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 { .wwg-card:hover {
@@ -110,6 +649,37 @@ export default function WingsWhyGrid() {
transform: translateY(-4px); 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 { .wwg-icon {
width: 52px; width: 52px;
height: 52px; height: 52px;
@@ -120,6 +690,18 @@ export default function WingsWhyGrid() {
background: rgba(200, 16, 46, 0.06); background: rgba(200, 16, 46, 0.06);
color: #c8102e; color: #c8102e;
margin-bottom: 20px; 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 { .wwg-card-title {
@@ -143,7 +725,18 @@ export default function WingsWhyGrid() {
} }
@media (max-width: 600px) { @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,10 +750,68 @@ export default function WingsWhyGrid() {
<h2 className="wwg-title">Built for the speed of the Gen-Z economy</h2> <h2 className="wwg-title">Built for the speed of the Gen-Z economy</h2>
</ScrollReveal> </ScrollReveal>
<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"> <StaggerChildren stagger={0.1} duration={0.7} yOffset={30} className="wwg-grid">
{FEATURES.map((f) => ( {FEATURES.map((f, i) => (
<div key={f.title} className="wwg-card"> <div
<div className="wwg-icon" aria-hidden="true">{f.icon}</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> <h3 className="wwg-card-title">{f.title}</h3>
<p className="wwg-card-desc">{f.desc}</p> <p className="wwg-card-desc">{f.desc}</p>
</div> </div>
@@ -168,6 +819,7 @@ export default function WingsWhyGrid() {
</StaggerChildren> </StaggerChildren>
</div> </div>
</div> </div>
</div>
</section> </section>
</> </>
); );