Parallax Mountain Scene in a Scroll Frame

A layered dusk landscape that scrolls inside its own fixed-height frame. As the frame scrolls, the sun sinks, the sky darkens, stars come out and three mountain ridges and a line of pines rise at different speeds, which creates the depth. The headline drifts away behind the middle ridge and a booking card settles in at the end. Everything is drawn with SVG paths and gradients, so there are no images to load. It works the same on phones, where the artwork crops from the centre.

Code

import { useEffect, useRef } from "react";
import type { RefObject } from "react";
import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "framer-motion";

// How far each layer travels (in pixels) over the whole scroll. Bigger numbers feel closer to the camera.
const TRAVEL = { stars: -40, sun: 230, far: 50, mid: 130, near: 240, pines: 400, headline: -150 };
const DEMO_SECONDS = 9;

/** A faceted mountain ridge as an SVG path, drawn from a few sine waves so it needs no image. */
function ridge(seed: number, base: number, amp: number, points: number) {
  let d = "M0 400";
  for (let i = 0; i <= points; i++) {
    const x = (i / points) * 1200;
    const n = Math.sin(i * 0.9 + seed) * 0.5 + Math.sin(i * 2.3 + seed * 1.7) * 0.3 + Math.sin(i * 4.1 + seed * 2.9) * 0.2;
    d += ` L${x.toFixed(0)} ${(base - amp * n).toFixed(0)}`;
  }
  return `${d} L1200 400 Z`;
}

/** A row of pine trees along the bottom edge. */
function pines() {
  let d = "";
  for (let i = 0; i < 48; i++) {
    const x = i * 26 - 10 + Math.sin(i * 7.3) * 9;
    const h = 90 + Math.abs(Math.sin(i * 3.7)) * 110;
    const w = 20 + (h - 90) * 0.12;
    const top = 400 - h - 120;
    d += `M${x.toFixed(0)} 400 L${x.toFixed(0)} ${(top + h * 0.75).toFixed(0)} L${(x - w).toFixed(0)} ${(top + h * 0.75).toFixed(0)} L${(x - w * 0.35).toFixed(0)} ${(top + h * 0.45).toFixed(0)} L${(x - w * 0.7).toFixed(0)} ${(top + h * 0.45).toFixed(0)} L${(x + 4).toFixed(0)} ${top.toFixed(0)} L${(x + 8 + w * 0.7).toFixed(0)} ${(top + h * 0.45).toFixed(0)} L${(x + 8 + w * 0.35).toFixed(0)} ${(top + h * 0.45).toFixed(0)} L${(x + 8 + w).toFixed(0)} ${(top + h * 0.75).toFixed(0)} L${(x + 8).toFixed(0)} ${(top + h * 0.75).toFixed(0)} L${(x + 8).toFixed(0)} 400 Z `;
  }
  return d;
}

const FAR = ridge(1.2, 170, 70, 26);
const MID = ridge(4.1, 190, 85, 20);
const NEAR = ridge(7.7, 215, 75, 16);
const PINES = pines();
const STARS = Array.from({ length: 46 }, (_, i) => ({
  left: (Math.sin(i * 12.9898) * 43758.5453) % 1,
  top: (Math.sin(i * 78.233) * 12543.123) % 1,
  size: i % 7 === 0 ? 3 : i % 3 === 0 ? 2 : 1,
  delay: (i % 9) * 0.4,
})).map((s) => ({ ...s, left: Math.abs(s.left) * 100, top: Math.abs(s.top) * 62 }));

function easeDemo(t: number) {
  if (t < 0.1) return (5 * t * t) / 0.9;
  if (t > 0.9) return 1 - (5 * (1 - t) * (1 - t)) / 0.9;
  return (t - 0.05) / 0.9;
}

