A full-bleed hero for a mountain lodge, built on a single photograph of a hut above a sea of cloud. The headline is set very large in the open sky, and a piece of cloud cut from the same picture slides slowly in front of the letters, so the type appears to sit inside the weather. Two frosted cards float nearest the viewer: a booking card with night and guest controls that reprices as you tap, and a morning forecast that moves a cloud line up and down a small ridge drawing. Photo, headline and cards each shift by a different amount as the pointer moves. On phones the cards stack under the picture and the pointer effect is switched off.
import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform, type MotionValue } from "framer-motion";
/** One photograph does all the work: the backdrop and the cloud that drifts over the headline. */
const PHOTO = "https://images.unsplash.com/photo-1760202953582-a54c9459dfd9?auto=format&fit=crop&w=1600&q=80";
const PHOTO_ALT = "A dark timber mountain hut on a green ridge, with pine trees and thick white cloud filling the valley below";
const lodge = {
name: "Kestrel Ridge",
eyebrow: "Nine rooms at 1,840 metres",
headline: ["Above the", "weather"],
copy: "A timber lodge on the ridge line, an hour's walk from the last road. Most mornings the valley fills with cloud and the lodge sits just over it.",
nightly: 412,
};
const links = ["Rooms", "The walk in", "Kitchen", "Winter"];
const stays = [2, 3, 5];
/** Cloud top for the next three mornings, in metres. The lodge sits at LODGE_ALTITUDE. */
const LODGE_ALTITUDE = 1840;
const mornings = [
{ day: "Thu", cloudTop: 1610, sunrise: "06:42" },
{ day: "Fri", cloudTop: 1575, sunrise: "06:43" },
{ day: "Sat", cloudTop: 1790, sunrise: "06:45" },
];
/** How far each layer travels with the pointer, in pixels. Bigger numbers feel closer to the viewer. */
const DEPTH = { photo: -10, type: -22, glassBack: 16, glassFront: 30 };
function useLayer(mx: MotionValue<number>, my: MotionValue<number>, depth: number) {
const x = useTransform(mx, (v) => v * depth);
const y = useTransform(my, (v) => v * depth * 0.7);
return { x, y };
}
function MountainMark() {
return (
<svg viewBox="0 0 24 24" className="h-6 w-6" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2.5 19 9 7.5l4 6.5 2.5-3.5L21.500 19" />
<path d="M2.5 19h19" />
</svg>
);
}
export default function MountainLodgePhotoHeroWithDriftingCloudHeadlineAndGlassBookingCards() {
const reduce = useReducedMotion();
const root = useRef<HTMLElement>(null);
const [live, setLive] = useState(true);
const [nights, setNights] = useState(3);
const [guests, setGuests] = useState(2);
const [morning, setMorning] = useState(0);
const px = useMotionValue(0);
const py = useMotionValue(0);
const mx = useSpring(px, { stiffness: 60, damping: 18, mass: 0.8 });
const my = useSpring(py, { stiffness: 60, damping: 18, mass: 0.8 });
const photo = useLayer(mx, my, DEPTH.photo);
const type = useLayer(mx, my, DEPTH.type);
const back = useLayer(mx, my, DEPTH.glassBack);
const front = useLayer(mx, my, DEPTH.glassFront);
// Pause the drifting cloud and the floating cards while the hero is off screen.
useEffect(() => {
const el = root.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => setLive(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, []);
function onMove(e: ReactPointerEvent<HTMLElement>) {
if (reduce || e.pointerType === "touch") return;
const r = e.currentTarget.getBoundingClientRect();
px.set(((e.clientX - r.left) / r.width - 0.5) * 2);
py.set(((e.clientY - r.top) / r.height - 0.5) * 2);
}
function onLeave() {
px.set(0);
py.set(0);
}
const chosen = mornings[morning];
const clearance = LODGE_ALTITUDE - chosen.cloudTop;
// Where the cloud line sits in the little diagram: 0 is the lodge, 100 is the valley floor.
const cloudY = 22 + Math.min(1, clearance / 320) * 44;
const rise = (delay: number) =>
reduce
? {}
: { initial: { opacity: 0, y: 26, filter: "blur(10px)" }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, transition: { type: "spring" as const, stiffness: 70, damping: 16, delay } };
return (
<section
ref={root}
data-live={live}
onPointerMove={onMove}
onPointerLeave={onLeave}
className="lodge-cloud-hero relative isolate overflow-hidden bg-[#dde5e8] text-[#0d2231]"
>
<style>{`
@keyframes lodge-cloud-hero-drift { from { transform: translate3d(-70%, 0, 0); } to { transform: translate3d(190%, 0, 0); } }
@keyframes lodge-cloud-hero-float { 0%, 100% { transform: translate3d(0, 0, 0); } 50% { transform: translate3d(0, -9px, 0); } }
@keyframes lodge-cloud-hero-breathe { 0%, 100% { transform: scale(1.08); } 50% { transform: scale(1.13); } }
@keyframes lodge-cloud-hero-ember { 0%, 100% { opacity: 0.55; transform: scale(1); } 50% { opacity: 1; transform: scale(1.35); } }
.lodge-cloud-hero-photo { transform: scale(1.08); animation: lodge-cloud-hero-breathe 26s ease-in-out infinite; }
.lodge-cloud-hero-wisp {
animation: lodge-cloud-hero-drift 46s linear infinite;
-webkit-mask-image: radial-gradient(closest-side, #000 22%, rgba(0,0,0,0.55) 55%, transparent 100%);
mask-image: radial-gradient(closest-side, #000 22%, rgba(0,0,0,0.55) 55%, transparent 100%);
}
.lodge-cloud-hero-float { animation: lodge-cloud-hero-float 9s ease-in-out infinite; }
.lodge-cloud-hero-ember { animation: lodge-cloud-hero-ember 2.8s ease-in-out infinite; }
.lodge-cloud-hero-glass {
background: linear-gradient(145deg, rgba(255,255,255,0.62), rgba(255,255,255,0.3));
box-shadow: 0 1px 0 rgba(255,255,255,0.9) inset, 0 -1px 0 rgba(13,34,49,0.06) inset, 0 2px 6px rgba(13,34,49,0.08), 0 28px 60px -22px rgba(13,34,49,0.45);
}
.lodge-cloud-hero[data-live="false"] * { animation-play-state: paused !important; }
@media (prefers-reduced-motion: reduce) {
.lodge-cloud-hero-photo, .lodge-cloud-hero-float, .lodge-cloud-hero-ember { animation: none; }
.lodge-cloud-hero-wisp { animation: none; transform: translate3d(48%, 0, 0); }
}
`}</style>
{/* Layer 1: the photograph, furthest away. */}
<motion.div className="absolute -inset-6 -z-10" style={photo}>
<img src={PHOTO} alt={PHOTO_ALT} width={1600} height={1067} loading="lazy" className="lodge-cloud-hero-photo h-full w-full object-cover object-[52%_42%]" />
</motion.div>
{/* A cool wash at the top so the navigation and headline sit on even sky. */}
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-72 bg-gradient-to-b from-[#e9eff1]/90 via-[#e9eff1]/40 to-transparent" aria-hidden="true" />
<div className="pointer-events-none absolute inset-x-0 bottom-0 -z-10 h-64 bg-gradient-to-t from-white/70 to-transparent lg:h-40 lg:from-white/30" aria-hidden="true" />
<div className="relative mx-auto flex min-h-[780px] max-w-7xl flex-col px-5 pb-6 pt-5 sm:px-8 lg:min-h-[800px] lg:pb-10">
<nav aria-label="Main" className="flex items-center justify-between">
<a href="#" className="flex items-center gap-2 rounded-full text-[15px] font-semibold tracking-tight focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0d2231] focus-visible:ring-offset-2 focus-visible:ring-offset-transparent">
<MountainMark />
{lodge.name}
</a>
<ul className="lodge-cloud-hero-glass hidden items-center gap-1 rounded-full border border-white/70 p-1 text-sm font-medium backdrop-blur-xl md:flex">
{links.map((link, i) => (
<li key={link}>
<a
href="#"
aria-current={i === 0 ? "page" : undefined}
className={`flex h-10 items-center rounded-full px-4 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0d2231] ${i === 0 ? "bg-[#0d2231] text-white" : "text-[#0d2231] hover:bg-white/60"}`}
>
{link}
</a>
</li>
))}
</ul>
<a href="#" className="flex h-10 items-center gap-2 rounded-full bg-[#0d2231] px-4 text-sm font-semibold text-white shadow-lg shadow-[#0d2231]/25 transition-transform hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0d2231] focus-visible:ring-offset-2">
<span className="lodge-cloud-hero-ember h-1.5 w-1.5 rounded-full bg-[#ff9d5c]" aria-hidden="true" />
Reserve
</a>
</nav>
{/* Layer 2: the headline, with a piece of real cloud passing in front of it. */}
<div className="relative mt-12 text-center sm:mt-14 lg:mt-10">
<motion.div style={type}>
<motion.p {...rise(0.05)} className="text-[11px] font-semibold uppercase tracking-[0.32em] text-[#0d2231]/80 sm:text-xs">
{lodge.eyebrow}
</motion.p>
<h1 className="mt-4 text-[clamp(3.4rem,11.5vw,10.5rem)] font-medium leading-[0.86] tracking-[-0.055em] text-[#0d2231]">
{lodge.headline.map((line, i) => (
<motion.span key={line} {...rise(0.15 + i * 0.12)} className="block lg:inline">
{line}
{i === 0 ? " " : ""}
</motion.span>
))}
</h1>
</motion.div>
<div className="pointer-events-none absolute inset-x-[-12%] -bottom-10 -top-6 overflow-hidden opacity-70 sm:opacity-100" aria-hidden="true">
{[
{ width: "46%", delay: "-17s", top: "8%", opacity: 0.95 },
{ width: "34%", delay: "-39s", top: "24%", opacity: 0.8 },
].map((wisp) => (
<div key={wisp.delay} className="lodge-cloud-hero-wisp absolute left-0 h-[72%] overflow-hidden" style={{ width: wisp.width, top: wisp.top, opacity: wisp.opacity, animationDelay: wisp.delay }}>
{/* The lower right corner of the same photograph: nothing but cloud. */}
<img src={PHOTO} alt="" loading="lazy" width={1600} height={1067} className="absolute bottom-0 right-0 h-auto w-[260%] max-w-none" />
</div>
))}
</div>
</div>
{/* Layer 3: glass, nearest the viewer. */}
<div className="mt-auto grid items-end gap-4 pt-52 sm:pt-72 lg:grid-cols-[minmax(0,26rem)_1fr_minmax(0,19rem)] lg:gap-6 lg:pt-10">
<motion.div style={front}>
<motion.div {...rise(0.5)}>
<form
onSubmit={(e) => e.preventDefault()}
aria-label="Check availability"
className="lodge-cloud-hero-glass lodge-cloud-hero-float rounded-[28px] border border-white/70 p-4 backdrop-blur-2xl sm:p-5"
>
<p className="text-sm leading-relaxed text-[#0d2231] sm:text-[15px]">{lodge.copy}</p>
<div className="mt-4 flex items-center justify-between gap-3">
<fieldset className="min-w-0">
<legend className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[#0d2231]/75">Nights</legend>
<div className="mt-2 flex gap-1.5">
{stays.map((n) => (
<button
key={n}
type="button"
aria-pressed={nights === n}
onClick={() => setNights(n)}
className={`h-10 min-w-[44px] rounded-full px-3 text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0d2231] ${nights === n ? "bg-[#0d2231] text-white" : "bg-white/60 text-[#0d2231] hover:bg-white"}`}
>
{n}
</button>
))}
</div>
</fieldset>
<div className="shrink-0">
<p id="lodge-cloud-hero-guests" className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[#0d2231]/75">Guests</p>
<div className="mt-2 flex items-center gap-1 rounded-full bg-white/60 p-1" role="group" aria-labelledby="lodge-cloud-hero-guests">
<button type="button" aria-label="One guest fewer" disabled={guests <= 1} onClick={() => setGuests((g) => Math.max(1, g - 1))} className="flex h-8 w-9 items-center justify-center rounded-full text-lg leading-none text-[#0d2231] transition-colors hover:bg-white disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0d2231]">
<span aria-hidden="true">−</span>
</button>
<span className="w-5 text-center text-sm font-semibold tabular-nums" aria-live="polite">{guests}</span>
<button type="button" aria-label="One guest more" disabled={guests >= 4} onClick={() => setGuests((g) => Math.min(4, g + 1))} className="flex h-8 w-9 items-center justify-center rounded-full text-lg leading-none text-[#0d2231] transition-colors hover:bg-white disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0d2231]">
<span aria-hidden="true">+</span>
</button>
</div>
</div>
</div>
<div className="mt-4 flex items-center justify-between gap-3 border-t border-[#0d2231]/10 pt-4">
<p className="text-sm text-[#0d2231]">
<span className="block text-2xl font-semibold tabular-nums tracking-tight">${(lodge.nightly * nights).toLocaleString("en-US")}</span>
<span className="text-[#0d2231]/80">{nights} nights, half board</span>
</p>
<button type="submit" className="group flex h-12 items-center gap-2 rounded-full bg-[#0d2231] pl-5 pr-2 text-sm font-semibold text-white shadow-lg shadow-[#0d2231]/30 transition-transform hover:-translate-y-0.5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0d2231] focus-visible:ring-offset-2">
See free rooms
<span className="flex h-8 w-8 items-center justify-center rounded-full bg-[#ff9d5c] text-[#0d2231] transition-transform group-hover:translate-x-0.5">
<svg viewBox="0 0 20 20" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M4 10h12M11 5l5 5-5 5" /></svg>
</span>
</button>
</div>
</form>
</motion.div>
</motion.div>
<div className="hidden lg:block" />
<motion.div style={back}>
<motion.div {...rise(0.65)}>
<div className="lodge-cloud-hero-glass lodge-cloud-hero-float rounded-[28px] border border-white/70 p-4 backdrop-blur-2xl sm:p-5" style={{ animationDelay: "-4s" }}>
<div className="flex items-center justify-between">
<h2 className="text-[11px] font-semibold uppercase tracking-[0.2em] text-[#0d2231]/75">Morning forecast</h2>
<span className="rounded-full bg-[#0d2231] px-2.5 py-1 text-[11px] font-semibold text-white">{clearance >= 120 ? "Clear above" : "Borderline"}</span>
</div>
{/* The ridge, the lodge and where the cloud is expected to sit. */}
<svg viewBox="0 0 240 92" className="mt-3 hidden h-auto w-full overflow-hidden rounded-2xl bg-white/35 sm:block" role="img" aria-label={`Cloud top at ${chosen.cloudTop.toLocaleString("en-US")} metres, ${clearance} metres below the lodge`}>
<path d="M0 92V70l46-30 30 12 44-40 38 30 34-12 48 30v42Z" fill="#0d2231" fillOpacity="0.16" />
<path d="M0 70l46-30 30 12 44-40 38 30 34-12 48 30" fill="none" stroke="#0d2231" strokeWidth="1.5" strokeLinejoin="round" />
<rect x="113" y="5" width="14" height="8" rx="1" fill="#0d2231" />
<circle cx="120" cy="9" r="1.6" fill="#ff9d5c" />
<motion.g animate={{ y: cloudY }} initial={false} transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 90, damping: 14 }}>
<rect x="0" y="0" width="240" height="92" fill="#ffffff" fillOpacity="0.82" />
<path d="M0 0c20-7 40 6 60 0s40-7 60 0 40 6 60 0 40-7 60 0" fill="none" stroke="#ffffff" strokeWidth="5" strokeLinecap="round" />
<text x="6" y="16" fontSize="10" fontWeight="600" fill="#0d2231">{chosen.cloudTop.toLocaleString("en-US")} m</text>
</motion.g>
</svg>
<div className="mt-3 flex gap-1.5" role="group" aria-label="Morning">
{mornings.map((m, i) => (
<button
key={m.day}
type="button"
aria-pressed={morning === i}
onClick={() => setMorning(i)}
className={`h-10 flex-1 rounded-full text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#0d2231] ${morning === i ? "bg-[#0d2231] text-white" : "bg-white/60 text-[#0d2231] hover:bg-white"}`}
>
{m.day}
</button>
))}
</div>
<p className="mt-3 text-sm leading-snug text-[#0d2231]">
Sunrise {chosen.sunrise}. Cloud sits <span className="font-semibold tabular-nums">{clearance} m</span> under the terrace.
</p>
</div>
</motion.div>
</motion.div>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/mountain-lodge-photo-hero-with-drifting-cloud-headline-and-glass-booking-cards.json
A second and third copy of the same image are placed in small boxes above the heading. Each box shows only the cloudy lower right corner of the picture, is faded at its edges with a radial mask, and is moved sideways by a slow keyframe animation. Because cloud has no hard edges, the copy blends with the sky behind it.
No. All three image elements use the identical address, so the browser fetches it once and reuses it from memory.
Pointer position is turned into two numbers between minus one and one, smoothed with a spring, and multiplied by a different pixel distance for each layer. The photo moves slightly against the pointer and the glass cards move with it, which reads as near and far.
Touch pointers are ignored, so nothing jumps while scrolling. The cloud still drifts and the cards still float gently, which keeps the hero alive without input.
Yes. The card is a `form` with `nights` and `guests` held in state. Replace the `onSubmit` handler with a call to your booking page and pass those two values along.
There are only three blurred surfaces and they are small, which modern phones handle well. If you target older devices, remove `backdrop-blur-2xl` and raise the white in the glass gradient so the cards stay readable.
Related: Neo-Brutalist Hero with Sticker Headline and Shifting Hard Shadows, Olive Oil Bottle Hero Turning on a Seamless Backdrop with the Headline Orbiting Round the Glass, Portrait Studio Hero with a Giant Headline That Passes Behind the Sitter and in Front of Her.
More hero sections or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.