Scenic Travel
Here is a complete, drop-in prompt you can hand to a fresh model to reproduce the site byte-for-byte. It captures the stack, structure, animations, every CloudFront URL, and the exact CSS quirks.
Master Recreation Prompt
Build a Next.js 16.2.6 (App Router, webpack — not Turbopack) site for a luxury travel concept. Stack and conventions below are mandatory. Reproduce file paths, classNames, animation timings, and copy verbatim.
Stack & dependencies
next16.2.6,react19.2.4,react-dom19.2.4framer-motion^12.38.0lucide-react^1.14.0gsap^3.15.0(installed but unused — keep it in deps)- Tailwind v4 (
tailwindcss+@tailwindcss/postcss), TypeScript 5.9.3, ESLint 9,eslint-config-next16.2.6
package.json scripts:
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"lint": "eslint"
next.config.ts is empty default ({}). No images.remotePatterns.
File layout
app/
layout.tsx
page.tsx
globals.css
not-found.tsx
[...catchAll]/page.tsx
destinations/
page.tsx
[id]/page.tsx
components/
Navbar.tsx
sections/
HeroSection.tsx
DestinationsSection.tsx
TourDetailSection.tsx
lib/
tours.ts
public/
img1.jpg … img10.jpg
Global constants (used everywhere)
const goldEase = [0.76, 0, 0.24, 1] as const
Background color throughout: #f3ebe4. Selection: bg-black text-white.
app/layout.tsx
- Metadata title:
"Travel — Discover the World" - Description:
"Escape the ordinary and find inspiration in the most breathtaking corners of the globe." - Loads Google Fonts Inter (weights 300, 400, 500) via
<link rel="preconnect">+ stylesheet in<head> <body>hasclassName="h-full antialiased"and inlinestyle={{ fontFamily: "'Inter', sans-serif" }}- Renders
<Navbar />then{children}.<html lang="en" className="h-full">.
app/globals.css (exact content)
@import "tailwindcss";
* { margin: 0; padding: 0; box-sizing: border-box; -webkit-font-smoothing: antialiased; }
html, body { height: 100%; background-color: #f3ebe4; font-family: 'Inter', system-ui, sans-serif; }
.destinations-page { overflow-y: auto; }
.hero-container { position: relative; width: 100vw; height: 100vh; display: flex; }
.left-bg { width: 50vw; height: 100%; background-color: #f3ebe4; }
.right-bg { width: 50vw; height: 100%; position: relative; display: flex; justify-content: flex-end; align-items: flex-end; padding: 30px; }
.bg-image-wrapper { position: absolute; inset: 0; z-index: 0; }
.text-layer-wrapper { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: center; align-items: center; text-align: center; pointer-events: none; z-index: 20; }
.text-black-side { color: #1c1c1c; clip-path: inset(0 50% 0 0); }
.text-white-side { color: white; clip-path: inset(0 0 0 50%); }
.gem-card { position: relative; z-index: 30; background: white; border-radius: 32px; padding: 16px; display: flex; gap: 24px; width: 100%; height: 200px; box-shadow: 0 25px 60px -15px rgba(0,0,0,0.15); }
.gem-image-box { width: 200px; height: 100%; border-radius: 20px; }
.gem-content { display: flex; flex-direction: column; justify-content: space-between; padding: 8px 0; }
#explorebtn { padding: 12px 24px; }
.footerLink { color: #000; }
.footerLink:hover { margin-left: 10px; color: #555; }
#destcontainer { padding: 30px; padding-top: 100px; }
#Popular { margin-bottom: 1rem; }
#infocard { padding: 24px; gap: 24px; }
#tourcontainer { padding: 30px; }
#bookbtn { padding: 8px; }
@media (max-width: 1000px) {
.gem-image-box { display: none; }
.gem-content p { font-size: 15px; }
#destcontainer { padding: 20px; padding-top: 80px; }
}
@media (max-width: 850px) {
#topContent { padding: 6px; gap: 10px; }
.left-bg, .text-black-side { display: none; }
.right-bg { width: 100vw; padding: 0; }
.text-white-side { clip-path: none; width: 100vw; color: white; }
.gem-card { max-width: 100%; flex-direction: column; border-radius: 40px 40px 0 0; padding: 24px; gap: 15px; position: fixed; bottom: 0; left: 0; right: 0; box-shadow: 0 -15px 50px rgba(0,0,0,0.15); }
.gem-image-box { display: none; }
.gem-content { width: 100%; text-align: left; }
.gem-content p { font-size: 12px; }
#destcontainer { padding: 10px; padding-top: 120px; }
}
@media (max-width: 500px) {
#tourcontainer { padding: 10px; padding-top: 100px; justify-content: center; }
#searchInput { margin-bottom: 3rem; }
}
app/components/Navbar.tsx
Client component. Imports useState from react, motion, AnimatePresence from framer-motion, Star, Menu, X from lucide-react, usePathname from next/navigation, Link from next/link.
desktopLinks: About/, Destinations/destinations, Booking/booking, FAQ/faq, Account/accountmobileLinks: same, minus Accountpathname === '/'→isHome.pathname.startsWith('/destinations/') && pathname !== '/destinations'→isTourDetail.- Star icon (size 30,
fill="currentColor",strokeWidth={0}): fixed top-left attop:30, left:30,z-1001, color logic:- menu open → black
- tour detail → white
- home →
max-[850px]:text-white min-[851px]:text-black - else → black
- Hamburger (Menu, size 32): fixed
top-7.5 right-7.5 z-300, white on home/tour-detail else black. Hover scale-110 with 300ms ease-out. - Mobile overlay (AnimatePresence): full-screen white panel slides from
y:'-100%'to0, duration 0.75s, goldEase. Close X (size 32) top-right,hover:rotate-90. Links centered,text-5xl md:text-7xl font-light tracking-tighter hover:italic, each animates from{opacity:0, y:28}to{0,0}with delay0.3 + i*0.07, duration 0.55s. If active, prepend<span className="mr-1">/</span>. - Desktop nav (fixed
bottom-10 left-10, hidden by default,min-[851px]:flexflex-col gap-1, but only when NOT tour detail):text-[13px] tracking-widest font-medium, each item animates{y:20, opacity:0}→{0,1}, delay0.4 + i*0.08, duration 0.6s. Active link prefixed with<span className="mr-0.5">/</span>.
app/sections/HeroSection.tsx
Client component. Imports motion from framer-motion, ArrowRight from lucide-react, Image from next/image, Link from next/link, plus useEffect, useRef from react.
A HeroContent subcomponent (used twice, mirrored via clip-path):
- Wrapper div
id="topContent",flex flex-col items-center justify-center transform -translate-y-[40px] md:-translate-y-[20px] px-6 - Two
<motion.h1>lines wrapped inoverflow-hiddendivs:"Discover the beauty"(initialy:'110%'→y:0, duration 1.1s, goldEase)"of the world around"(same, butdelay: 0.08, parent div hasmb-8)- Both:
font-light leading-[1.05] tracking-[-0.04em] text-[clamp(42px,6vw,80px)]
- A
<motion.p>body: copy"Escape the ordinary and find inspiration in the most breathtaking corners of the globe. We curate unique travel experiences tailored to your rhythm and spirit.". Classtext-[clamp(14px,1vw,16px)] leading-[1.7] max-w-[550px] mx-auto opacity-80 font-light tracking-wide. Initial{opacity:0, y:18}→{1,0}, duration 0.9s, delay 0.55s.
Default export HeroSection:
videoReffor the hero video.useEffect: setv.muted = true, callv.play().catch(()=>{})immediately and onloadeddata. Remove listener on cleanup. (This defeats Safari/Chrome autoplay edge cases.)- Outer div:
bg-[#f3ebe4] selection:bg-black selection:text-white min-h-screen overflow-hidden font-sans. <main className="hero-container">with:<div className="left-bg" /><div className="right-bg">:.bg-image-wrappercontaining a<motion.div className="relative w-full h-full">(initialscale:1.06→scale:1, duration 2.2s, goldEase) wrapping a<video>:src="https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260508_220929_e6719f25-1ba0-45c2-97fc-0148805d9fb9.mp4"autoPlay loop muted playsInline preload="auto",ref={videoRef}- Class
absolute inset-0 w-full h-full object-cover object-left
- Sibling
<div className="absolute inset-0 bg-black/20 md:bg-transparent" />(mobile darkening only) - Gem card
<motion.div className="gem-card">(initial{y:60, opacity:0}→{0,1}, duration 1.1s, delay 0.5s):.gem-image-box relative shrink-0 overflow-hiddencontaining a<video>:src="https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260509_073207_eeb9b7e5-7df4-4204-80c2-163eb46466e8.mp4"autoPlay loop muted playsInline preload="auto"- Class
absolute inset-0 w-full h-full object-cover
.gem-content gap-[20px]with:<div className="mb-5 md:mb-0">:<h3 className="font-semibold text-[#1c1c1c] text-xl md:text-base mb-2">Hidden Gems</h3>and<p className="text-gray-500 text-xs leading-relaxed">Explore our handpicked collection of authentic stays and secluded retreats, where nature meets comfort in perfect harmony.</p><Link id="explorebtn" href="/destinations" className="bg-black text-white px-8 py-4 md:px-5 md:py-2.5 rounded-full text-xs flex items-center gap-2 self-start hover:bg-zinc-800 transition-all duration-300 active:scale-95 cursor-pointer">Explore more <ArrowRight size={14} /></Link>
- Two text layers stacked:
<div className="text-layer-wrapper text-black-side"><HeroContent /></div><div className="text-layer-wrapper text-white-side"><HeroContent /></div>- The clip-path CSS makes the left half show black text and the right half show white — same headline, two colors, perfectly aligned.
app/sections/DestinationsSection.tsx
Client component. State query. Filters tours by name.toLowerCase().includes(query.toLowerCase()).
- Outer:
bg-[#f3ebe4] min-h-screen font-sans selection:bg-black selection:text-white <div id="destcontainer" className="transition-all duration-500">- Search row (
<motion.div>initial{opacity:0, y:20}→{1,0}, duration 0.8s) wrapping<input id="searchInput" placeholder="Find your tour" className="w-full max-w-2xl bg-transparent text-[clamp(24px,4vw,42px)] font-light tracking-[-0.02em] outline-none placeholder-black/20 caret-black/40 text-center" /> <motion.p id="Popular" className="text-sm font-medium tracking-widest mb-[15px]">Popular</motion.p>(initialopacity:0→0.6, duration 0.6s, delay 0.2s)<div className="flex gap-5 overflow-x-auto pb-6 no-scrollbar">with empty-state<p className="text-black/40 text-sm pt-4">No tours found for "{query}"</p>then card map. Each<motion.div>hasstyle={{ width: tour.w, flexShrink: 0 }}, animates{opacity:0, y:30}→{1,0}withdelay: 0.1 + i*0.07, duration 0.55s. Inside is a<Link href={/destinations/${tour.id}} className="flex flex-col gap-3 group">containing:- Media box
relative rounded-2xl overflow-hiddenwith inlinestyle={{ height: tour.imgH }}. Iftour.video, render a paused<video src={\${tour.video}#t=0.1`} muted playsInline preload="metadata">with classabsolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 ease-out. **Else**<Image src={tour.image} fill alt={tour.name}>` with the same hover transform classes. - Caption:
<h3 className="text-sm font-medium leading-tight">{name}</h3><p className="text-sm text-black/45 mt-1">{priceDisplay} / person</p>
- Media box
- Search row (
- Append a
<style jsx global>block hiding scrollbars on.no-scrollbar.
app/sections/TourDetailSection.tsx
Client component, accepts { tour: Tour }.
- Outer:
<div id="tourcontainer" className="relative min-h-screen w-full flex items-end justify-end font-sans selection:bg-black selection:text-white overflow-hidden p-4 md:p-10"> - Background:
<div className="absolute inset-0 z-0 overflow-hidden">containing<motion.div className="relative w-full h-full">(scale 1.06 → 1, duration 2.2s).- If
tour.video:<video src={tour.video} autoPlay loop muted playsInline preload="auto" className="absolute inset-0 w-full h-full object-cover" />. No overlay, no brightness filter. - Else:
<Image src={tour.image} fill alt={tour.name} className="object-cover brightness-90" priority />AND a sibling overlay<div className="absolute inset-0 bg-black/10 md:bg-transparent md:bg-gradient-to-r from-black/20 to-transparent" />(only when no video).
- If
- Info card
<motion.div id="infocard" className="relative z-10 w-full max-w-[400px] bg-[#f3ebe4] rounded-[20px] shadow-2xl overflow-y-auto max-h-[90vh] no-scrollbar sm:gap-6 gap-2 flex flex-col">(initial{opacity:0, x:40}→{1,0}, duration 0.8s):- Header block
flex flex-col gap-[10px]:<Link href="/destinations" className="inline-flex items-center gap-2 text-sm text-black px-4 py-2 rounded-full transition-all"><ArrowLeft size={15} />Back to explore</Link><h1 className="text-[20px] font-semibold text-[#1c1c1c] mb-4 tracking-tight">{name}</h1>
- Description
<p className="sm:text-[15px] text-[12px] text-black/70 leading-relaxed mb-8">{description}</p> - Friends row
flex items-center gap-4 mb-10: stacked avatar circles (3 oftour.images) — eachrelative w-9 h-9 rounded-full overflow-hidden border-2 border-[#f3ebe4] shadow-sm,marginLeft: i===0 ? 0 : -12,zIndex: 3-i,<Image fill object-cover>. Then a+{friends-3}chipw-9 h-9 rounded-full bg-black text-white text-[11px] font-bold flex items-center justify-center border-2 border-[#f3ebe4]. Trailing label<span className="text-[13px] font-medium text-black/60">{friends} friends been there</span> - Three info rows in
space-y-4 mb-10 gap-2 flex flex-col. Eachflex justify-between items-center pb-4 border-b border-black/10(last row usespb-2and no border):Avg cost per trip→priceDisplayBest time to visit→bestTimeVisa→<span className="text-blue-600">🇪🇺</span> {visa}. Labels:text-[13px] text-black/40 font-medium uppercase tracking-wider. Values:text-sm font-bold text-black/90.
grid grid-cols-3 gap-3 mb-8of three thumbnails —relative aspect-square rounded-[20px] overflow-hidden groupwith<Image fill className="object-cover transition-transform duration-500 group-hover:scale-110">.- CTA
<motion.button id="bookbtn" whileHover={{ y: -2 }} className="w-full bg-[#0f1115] text-white rounded-[24px] text-[15px] font-bold tracking-tight hover:bg-black active:scale-[0.98] transition-all duration-300">Book this tour</motion.button>
- Header block
- Append the same
<style jsx global>no-scrollbar block.
app/lib/tours.ts
export type Tour = {
id: string
image: string
video?: string
images: string[]
name: string
description: string
price: number
priceDisplay: string
friends: number
bestTime: string
visa: string
imgH: number
w: number
}
export const tours: Tour[] = [
{
id: 'cold-islands-norway',
image: '/img1.jpg',
video: 'https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260508_071134_9cc2f2d8-a599-4a73-8c89-6eb4af170352.mp4',
images: ['/img4.jpg','/img6.jpg','/img9.jpg'],
name: 'Cold Islands Norway',
description: "Experience the raw beauty of Norway's remote arctic islands. From dramatic fjords to the magical northern lights, Norway offers a perfect blend of wilderness and Scandinavian culture.",
price: 1800, priceDisplay: '$1,800', friends: 8, bestTime: 'Jun - Sep', visa: 'Schengen / EU', imgH: 230, w: 200,
},
{ id: 'serengeti-tanzania', image: '/img2.jpg', images: ['/img5.jpg','/img7.jpg','/img8.jpg'],
name: 'Serengeti National Park, Tanzania',
description: 'Witness the greatest wildlife spectacle on Earth. The Serengeti offers unmatched safari experiences, from the Great Migration to close encounters with the Big Five across endless golden plains.',
price: 2400, priceDisplay: '$2,400', friends: 14, bestTime: 'Jul - Oct', visa: 'Visa on arrival', imgH: 310, w: 340 },
{ id: 'switzerland-alps', image: '/img3.jpg', images: ['/img1.jpg','/img6.jpg','/img10.jpg'],
name: 'Switzerland',
description: 'Experience the pinnacle of alpine serenity. From the pristine peaks of the Jungfrau region to the crystal-clear waters of Lake Brienz, Switzerland offers a perfect blend of high-end comfort and untouched nature.',
price: 3200, priceDisplay: '$3,200', friends: 12, bestTime: 'May - Oct', visa: 'Schengen / EU', imgH: 360, w: 250 },
{ id: 'norway-coastal', image: '/img4.jpg', images: ['/img1.jpg','/img9.jpg','/img6.jpg'],
name: 'Cold Islands Norway',
description: "Sail through Norway's stunning coastal landscapes where turquoise waters meet towering cliffs. A journey through some of the most dramatic scenery on the planet.",
price: 1800, priceDisplay: '$1,800', friends: 6, bestTime: 'May - Aug', visa: 'Schengen / EU', imgH: 215, w: 210 },
{ id: 'mountain-valleys-iceland', image: '/img6.jpg', images: ['/img5.jpg','/img3.jpg','/img9.jpg'],
name: 'Mountain Valleys, Iceland',
description: "Explore Iceland's surreal volcanic landscapes, cascading waterfalls and geothermal wonders. A land of fire and ice unlike anywhere else on Earth.",
price: 2100, priceDisplay: '$2,100', friends: 9, bestTime: 'Jun - Aug', visa: 'Schengen / EU', imgH: 250, w: 235 },
{ id: 'hidden-coves-croatia', image: '/img7.jpg', images: ['/img8.jpg','/img2.jpg','/img5.jpg'],
name: 'Hidden Coves, Croatia',
description: "Discover Croatia's secluded Adriatic coastline — crystal clear waters, ancient walled cities and charming fishing villages tucked between dramatic limestone cliffs.",
price: 1950, priceDisplay: '$1,950', friends: 11, bestTime: 'May - Sep', visa: 'Schengen / EU', imgH: 300, w: 220 },
{ id: 'desert-dunes-morocco', image: '/img8.jpg', images: ['/img2.jpg','/img7.jpg','/img10.jpg'],
name: 'Desert Dunes, Morocco',
description: "Journey into the Sahara's vast golden dunes, ancient medinas and vibrant souks. Morocco blends Berber, Arab and French influences into one unforgettable sensory experience.",
price: 1600, priceDisplay: '$1,600', friends: 7, bestTime: 'Oct - Apr', visa: 'Visa free / 90 days', imgH: 240, w: 215 },
]
export function getTourById(id: string): Tour | undefined {
return tours.find(t => t.id === id)
}
Routing pages
app/page.tsx→import HeroSection from './sections/HeroSection'; export default function Home(){ return <HeroSection /> }app/destinations/page.tsx→ renders<DestinationsSection />app/destinations/[id]/page.tsxisasync, awaitsparams: Promise<{id:string}>, callsgetTourById, callsnotFound()if missing, otherwise renders<TourDetailSection tour={tour} />app/[...catchAll]/page.tsx→import { notFound } from 'next/navigation'; export default function CatchAll(){ notFound() }
app/not-found.tsx
Client component, full-screen bg-[#f3ebe4] flex center. Three motion blocks (all goldEase):
- Big
404:text-[120px] font-light leading-none tracking-[-0.04em] text-black/10 select-none,{opacity:0, y:30}→{1,0}, duration 1s Page not found:text-2xl font-light tracking-tight text-black mt-4 mb-3, delay 0.15s, duration 0.8sThis page doesn't exist yet.:text-sm text-black/40 mb-10, delay 0.25s, duration 0.7sBack to homelink to/:text-[13px] tracking-widest font-medium text-black border-b border-black/20 pb-0.5 hover:border-black transition-colors duration-200, delay 0.35s
Assets
Put any 10 photographs into public/ named img1.jpg through img10.jpg (the gallery references all of them).
CloudFront video URLs (use exactly these strings)
- Hero background video — used in
HeroSection.tsxonly:https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260508_220929_e6719f25-1ba0-45c2-97fc-0148805d9fb9.mp4 - Hidden Gems card video — used inside
.gem-image-boxinHeroSection.tsx:https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260509_073207_eeb9b7e5-7df4-4204-80c2-163eb46466e8.mp4 - First-tour video —
tours[0].video(Cold Islands Norway). On/destinationsrendered paused with#t=0.1as a still frame. On/destinations/cold-islands-norwayrendered as autoplaying full-bleed background, no overlay, no brightness filter:https://d8j0ntlcm91z4.cloudfront.net/user_38xzZboKViGWJOttwIXH07lWA1P/hf_20260508_071134_9cc2f2d8-a599-4a73-8c89-6eb4af170352.mp4
Behavioral requirements (do not omit)
- All three videos:
autoPlay loop muted playsInline preload="auto"(the destinations card usespreload="metadata"and no autoplay — it's a still frame via#t=0.1). - Hero video focal point is left-aligned:
object-cover object-left. - Hero video has a defensive
useEffectthat forcesmuted = trueand re-callsplay()onloadeddata. - The split-screen headline is two identical
HeroContentcomponents clipped viaclip-path: inset(0 50% 0 0)(black, left half) andclip-path: inset(0 0 0 50%)(white, right half). Below 850px the black side is hidden and the white side fills the viewport. - Active route in Navbar is prefixed with
/and a small margin. - Tour detail with a video has no darkening overlay and no
brightness-90. Image-backed tours keep both.