/** Scrolls the frame slowly once so the preview shows the effect. Stops for good on any input. */
function useDemoScroll(ref: RefObject<HTMLDivElement>, enabled: boolean, seconds: number) {
  useEffect(() => {
    const el = ref.current;
    if (!el || !enabled) return;
    let raf = 0;
    let last = 0;
    let t = 0;
    let done = false;
    const inputs = ["wheel", "touchstart", "pointerdown", "keydown"];
    const tick = (now: number) => {
      if (done) return;
      const dt = last ? Math.min(now - last, 50) : 0;
      last = now;
      t = Math.min(1, t + dt / (seconds * 1000));
      el.scrollTop = easeDemo(t) * (el.scrollHeight - el.clientHeight);
      if (t < 1) raf = requestAnimationFrame(tick);
    };
    const io = new IntersectionObserver(
      ([entry]) => {
        cancelAnimationFrame(raf);
        last = 0;
        if (entry.isIntersecting && !done) raf = requestAnimationFrame(tick);
      },
      { threshold: 0.25 }
    );
    const stop = () => {
      done = true;
      cancelAnimationFrame(raf);
    };
    io.observe(el);
    inputs.forEach((name) => el.addEventListener(name, stop, { passive: true }));
    return () => {
      stop();
      io.disconnect();
      inputs.forEach((name) => el.removeEventListener(name, stop));
    };
  }, [ref, enabled, seconds]);
}

