Pages Hard
Travel Experience
Immersive travel booking experience with a scroll-driven 3D airplane flight path and animated destination reveals.
Open in Lab
MCP
three.js gsap lenis scrolltrigger scrambletext
Targets: JS HTML
Code
:root {
--page-bg: #0b1628;
--page-surface: #101e36;
--page-border: #1a3050;
--page-text: #e8f0ff;
--page-muted: #7a8da8;
--page-accent: #00c2ff;
--page-accent-warm: #ff8c42;
--page-cloud: #2a4068;
--page-sky-top: #0a1020;
--page-sky-mid: #1a3060;
}
*,
*::before,
*::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: var(--page-bg);
color: var(--page-text);
font-family: "SF Pro Display", "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.6;
overflow-x: hidden;
}
/* โโ Canvas Container โโ */
#canvas-container {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
}
/* โโ Sections โโ */
.section {
position: relative;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6rem 2rem;
z-index: 1;
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
HERO
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.hero-section {
position: relative;
overflow: hidden;
background: linear-gradient(
180deg,
var(--page-sky-top) 0%,
var(--page-sky-mid) 40%,
var(--page-bg) 100%
);
}
/* Decorative cloud layers */
.hero-clouds {
position: absolute;
inset: 0;
pointer-events: none;
}
.hero-clouds::before {
content: "";
position: absolute;
top: 20%;
left: -10%;
width: 55%;
height: 30%;
background: radial-gradient(ellipse at center, rgba(42, 64, 104, 0.25) 0%, transparent 70%);
border-radius: 50%;
filter: blur(40px);
}
.hero-clouds::after {
content: "";
position: absolute;
top: 40%;
right: -5%;
width: 45%;
height: 25%;
background: radial-gradient(ellipse at center, rgba(42, 64, 104, 0.2) 0%, transparent 70%);
border-radius: 50%;
filter: blur(50px);
}
.hero-content {
position: relative;
z-index: 1;
text-align: center;
}
.hero-title {
font-size: clamp(2.8rem, 10vw, 7rem);
font-weight: 700;
color: #ffffff;
line-height: 1;
letter-spacing: -0.02em;
text-shadow: 0 4px 40px rgba(0, 0, 0, 0.3);
margin-bottom: 1.5rem;
}
.hero-subtitle {
font-size: clamp(0.9rem, 2.5vw, 1.25rem);
color: var(--page-muted);
font-weight: 400;
letter-spacing: 0.04em;
opacity: 0;
min-height: 1.8em;
}
.scroll-indicator {
margin-top: 3rem;
color: var(--page-accent);
opacity: 0;
animation: scroll-float 2.5s ease-in-out infinite;
}
@keyframes scroll-float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(8px);
}
}
.reduced-motion .scroll-indicator {
animation: none;
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
FLIGHT TRACK (scroll spacer)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.flight-track {
position: relative;
height: 600vh;
z-index: 0;
}
.flight-section {
height: 150vh;
position: relative;
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
DESTINATION CARDS (fixed overlay)
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.dest-card {
position: fixed;
right: 4rem;
top: 50%;
transform: translateY(-50%);
z-index: 2;
width: 280px;
background: rgba(16, 30, 54, 0.92);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid var(--page-border);
border-radius: 16px;
padding: 0;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.dest-card.visible {
pointer-events: auto;
}
.dest-thumb {
width: 100%;
height: 140px;
position: relative;
overflow: hidden;
}
/* โโ CSS Landscape Thumbnails โโ */
/* Tokyo: Neon cityscape */
.thumb-tokyo {
background: linear-gradient(
180deg,
#1a1030 0%,
#2a1848 30%,
#3a2060 50%,
#1a1830 80%,
#0a0818 100%
);
}
.thumb-tokyo::before {
content: "";
position: absolute;
bottom: 0;
left: 5%;
width: 90%;
height: 65%;
background: #0e0a1a;
clip-path: polygon(
0% 100%,
0% 45%,
3% 45%,
3% 30%,
7% 30%,
7% 15%,
10% 15%,
10% 25%,
14% 25%,
14% 10%,
18% 10%,
18% 35%,
22% 35%,
22% 20%,
26% 20%,
26% 5%,
30% 5%,
30% 30%,
34% 30%,
34% 40%,
38% 40%,
38% 12%,
42% 12%,
42% 28%,
46% 28%,
46% 18%,
50% 18%,
50% 8%,
54% 8%,
54% 32%,
58% 32%,
58% 22%,
62% 22%,
62% 38%,
66% 38%,
66% 14%,
70% 14%,
70% 25%,
74% 25%,
74% 35%,
78% 35%,
78% 20%,
82% 20%,
82% 30%,
86% 30%,
86% 42%,
90% 42%,
90% 15%,
94% 15%,
94% 38%,
98% 38%,
98% 50%,
100% 50%,
100% 100%
);
}
.thumb-tokyo::after {
content: "";
position: absolute;
bottom: 0;
left: 5%;
width: 90%;
height: 55%;
background: radial-gradient(circle 1.5px at 8% 30%, #ff40d6 0%, transparent 100%),
radial-gradient(circle 1.5px at 15% 50%, #00c2ff 0%, transparent 100%),
radial-gradient(circle 1.5px at 22% 20%, #ffcc66 0%, transparent 100%),
radial-gradient(circle 1px at 30% 60%, #ff40d6 0%, transparent 100%),
radial-gradient(circle 1.5px at 38% 35%, #00c2ff 0%, transparent 100%),
radial-gradient(circle 1px at 45% 15%, #ffcc66 0%, transparent 100%),
radial-gradient(circle 1.5px at 52% 45%, #ff40d6 0%, transparent 100%),
radial-gradient(circle 1px at 60% 25%, #00c2ff 0%, transparent 100%),
radial-gradient(circle 1.5px at 68% 55%, #ffcc66 0%, transparent 100%),
radial-gradient(circle 1px at 75% 30%, #ff40d6 0%, transparent 100%),
radial-gradient(circle 1.5px at 82% 40%, #00c2ff 0%, transparent 100%),
radial-gradient(circle 1px at 90% 20%, #ffcc66 0%, transparent 100%);
}
/* Santorini: White domes + blue sea */
.thumb-santorini {
background: linear-gradient(180deg, #4a8fcc 0%, #6bb3e8 35%, #2a6fa0 55%, #1a5580 100%);
}
.thumb-santorini::before {
content: "";
position: absolute;
bottom: 15%;
left: 10%;
width: 80%;
height: 55%;
background: #f0f0f0;
clip-path: polygon(
0% 100%,
0% 60%,
5% 55%,
10% 50%,
12% 30%,
16% 30%,
18% 50%,
22% 48%,
25% 25%,
30% 20%,
35% 25%,
38% 45%,
42% 42%,
45% 15%,
50% 10%,
55% 15%,
58% 40%,
62% 38%,
65% 30%,
70% 28%,
75% 35%,
78% 50%,
82% 48%,
85% 40%,
90% 45%,
95% 55%,
100% 60%,
100% 100%
);
}
.thumb-santorini::after {
content: "";
position: absolute;
bottom: 38%;
left: 25%;
width: 50%;
height: 20%;
background: radial-gradient(
ellipse 8px 10px at 20% 50%,
#3a7fc0 0%,
#3a7fc0 85%,
transparent 100%
), radial-gradient(ellipse 8px 10px at 50% 40%, #3a7fc0 0%, #3a7fc0 85%, transparent 100%),
radial-gradient(ellipse 8px 10px at 80% 55%, #3a7fc0 0%, #3a7fc0 85%, transparent 100%);
}
/* Machu Picchu: Misty mountain ruins */
.thumb-machu {
background: linear-gradient(
180deg,
#e8e0d0 0%,
#c8c0a8 15%,
#6a8c4a 40%,
#4a7030 55%,
#3a5a28 70%,
#2a4020 100%
);
}
.thumb-machu::before {
content: "";
position: absolute;
bottom: 10%;
left: 0;
width: 100%;
height: 70%;
background: linear-gradient(160deg, #5a7840 0%, #3a5828 50%, #2a4520 100%);
clip-path: polygon(
0% 100%,
0% 70%,
10% 55%,
20% 40%,
30% 20%,
40% 5%,
50% 10%,
55% 25%,
60% 15%,
70% 0%,
80% 18%,
85% 30%,
90% 22%,
95% 40%,
100% 50%,
100% 100%
);
}
.thumb-machu::after {
content: "";
position: absolute;
bottom: 25%;
left: 20%;
width: 60%;
height: 30%;
background: #8a7858;
clip-path: polygon(
5% 100%,
5% 70%,
10% 65%,
15% 60%,
15% 40%,
20% 40%,
20% 55%,
25% 55%,
25% 35%,
30% 35%,
30% 50%,
40% 50%,
40% 30%,
45% 30%,
45% 45%,
55% 45%,
55% 25%,
60% 25%,
60% 40%,
70% 40%,
70% 55%,
75% 55%,
75% 35%,
80% 35%,
80% 60%,
85% 65%,
90% 70%,
95% 75%,
95% 100%
);
opacity: 0.6;
}
/* Reykjavik: Aurora + glacier */
.thumb-reykjavik {
background: linear-gradient(
180deg,
#0a0a20 0%,
#0a1530 25%,
#102040 50%,
#1a3050 70%,
#d0d8e8 85%,
#e8f0ff 100%
);
}
.thumb-reykjavik::before {
content: "";
position: absolute;
top: 10%;
left: 0;
width: 100%;
height: 40%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(0, 194, 255, 0.15) 20%,
rgba(80, 255, 120, 0.2) 40%,
rgba(0, 194, 255, 0.18) 60%,
rgba(80, 255, 120, 0.12) 80%,
transparent 100%
);
filter: blur(8px);
border-radius: 50%;
transform: rotate(-5deg);
}
.thumb-reykjavik::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 35%;
background: linear-gradient(180deg, #b8c8d8 0%, #d8e4f0 50%, #e8f0ff 100%);
clip-path: polygon(
0% 100%,
0% 50%,
8% 35%,
15% 25%,
22% 40%,
30% 20%,
38% 30%,
45% 15%,
52% 28%,
58% 10%,
65% 22%,
72% 35%,
78% 18%,
85% 30%,
92% 40%,
100% 25%,
100% 100%
);
}
.dest-name {
font-size: 1.5rem;
font-weight: 700;
color: var(--page-text);
padding: 1.25rem 1.5rem 0.25rem;
letter-spacing: -0.01em;
}
.dest-country {
display: block;
font-size: 0.75rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--page-accent);
padding: 0 1.5rem;
}
.dest-tagline {
font-size: 0.9rem;
color: var(--page-muted);
padding: 0.75rem 1.5rem 1.5rem;
line-height: 1.5;
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
DESTINATIONS GRID
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.grid-section {
background: var(--page-bg);
padding: 8rem 2rem;
}
.grid-heading {
font-size: clamp(2rem, 6vw, 3.5rem);
font-weight: 700;
text-align: center;
margin-bottom: 4rem;
color: var(--page-text);
letter-spacing: -0.02em;
}
.destinations-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 2rem;
max-width: 960px;
width: 100%;
margin: 0 auto;
}
.grid-card {
background: var(--page-surface);
border: 1px solid var(--page-border);
border-radius: 16px;
overflow: hidden;
opacity: 0;
transform: translateY(40px) scale(0.95);
transition: box-shadow 0.3s ease, transform 0.3s ease;
cursor: default;
}
.grid-card:hover {
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.3);
transform: translateY(-4px) scale(1);
}
.reduced-motion .grid-card {
opacity: 1;
transform: none;
}
.reduced-motion .grid-card:hover {
transform: none;
}
.grid-thumb {
width: 100%;
height: 180px;
position: relative;
overflow: hidden;
}
/* Grid uses same landscape compositions as dest-card thumbs */
.grid-tokyo {
background: linear-gradient(
180deg,
#1a1030 0%,
#2a1848 30%,
#3a2060 50%,
#1a1830 80%,
#0a0818 100%
);
}
.grid-tokyo::before {
content: "";
position: absolute;
bottom: 0;
left: 5%;
width: 90%;
height: 65%;
background: #0e0a1a;
clip-path: polygon(
0% 100%,
0% 45%,
3% 45%,
3% 30%,
7% 30%,
7% 15%,
10% 15%,
10% 25%,
14% 25%,
14% 10%,
18% 10%,
18% 35%,
22% 35%,
22% 20%,
26% 20%,
26% 5%,
30% 5%,
30% 30%,
34% 30%,
34% 40%,
38% 40%,
38% 12%,
42% 12%,
42% 28%,
46% 28%,
46% 18%,
50% 18%,
50% 8%,
54% 8%,
54% 32%,
58% 32%,
58% 22%,
62% 22%,
62% 38%,
66% 38%,
66% 14%,
70% 14%,
70% 25%,
74% 25%,
74% 35%,
78% 35%,
78% 20%,
82% 20%,
82% 30%,
86% 30%,
86% 42%,
90% 42%,
90% 15%,
94% 15%,
94% 38%,
98% 38%,
98% 50%,
100% 50%,
100% 100%
);
}
.grid-tokyo::after {
content: "";
position: absolute;
bottom: 0;
left: 5%;
width: 90%;
height: 55%;
background: radial-gradient(circle 2px at 12% 35%, #ff40d6 0%, transparent 100%),
radial-gradient(circle 2px at 25% 50%, #00c2ff 0%, transparent 100%),
radial-gradient(circle 2px at 38% 25%, #ffcc66 0%, transparent 100%),
radial-gradient(circle 2px at 52% 55%, #ff40d6 0%, transparent 100%),
radial-gradient(circle 2px at 65% 30%, #00c2ff 0%, transparent 100%),
radial-gradient(circle 2px at 78% 45%, #ffcc66 0%, transparent 100%),
radial-gradient(circle 2px at 88% 20%, #ff40d6 0%, transparent 100%);
}
.grid-santorini {
background: linear-gradient(180deg, #4a8fcc 0%, #6bb3e8 35%, #2a6fa0 55%, #1a5580 100%);
}
.grid-santorini::before {
content: "";
position: absolute;
bottom: 15%;
left: 10%;
width: 80%;
height: 55%;
background: #f0f0f0;
clip-path: polygon(
0% 100%,
0% 60%,
5% 55%,
10% 50%,
12% 30%,
16% 30%,
18% 50%,
22% 48%,
25% 25%,
30% 20%,
35% 25%,
38% 45%,
42% 42%,
45% 15%,
50% 10%,
55% 15%,
58% 40%,
62% 38%,
65% 30%,
70% 28%,
75% 35%,
78% 50%,
82% 48%,
85% 40%,
90% 45%,
95% 55%,
100% 60%,
100% 100%
);
}
.grid-machu {
background: linear-gradient(
180deg,
#e8e0d0 0%,
#c8c0a8 15%,
#6a8c4a 40%,
#4a7030 55%,
#3a5a28 70%,
#2a4020 100%
);
}
.grid-machu::before {
content: "";
position: absolute;
bottom: 10%;
left: 0;
width: 100%;
height: 70%;
background: linear-gradient(160deg, #5a7840 0%, #3a5828 50%, #2a4520 100%);
clip-path: polygon(
0% 100%,
0% 70%,
10% 55%,
20% 40%,
30% 20%,
40% 5%,
50% 10%,
55% 25%,
60% 15%,
70% 0%,
80% 18%,
85% 30%,
90% 22%,
95% 40%,
100% 50%,
100% 100%
);
}
.grid-reykjavik {
background: linear-gradient(
180deg,
#0a0a20 0%,
#0a1530 25%,
#102040 50%,
#1a3050 70%,
#d0d8e8 85%,
#e8f0ff 100%
);
}
.grid-reykjavik::before {
content: "";
position: absolute;
top: 10%;
left: 0;
width: 100%;
height: 40%;
background: linear-gradient(
90deg,
transparent 0%,
rgba(0, 194, 255, 0.15) 20%,
rgba(80, 255, 120, 0.2) 40%,
rgba(0, 194, 255, 0.18) 60%,
rgba(80, 255, 120, 0.12) 80%,
transparent 100%
);
filter: blur(8px);
border-radius: 50%;
}
.grid-reykjavik::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 35%;
background: linear-gradient(180deg, #b8c8d8 0%, #d8e4f0 50%, #e8f0ff 100%);
clip-path: polygon(
0% 100%,
0% 50%,
8% 35%,
15% 25%,
22% 40%,
30% 20%,
38% 30%,
45% 15%,
52% 28%,
58% 10%,
65% 22%,
72% 35%,
78% 18%,
85% 30%,
92% 40%,
100% 25%,
100% 100%
);
}
.grid-card-body {
padding: 1.5rem;
}
.grid-card-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--page-text);
margin-bottom: 0.5rem;
letter-spacing: -0.01em;
}
.grid-card-desc {
font-size: 0.9rem;
color: var(--page-muted);
line-height: 1.6;
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
CTA
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.cta-section {
position: relative;
overflow: hidden;
background: linear-gradient(180deg, var(--page-bg) 0%, var(--page-sky-top) 100%);
}
.cta-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
}
.cta-orb-1 {
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(0, 194, 255, 0.15) 0%, transparent 70%);
top: 10%;
left: -5%;
}
.cta-orb-2 {
width: 350px;
height: 350px;
background: radial-gradient(circle, rgba(255, 140, 66, 0.12) 0%, transparent 70%);
bottom: 5%;
right: -5%;
}
.cta-content {
position: relative;
z-index: 1;
text-align: center;
}
.cta-heading {
font-size: clamp(2.5rem, 8vw, 5rem);
font-weight: 700;
color: var(--page-text);
line-height: 1.1;
letter-spacing: -0.02em;
margin-bottom: 1rem;
}
.cta-subtitle {
font-size: clamp(1rem, 2.5vw, 1.3rem);
color: var(--page-muted);
margin-bottom: 2.5rem;
opacity: 0;
}
.cta-buttons {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
.btn {
padding: 0.85rem 2rem;
border-radius: 10px;
font-family: inherit;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
opacity: 0;
transform: translateY(15px);
}
.btn:hover {
transform: translateY(-2px);
}
.reduced-motion .btn {
opacity: 1;
transform: none;
}
.reduced-motion .btn:hover {
transform: none;
}
.btn-primary {
background: var(--page-accent);
color: var(--page-bg);
border: none;
box-shadow: 0 4px 20px rgba(0, 194, 255, 0.3);
}
.btn-primary:hover {
box-shadow: 0 8px 32px rgba(0, 194, 255, 0.4);
}
.btn-secondary {
background: transparent;
color: var(--page-accent);
border: 1.5px solid var(--page-accent);
}
.btn-secondary:hover {
background: rgba(0, 194, 255, 0.08);
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
REDUCED MOTION
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
.reduced-motion .hero-subtitle {
opacity: 1;
}
.reduced-motion .scroll-indicator {
opacity: 1;
}
.reduced-motion .dest-card {
opacity: 0;
}
.reduced-motion .cta-subtitle {
opacity: 1;
}
/* โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
RESPONSIVE
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ */
@media (max-width: 768px) {
.section {
padding: 4rem 1.25rem;
}
.dest-card {
right: auto;
left: 1rem;
right: 1rem;
top: auto;
bottom: 2rem;
transform: none;
width: auto;
max-width: 360px;
margin: 0 auto;
}
.dest-thumb {
height: 100px;
}
.destinations-grid {
grid-template-columns: 1fr;
}
.flight-track {
height: 400vh;
}
.flight-section {
height: 100vh;
}
.grid-section {
padding: 5rem 1.25rem;
}
.cta-orb-1 {
width: 250px;
height: 250px;
}
.cta-orb-2 {
width: 200px;
height: 200px;
}
}
@media (max-width: 480px) {
.hero-title {
font-size: clamp(2rem, 12vw, 3.5rem);
}
.dest-card {
left: 0.75rem;
right: 0.75rem;
bottom: 1.5rem;
}
.grid-heading {
font-size: 1.8rem;
}
.cta-heading {
font-size: 2rem;
}
}if (!window.MotionPreference) {
const __mql = window.matchMedia("(prefers-reduced-motion: reduce)");
const __listeners = new Set();
const MotionPreference = {
prefersReducedMotion() {
return __mql.matches;
},
setOverride(value) {
const reduced = Boolean(value);
document.documentElement.classList.toggle("reduced-motion", reduced);
window.dispatchEvent(new CustomEvent("motion-preference", { detail: { reduced } }));
for (const listener of __listeners) {
try {
listener({ reduced, override: reduced, systemReduced: __mql.matches });
} catch {}
}
},
onChange(listener) {
__listeners.add(listener);
try {
listener({
reduced: __mql.matches,
override: null,
systemReduced: __mql.matches,
});
} catch {}
return () => __listeners.delete(listener);
},
getState() {
return { reduced: __mql.matches, override: null, systemReduced: __mql.matches };
},
};
window.MotionPreference = MotionPreference;
}
function prefersReducedMotion() {
return window.MotionPreference.prefersReducedMotion();
}
function initDemoShell() {
// No-op shim in imported standalone snippets.
}
import * as THREE from "three";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import { SplitText } from "gsap/SplitText";
import { ScrambleTextPlugin } from "gsap/ScrambleTextPlugin";
import Lenis from "lenis";
gsap.registerPlugin(ScrollTrigger, SplitText, ScrambleTextPlugin);
initDemoShell({
title: "Travel Experience",
category: "pages",
tech: ["three.js", "gsap", "lenis", "scrolltrigger"],
});
const lenis = new Lenis({ lerp: 0.1, smoothWheel: true });
lenis.on("scroll", ScrollTrigger.update);
gsap.ticker.add((time) => lenis.raf(time * 1000));
gsap.ticker.lagSmoothing(0);
let reduced = prefersReducedMotion();
if (reduced) document.documentElement.classList.add("reduced-motion");
window.addEventListener("motion-preference", (e) => {
reduced = e.detail.reduced;
document.documentElement.classList.toggle("reduced-motion", reduced);
ScrollTrigger.refresh();
});
const dur = (d) => (reduced ? 0 : d);
// =============================================================================
// THREE.JS SETUP
// =============================================================================
const container = document.getElementById("canvas-container");
const scene = new THREE.Scene();
scene.background = new THREE.Color("#0b1628");
scene.fog = new THREE.FogExp2(0x0b1628, 0.015);
const camera = new THREE.PerspectiveCamera(50, window.innerWidth / window.innerHeight, 0.1, 200);
camera.position.set(0, 3, 12);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.2;
container.appendChild(renderer.domElement);
// -- Lighting --
const ambient = new THREE.AmbientLight(0x1a3060, 0.6);
scene.add(ambient);
const keyLight = new THREE.DirectionalLight(0xffffff, 2.0);
keyLight.position.set(5, 8, 5);
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0x00c2ff, 0.8);
fillLight.position.set(-4, 2, -3);
scene.add(fillLight);
const rimLight = new THREE.DirectionalLight(0xff8c42, 1.0);
rimLight.position.set(0, -2, -6);
scene.add(rimLight);
// =============================================================================
// AIRPLANE GEOMETRY (Procedural Three.js Group)
// =============================================================================
const airplaneMat = new THREE.MeshPhysicalMaterial({
color: 0xe8f0ff,
metalness: 0.6,
roughness: 0.2,
clearcoat: 0.8,
clearcoatRoughness: 0.1,
});
// -- Fuselage --
const fuselageGeo = new THREE.CapsuleGeometry(0.15, 1.2, 16, 24);
const fuselage = new THREE.Mesh(fuselageGeo, airplaneMat);
fuselage.rotation.z = Math.PI / 2;
// -- Main Wings --
const mainWingGeo = new THREE.BoxGeometry(2.4, 0.025, 0.45, 24, 1, 8);
const mainWing = new THREE.Mesh(mainWingGeo, airplaneMat);
mainWing.position.set(0.05, -0.02, 0);
// Apply sweep and taper to wing vertices
const wingPos = mainWingGeo.attributes.position;
for (let i = 0; i < wingPos.count; i++) {
const x = wingPos.getX(i);
let z = wingPos.getZ(i);
// Sweep: shift Z backward proportional to distance from center
z -= Math.abs(x) * 0.15;
// Taper: reduce chord width at wingtips
const taperFactor = 1.0 - (Math.abs(x) / 1.2) * 0.5;
z *= taperFactor;
wingPos.setZ(i, z);
}
wingPos.needsUpdate = true;
mainWingGeo.computeVertexNormals();
// -- Tail Horizontal Stabilizer --
const tailWingGeo = new THREE.BoxGeometry(0.8, 0.02, 0.2, 12, 1, 4);
const tailWing = new THREE.Mesh(tailWingGeo, airplaneMat);
tailWing.position.set(-0.7, 0.05, 0);
// Apply sweep and taper to tail
const tailPos = tailWingGeo.attributes.position;
for (let i = 0; i < tailPos.count; i++) {
const x = tailPos.getX(i);
let z = tailPos.getZ(i);
z -= Math.abs(x) * 0.1;
const taperFactor = 1.0 - (Math.abs(x) / 0.4) * 0.3;
z *= taperFactor;
tailPos.setZ(i, z);
}
tailPos.needsUpdate = true;
tailWingGeo.computeVertexNormals();
// -- Vertical Fin --
const finGeo = new THREE.BoxGeometry(0.02, 0.35, 0.3, 1, 8, 6);
const fin = new THREE.Mesh(finGeo, airplaneMat);
fin.position.set(-0.7, 0.2, 0);
// Taper fin: narrower at top, sweep back at top
const finPos = finGeo.attributes.position;
for (let i = 0; i < finPos.count; i++) {
const y = finPos.getY(i);
let z = finPos.getZ(i);
// Narrower at top
const heightFactor = (y + 0.175) / 0.35; // 0 at bottom, 1 at top
z *= 1.0 - heightFactor * 0.5;
// Sweep back at top
z -= heightFactor * 0.08;
finPos.setZ(i, z);
}
finPos.needsUpdate = true;
finGeo.computeVertexNormals();
// -- Engine Nacelles --
const engineGeo = new THREE.CylinderGeometry(0.06, 0.08, 0.25, 12);
const engineLeft = new THREE.Mesh(engineGeo, airplaneMat);
engineLeft.rotation.z = Math.PI / 2;
engineLeft.position.set(0.1, -0.05, -0.5);
const engineRight = new THREE.Mesh(engineGeo, airplaneMat);
engineRight.rotation.z = Math.PI / 2;
engineRight.position.set(0.1, -0.05, 0.5);
// -- Assembly --
const airplane = new THREE.Group();
airplane.add(fuselage, mainWing, tailWing, fin, engineLeft, engineRight);
airplane.scale.setScalar(0.8);
scene.add(airplane);
// =============================================================================
// CLOUD PARTICLES
// =============================================================================
const cloudCount = 300;
const cloudGeo = new THREE.BufferGeometry();
const cloudPositions = new Float32Array(cloudCount * 3);
for (let i = 0; i < cloudCount; i++) {
cloudPositions[i * 3] = (Math.random() - 0.5) * 80;
cloudPositions[i * 3 + 1] = Math.random() * 20 - 5;
cloudPositions[i * 3 + 2] = (Math.random() - 0.5) * 100 - 20;
}
cloudGeo.setAttribute("position", new THREE.BufferAttribute(cloudPositions, 3));
const cloudMat = new THREE.PointsMaterial({
color: 0x4a6a90,
size: 2.5,
transparent: true,
opacity: 0.3,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const clouds = new THREE.Points(cloudGeo, cloudMat);
scene.add(clouds);
// =============================================================================
// FLIGHT PATH
// =============================================================================
const flightPath = new THREE.CatmullRomCurve3(
[
new THREE.Vector3(0, 0, 8),
new THREE.Vector3(3, 2, 4),
new THREE.Vector3(0, 4, 0),
new THREE.Vector3(-4, 3, -4),
new THREE.Vector3(-2, 5, -8),
new THREE.Vector3(2, 4, -12),
new THREE.Vector3(0, 6, -16),
],
false,
"catmullrom",
0.3
);
// =============================================================================
// SCROLL-DRIVEN FLIGHT ANIMATION
// =============================================================================
const scrollState = { progress: 0 };
gsap.to(scrollState, {
progress: 1,
ease: "none",
scrollTrigger: {
trigger: ".flight-track",
start: "top top",
end: "bottom bottom",
scrub: 1.5,
},
});
// Static position for reduced motion
if (reduced) {
const finalPos = flightPath.getPoint(0.5);
airplane.position.copy(finalPos);
camera.position.set(finalPos.x + 2, finalPos.y + 2, finalPos.z + 4);
camera.lookAt(finalPos);
}
// =============================================================================
// HERO ENTRANCE
// =============================================================================
const heroTitle = document.querySelector(".hero-title");
const heroSubtitle = document.getElementById("hero-subtitle");
// SplitText for hero title
const titleSplit = new SplitText(heroTitle, { type: "chars", charsClass: "char" });
gsap.set(titleSplit.chars, {
opacity: 0,
y: reduced ? 0 : 60,
rotateX: reduced ? 0 : -90,
});
const heroTl = gsap.timeline({ delay: 0.3 });
heroTl
.to(titleSplit.chars, {
opacity: 1,
y: 0,
rotateX: 0,
duration: dur(0.6),
ease: "back.out(1.4)",
stagger: { each: 0.03 },
})
.to(
heroSubtitle,
{
opacity: 1,
duration: dur(0.1),
onComplete: () => {
if (!reduced) {
gsap.to(heroSubtitle, {
duration: dur(1.2),
scrambleText: {
text: "Scroll to take flight",
chars: "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
speed: 0.4,
},
});
} else {
heroSubtitle.textContent = "Scroll to take flight";
}
},
},
0.5
)
.to(
"#scroll-indicator",
{
opacity: 1,
duration: dur(0.6),
ease: "expo.out",
},
1.0
);
// =============================================================================
// DESTINATION CARDS (ScrollTrigger per flight-section)
// =============================================================================
const destCards = document.querySelectorAll(".dest-card");
const flightSections = document.querySelectorAll(".flight-section");
flightSections.forEach((section, i) => {
const card = destCards[i];
if (!card) return;
if (reduced) {
// In reduced motion, cards are simply visible when in viewport
gsap.set(card, { opacity: 1, x: 0 });
card.classList.add("visible");
return;
}
// Card entrance
ScrollTrigger.create({
trigger: section,
start: "top 60%",
end: "bottom 40%",
onEnter: () => {
card.classList.add("visible");
gsap.to(card, {
opacity: 1,
x: 0,
duration: dur(0.8),
ease: "expo.out",
});
},
onLeave: () => {
gsap.to(card, {
opacity: 0,
x: -30,
duration: dur(0.4),
ease: "power2.in",
onComplete: () => card.classList.remove("visible"),
});
},
onEnterBack: () => {
card.classList.add("visible");
gsap.to(card, {
opacity: 1,
x: 0,
duration: dur(0.8),
ease: "expo.out",
});
},
onLeaveBack: () => {
gsap.to(card, {
opacity: 0,
x: 60,
duration: dur(0.4),
ease: "power2.in",
onComplete: () => card.classList.remove("visible"),
});
},
});
// Set initial state for cards (off-screen right)
gsap.set(card, { opacity: 0, x: 60 });
});
// =============================================================================
// DESTINATIONS GRID
// =============================================================================
const gridHeading = document.querySelector(".grid-heading");
if (gridHeading) {
const gridSplit = new SplitText(gridHeading, { type: "words", wordsClass: "word" });
gsap.set(gridSplit.words, {
opacity: 0,
y: reduced ? 0 : 30,
});
gsap.to(gridSplit.words, {
opacity: 1,
y: 0,
duration: dur(0.6),
ease: "back.out(1.2)",
stagger: { each: 0.08 },
scrollTrigger: {
trigger: ".grid-section",
start: "top 75%",
toggleActions: "play none none reverse",
},
});
}
// Grid cards staggered entrance
gsap.to(".grid-card", {
opacity: 1,
y: 0,
scale: 1,
duration: dur(0.7),
ease: "expo.out",
stagger: {
each: 0.1,
from: "center",
grid: [2, 2],
},
scrollTrigger: {
trigger: ".destinations-grid",
start: "top 80%",
toggleActions: "play none none reverse",
},
});
// =============================================================================
// CTA SECTION
// =============================================================================
const ctaHeading = document.querySelector(".cta-heading");
if (ctaHeading) {
const ctaSplit = new SplitText(ctaHeading, { type: "chars", charsClass: "char" });
gsap.set(ctaSplit.chars, {
opacity: 0,
y: reduced ? 0 : 40,
rotateX: reduced ? 0 : -60,
});
if (reduced) {
gsap.set(ctaSplit.chars, { opacity: 1 });
gsap.set(".cta-subtitle", { opacity: 1 });
gsap.set(".btn", { opacity: 1, y: 0 });
} else {
ScrollTrigger.create({
trigger: ".cta-section",
start: "top 60%",
once: true,
onEnter: () => {
const ctaTl = gsap.timeline();
ctaTl
.to(ctaSplit.chars, {
opacity: 1,
y: 0,
rotateX: 0,
duration: dur(0.6),
ease: "back.out(1.4)",
stagger: { each: 0.02 },
})
.to(
".cta-subtitle",
{
opacity: 1,
duration: dur(0.6),
ease: "expo.out",
},
"-=0.3"
)
.to(
".btn",
{
opacity: 1,
y: 0,
duration: dur(0.5),
ease: "back.out(1.7)",
stagger: 0.1,
},
"-=0.2"
);
},
});
}
}
// =============================================================================
// THREE.JS ANIMATION LOOP
// =============================================================================
// Reusable vectors to avoid allocation in the loop
const _lookTarget = new THREE.Vector3();
const _camTarget = new THREE.Vector3();
function animate() {
requestAnimationFrame(animate);
if (reduced) {
renderer.render(scene, camera);
return;
}
const t = Math.max(0, Math.min(1, scrollState.progress));
// Position airplane on curve
const pos = flightPath.getPoint(t);
airplane.position.copy(pos);
// Orient airplane to face direction of travel
const lookAheadT = Math.min(t + 0.01, 1);
_lookTarget.copy(flightPath.getPoint(lookAheadT));
airplane.lookAt(_lookTarget);
// Compensate for fuselage being along Y axis (from CapsuleGeometry rotation)
airplane.rotateY(Math.PI / 2);
// Gentle wing bank based on curve curvature
airplane.rotateZ(Math.sin(t * Math.PI * 4) * 0.1);
// Camera follows behind and above
const behindT = Math.max(t - 0.05, 0);
const camPos = flightPath.getPoint(behindT);
_camTarget.set(camPos.x + 2, camPos.y + 2, camPos.z + 4);
camera.position.lerp(_camTarget, 0.05);
camera.lookAt(pos);
renderer.render(scene, camera);
}
animate();
// =============================================================================
// RESIZE
// =============================================================================
window.addEventListener("resize", () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// =============================================================================
// CLEANUP
// =============================================================================
window.addEventListener("beforeunload", () => {
airplane.traverse((child) => {
if (child.geometry) child.geometry.dispose();
});
airplaneMat.dispose();
cloudGeo.dispose();
cloudMat.dispose();
renderer.dispose();
});<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Travel Experience โ stealthisdesign</title>
<link rel="stylesheet" href="style.css">
<script type="importmap">{"imports":{"gsap":"https://esm.sh/gsap@3.13.0","gsap/ScrollTrigger":"https://esm.sh/gsap@3.13.0/ScrollTrigger","gsap/SplitText":"https://esm.sh/gsap@3.13.0/SplitText","gsap/Flip":"https://esm.sh/gsap@3.13.0/Flip","gsap/ScrambleTextPlugin":"https://esm.sh/gsap@3.13.0/ScrambleTextPlugin","gsap/TextPlugin":"https://esm.sh/gsap@3.13.0/TextPlugin","gsap/all":"https://esm.sh/gsap@3.13.0/all","gsap/":"https://esm.sh/gsap@3.13.0/","lenis":"https://esm.sh/lenis@1.1.13/dist/lenis.mjs","three":"https://esm.sh/three@0.171.0","three/addons/":"https://esm.sh/three@0.171.0/examples/jsm/"}}</script>
<style>html.lenis,
html.lenis body {
height: auto;
}
.lenis:not(.lenis-autoToggle).lenis-stopped {
overflow: clip;
}
.lenis [data-lenis-prevent],
.lenis [data-lenis-prevent-wheel],
.lenis [data-lenis-prevent-touch] {
overscroll-behavior: contain;
}
.lenis.lenis-smooth iframe {
pointer-events: none;
}
.lenis.lenis-autoToggle {
transition-property: overflow;
transition-duration: 1ms;
transition-behavior: allow-discrete;
}</style>
</head>
<body>
<!-- Three.js canvas (fixed behind everything) -->
<div id="canvas-container" aria-hidden="true"></div>
<!-- Hero -->
<section class="section hero-section" id="hero">
<div class="hero-clouds" aria-hidden="true"></div>
<div class="hero-content">
<h1 class="hero-title">Explore the World</h1>
<p class="hero-subtitle" id="hero-subtitle">Scroll to take flight</p>
<div class="scroll-indicator" id="scroll-indicator">
<svg width="20" height="32" viewBox="0 0 20 32" fill="none">
<path d="M10 0v28M2 20l8 8 8-8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
</div>
</section>
<!-- Flight Path (scroll spacer) -->
<div class="flight-track">
<div class="flight-section" data-dest="0"></div>
<div class="flight-section" data-dest="1"></div>
<div class="flight-section" data-dest="2"></div>
<div class="flight-section" data-dest="3"></div>
</div>
<!-- Fixed overlay destination cards -->
<div class="dest-card" id="dest-0">
<div class="dest-thumb thumb-tokyo" aria-hidden="true"></div>
<h3 class="dest-name">Tokyo</h3>
<span class="dest-country">Japan</span>
<p class="dest-tagline">Where tradition meets tomorrow</p>
</div>
<div class="dest-card" id="dest-1">
<div class="dest-thumb thumb-santorini" aria-hidden="true"></div>
<h3 class="dest-name">Santorini</h3>
<span class="dest-country">Greece</span>
<p class="dest-tagline">White walls against endless blue</p>
</div>
<div class="dest-card" id="dest-2">
<div class="dest-thumb thumb-machu" aria-hidden="true"></div>
<h3 class="dest-name">Machu Picchu</h3>
<span class="dest-country">Peru</span>
<p class="dest-tagline">Ancient ruins above the clouds</p>
</div>
<div class="dest-card" id="dest-3">
<div class="dest-thumb thumb-reykjavik" aria-hidden="true"></div>
<h3 class="dest-name">Reykjavik</h3>
<span class="dest-country">Iceland</span>
<p class="dest-tagline">Fire and ice at the edge of the world</p>
</div>
<!-- Destinations Grid -->
<section class="section grid-section" id="destinations">
<h2 class="grid-heading">Your Next Adventure</h2>
<div class="destinations-grid">
<div class="grid-card">
<div class="grid-thumb grid-tokyo" aria-hidden="true"></div>
<div class="grid-card-body">
<h3 class="grid-card-title">Tokyo, Japan</h3>
<p class="grid-card-desc">Neon-lit streets, ancient temples, and culinary mastery converge in this metropolis where every corner tells a different story.</p>
</div>
</div>
<div class="grid-card">
<div class="grid-thumb grid-santorini" aria-hidden="true"></div>
<div class="grid-card-body">
<h3 class="grid-card-title">Santorini, Greece</h3>
<p class="grid-card-desc">Volcanic cliffs draped in whitewashed villages, where sunsets paint the Aegean in impossible shades of gold and crimson.</p>
</div>
</div>
<div class="grid-card">
<div class="grid-thumb grid-machu" aria-hidden="true"></div>
<div class="grid-card-body">
<h3 class="grid-card-title">Machu Picchu, Peru</h3>
<p class="grid-card-desc">An Incan citadel perched among misty Andean peaks, revealing the engineering brilliance of a lost civilization.</p>
</div>
</div>
<div class="grid-card">
<div class="grid-thumb grid-reykjavik" aria-hidden="true"></div>
<div class="grid-card-body">
<h3 class="grid-card-title">Reykjavik, Iceland</h3>
<p class="grid-card-desc">Gateway to glaciers, geysers, and the northern lights, where raw natural forces shape an otherworldly landscape.</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="section cta-section" id="cta">
<div class="cta-orb cta-orb-1" aria-hidden="true"></div>
<div class="cta-orb cta-orb-2" aria-hidden="true"></div>
<div class="cta-content">
<h2 class="cta-heading">Start Your Journey</h2>
<p class="cta-subtitle">Book your next adventure today</p>
<div class="cta-buttons">
<button class="btn btn-primary">Book Now</button>
<button class="btn btn-secondary">View Routes</button>
</div>
</div>
</section>
<script type="module" src="script.js"></script>
</body>
</html>Travel Experience
Immersive travel booking experience with a scroll-driven 3D airplane flight path and animated destination reveals.
Source
- Repository:
libs-genclaude - Original demo id:
26-travel-experience
Notes
Immersive travel booking experience with a scroll-driven 3D airplane flight path and animated destination reveals.