export default function ParallaxMountainSceneInScrollFrame() {
  const reduced = !!useReducedMotion();
  const frame = useRef<HTMLDivElement>(null);
  const raw = useMotionValue(0);
  const p = useSpring(raw, { stiffness: 110, damping: 24, mass: 0.5 });

  useDemoScroll(frame, !reduced, DEMO_SECONDS);

  useEffect(() => {
    const el = frame.current;
    if (!el || reduced) return;
    const onScroll = () => {
      const max = el.scrollHeight - el.clientHeight;
      raw.set(max > 0 ? el.scrollTop / max : 0);
    };
    onScroll();
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => el.removeEventListener("scroll", onScroll);
  }, [raw, reduced]);

  // Layers start lowered and rise at their own speed; the sun sinks the other way.
  const starsY = useTransform(p, [0, 1], [0, TRAVEL.stars]);
  const starsOpacity = useTransform(p, [0, 0.7], [0.25, 1]);
  const night = useTransform(p, [0.1, 0.9], [0, 0.92]);
  const dusk = useTransform(p, [0.3, 1], [0, 0.6]);
  const sunY = useTransform(p, [0, 1], [0, TRAVEL.sun]);
  const farY = useTransform(p, [0, 1], [TRAVEL.far, 0]);
  const midY = useTransform(p, [0, 1], [TRAVEL.mid, 0]);
  const nearY = useTransform(p, [0, 1], [TRAVEL.near, 0]);
  const pinesY = useTransform(p, [0, 1], [TRAVEL.pines, 0]);
  const headY = useTransform(p, [0, 1], [0, TRAVEL.headline]);
  const headOpacity = useTransform(p, [0.18, 0.5], [1, 0]);
  const hintOpacity = useTransform(p, [0, 0.08], [1, 0]);
  const cardOpacity = useTransform(p, [0.62, 0.88], [0, 1]);
  const cardY = useTransform(p, [0.62, 0.95], [50, 0]);
  const cardScale = useTransform(p, [0.62, 0.95], [0.94, 1]);

  return (
    <section className="bg-indigo-950 px-4 py-10 sm:py-14">
      <style>{`
        @keyframes parallax-mountain-scene-twinkle { 0%, 100% { opacity: 0.25 } 50% { opacity: 1 } }
        @keyframes parallax-mountain-scene-drift { from { transform: translateX(-12%) } to { transform: translateX(12%) } }
        @keyframes parallax-mountain-scene-bob { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(5px) } }
        .parallax-mountain-scene-star { animation: parallax-mountain-scene-twinkle 3.6s ease-in-out infinite }
        .parallax-mountain-scene-fog { animation: parallax-mountain-scene-drift 16s ease-in-out infinite alternate }
        .parallax-mountain-scene-bob { animation: parallax-mountain-scene-bob 1.8s ease-in-out infinite }
        @media (prefers-reduced-motion: reduce) {
          .parallax-mountain-scene-star, .parallax-mountain-scene-fog, .parallax-mountain-scene-bob { animation: none }
        }
      `}</style>
      <div className="mx-auto max-w-5xl overflow-hidden rounded-3xl shadow-2xl shadow-black/50 ring-1 ring-white/10">
        <div
          ref={frame}
          tabIndex={0}
          role="region"
          aria-label="Halden Ridge cabins, a scrolling mountain scene"
          className="relative h-[620px] overflow-y-auto overscroll-contain focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-amber-300"
        >
          {/* The tall wrapper gives the frame something to scroll; the scene itself stays pinned. */}
          <div className={reduced ? "h-[620px]" : "h-[1500px]"}>
            <div className="sticky top-0 h-[620px] overflow-hidden bg-gradient-to-b from-indigo-900 via-fuchsia-800 to-orange-400">
              <motion.div aria-hidden="true" className="absolute inset-0 bg-gradient-to-b from-slate-950 via-indigo-950 to-indigo-900" style={{ opacity: night }} />

              <motion.div aria-hidden="true" className="absolute inset-0" style={{ y: starsY, opacity: starsOpacity }}>
                {STARS.map((s, i) => (
                  <span
                    key={i}
                    className="parallax-mountain-scene-star absolute rounded-full bg-white"
                    style={{ left: `${s.left}%`, top: `${s.top}%`, width: s.size, height: s.size, animationDelay: `${s.delay}s` }}
                  />
                ))}
              </motion.div>

              <div aria-hidden="true" className="absolute left-1/2 top-[47%] -ml-24 h-48 w-48 sm:top-[42%] sm:-ml-32 sm:h-64 sm:w-64">
                <motion.div className="h-full w-full rounded-full bg-gradient-to-b from-amber-200 via-orange-300 to-rose-400 shadow-[0_0_120px_40px_rgba(253,186,116,0.45)]" style={{ y: sunY }} />
              </div>

              <motion.svg aria-hidden="true" viewBox="0 0 1200 400" preserveAspectRatio="xMidYMax slice" className="absolute inset-x-0 bottom-0 h-[420px] w-full" style={{ y: farY }}>
                <path d={FAR} className="fill-fuchsia-900" />
              </motion.svg>

              <motion.div className="absolute inset-x-0 top-0 px-6 pt-14 text-center sm:pt-16" style={{ y: headY, opacity: headOpacity }}>
                <p className="text-xs font-semibold uppercase tracking-[0.3em] text-amber-200">Halden Ridge · 1,940 m</p>
                <h2 className="mx-auto mt-4 max-w-2xl text-4xl font-semibold leading-[1.02] tracking-tight text-white sm:text-6xl">Stay where the road ends.</h2>
                <p className="mx-auto mt-4 max-w-md text-base text-indigo-50">Six off-grid cabins above the tree line. No signal, a wood stove and the whole valley to yourself.</p>
              </motion.div>

              <motion.svg aria-hidden="true" viewBox="0 0 1200 400" preserveAspectRatio="xMidYMax slice" className="absolute inset-x-0 bottom-0 h-[380px] w-full" style={{ y: midY }}>
                <path d={MID} className="fill-purple-950" />
              </motion.svg>

              <div aria-hidden="true" className="parallax-mountain-scene-fog absolute inset-x-0 bottom-28 h-24 bg-gradient-to-r from-transparent via-white/20 to-transparent blur-2xl" />

              <motion.svg aria-hidden="true" viewBox="0 0 1200 400" preserveAspectRatio="xMidYMax slice" className="absolute inset-x-0 bottom-0 h-[330px] w-full" style={{ y: nearY }}>
                <path d={NEAR} className="fill-indigo-950" />
              </motion.svg>

              <motion.div aria-hidden="true" className="absolute inset-0 bg-indigo-950" style={{ opacity: dusk }} />

              <motion.svg aria-hidden="true" viewBox="0 0 1200 400" preserveAspectRatio="xMidYMax slice" className="absolute inset-x-0 bottom-0 h-[350px] w-full" style={{ y: pinesY }}>
                <path d={PINES} className="fill-slate-950" />
                <rect x="0" y="226" width="1200" height="174" className="fill-slate-950" />
              </motion.svg>

              {!reduced && (
                <motion.div aria-hidden="true" className="absolute inset-x-0 bottom-5 flex justify-center" style={{ opacity: hintOpacity }}>
                  <span className="flex items-center gap-2 rounded-full bg-slate-950/60 px-4 py-2 text-xs font-medium text-white backdrop-blur">
                    Scroll the scene
                    <svg viewBox="0 0 20 20" className="parallax-mountain-scene-bob h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                      <path d="M10 4v12M5 11l5 5 5-5" />
                    </svg>
                  </span>
                </motion.div>
              )}

              <div className="absolute inset-x-0 bottom-0 flex justify-center px-4 pb-6 sm:pb-8">
                <motion.div
                  className="w-full max-w-md rounded-2xl border border-white/15 bg-slate-900/80 p-5 text-left shadow-2xl backdrop-blur-md"
                  style={reduced ? undefined : { opacity: cardOpacity, y: cardY, scale: cardScale }}
                >
                  <div className="flex items-start justify-between gap-4">
                    <div>
                      <p className="text-xs font-semibold uppercase tracking-widest text-amber-300">Cabin No. 4</p>
                      <p className="mt-1 text-xl font-semibold text-white">The Larch House</p>
                      <p className="mt-1 text-sm text-slate-300">Sleeps four · wood stove · outdoor tub</p>
                    </div>
                    <p className="shrink-0 text-right text-sm text-slate-300">
                      from <span className="block text-2xl font-semibold tabular-nums text-white">$240</span> a night
                    </p>
                  </div>
                  <a
                    href="#"
                    className="mt-4 flex h-11 items-center justify-center rounded-xl bg-amber-300 text-sm font-semibold text-slate-950 transition-colors hover:bg-amber-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-slate-900"
                  >
                    Check winter dates
                  </a>
                </motion.div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </section>
  );
}

Install with one command: npx shadcn@latest add https://we.inc/r/parallax-mountain-scene-in-scroll-frame.json

When to use it

How to customize it

Accessibility

Questions

How does a parallax scroll effect work?

Every layer reads the same scroll progress, a number from 0 to 1, and multiplies it by a different distance. Layers that move further appear nearer the viewer. Here `useTransform` maps progress to a `y` offset for each layer, and only `transform` and `opacity` change, so it stays smooth.

Why is the scene sticky inside a tall wrapper?

The frame needs something taller than itself in order to scroll. The wrapper provides that height, and the scene uses `position: sticky` so it stays in view for the whole distance while the layers inside it move.

Can I use photos instead of drawn mountains?

Yes. Replace each `motion.svg` with a `motion.img` or a `motion.div` with a background image, keep the `style={{ y }}` prop and use transparent PNG or WebP cut-outs for the nearer layers.

How do I drive it from the page scroll?

Use `useScroll({ target, offset: ["start start", "end end"] })` from framer-motion on the tall wrapper and pass its `scrollYProgress` in place of the `raw` value. Remove the frame's fixed height and overflow, and make the sticky scene `h-screen`.

Is parallax bad for performance?

It is when it animates layout properties or runs heavy work on every scroll event. This version moves a handful of composited layers with transforms and does one division per scroll event.

Related: Sticky Scroll Storytelling with Pinned Visual, Reading Progress Bar with Section Reveals, Dark Hero with Gradient Glow and Product Window.

More scroll effects or all free UI components. Start for free to have a full site built around it.

More in Scroll Effects

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.

Product

Who It's For

Features

Resources

Company

View Sitemap