Parallax scroll effects on website

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_crm_chasseurs_493_0.webp
    CRM development for Chasseurs
    847
  • image_website-sbh_0.png
    Website development for SBH Partners
    999
  • image_website-_0.png
    Website development for Red Pear
    451

Implementing Parallax Scroll Effects on a Website

Parallax is different speed movement of layers during scroll, creating an illusion of depth. Implemented three ways: CSS background-attachment: fixed (limited), JavaScript with requestAnimationFrame (universal), or GSAP ScrollTrigger scrub (smoothest). CSS approach breaks on iOS Safari due to how mobile browser optimizes scroll rendering — JavaScript is almost always needed.

CSS Parallax (Desktop Only)

/* Background images only, doesn't work on iOS Safari */
.parallax-section {
  background-image: url('/hero-bg.jpg');
  background-attachment: fixed;
  background-size: cover;
  background-position: center;
  min-height: 100vh;
}

/* Alternative via transform with will-change */
.parallax-layer {
  will-change: transform;
  transform: translateZ(0); /* force GPU acceleration */
}

JavaScript: Optimized Parallax

Key performance rules:

  1. Read scrollY only from scroll event (or IntersectionObserver for visibility detection)
  2. Write to DOM only via requestAnimationFrame
  3. Use transform instead of top/left — doesn't cause reflow
  4. Add will-change: transform before starting animation
// hooks/useParallax.ts
import { useEffect, useRef, useState } from 'react'

interface ParallaxOptions {
  speed?: number        // 0 = no movement, 1 = with scroll, -1 = reverse
  direction?: 'vertical' | 'horizontal'
  disabled?: boolean    // for mobile
}

export function useParallax({
  speed = 0.5,
  direction = 'vertical',
  disabled = false,
}: ParallaxOptions = {}) {
  const elementRef = useRef<HTMLElement>(null)
  const rafRef = useRef<number | null>(null)
  const lastScrollY = useRef(0)

  useEffect(() => {
    if (disabled || !elementRef.current) return

    const el = elementRef.current
    el.style.willChange = 'transform'

    let ticking = false

    const updateTransform = () => {
      const rect = el.getBoundingClientRect()
      const viewportCenter = window.innerHeight / 2
      const elementCenter = rect.top + rect.height / 2
      const distanceFromCenter = elementCenter - viewportCenter

      const offset = -distanceFromCenter * (speed - 1)

      if (direction === 'vertical') {
        el.style.transform = `translateY(${offset}px)`
      } else {
        el.style.transform = `translateX(${offset}px)`
      }

      ticking = false
    }

    const onScroll = () => {
      if (!ticking) {
        rafRef.current = requestAnimationFrame(updateTransform)
        ticking = true
      }
    }

    window.addEventListener('scroll', onScroll, { passive: true })
    updateTransform() // initial position

    return () => {
      window.removeEventListener('scroll', onScroll)
      if (rafRef.current) cancelAnimationFrame(rafRef.current)
      el.style.willChange = ''
      el.style.transform = ''
    }
  }, [speed, direction, disabled])

  return elementRef
}
// components/ParallaxImage.tsx
import { useParallax } from '../hooks/useParallax'

interface ParallaxImageProps {
  src: string
  alt: string
  speed?: number
  className?: string
}

export function ParallaxImage({ src, alt, speed = 0.6, className }: ParallaxImageProps) {
  // Disable parallax on mobile — save resources
  const isMobile = typeof window !== 'undefined'
    ? window.matchMedia('(max-width: 768px)').matches
    : false

  const ref = useParallax({ speed, disabled: isMobile })

  return (
    <div className="overflow-hidden">
      <img
        ref={ref as React.RefObject<HTMLImageElement>}
        src={src}
        alt={alt}
        className={className}
        // Slightly enlarge image to hide edges during parallax
        style={{ transform: 'scale(1.1)', transformOrigin: 'center' }}
      />
    </div>
  )
}

GSAP ScrollTrigger Scrub (Recommended)

GSAP handles parallax via scrub — binds animation to scroll position with optional smoothing:

// components/ParallaxSection.tsx
import { useEffect, useRef } from 'react'
import { gsap } from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'

gsap.registerPlugin(ScrollTrigger)

export function ParallaxSection() {
  const sectionRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const ctx = gsap.context(() => {
      // Layer 1: slow background
      gsap.to('.bg-layer', {
        yPercent: -20,
        ease: 'none',
        scrollTrigger: {
          trigger: sectionRef.current,
          start: 'top bottom',
          end: 'bottom top',
          scrub: true,
        },
      })

      // Layer 2: mid-ground
      gsap.to('.mid-layer', {
        yPercent: -40,
        ease: 'none',
        scrollTrigger: {
          trigger: sectionRef.current,
          start: 'top bottom',
          end: 'bottom top',
          scrub: 1.5,  // smoothing delay in seconds
        },
      })

      // Layer 3: foreground (fastest)
      gsap.to('.fg-layer', {
        yPercent: -60,
        ease: 'none',
        scrollTrigger: {
          trigger: sectionRef.current,
          start: 'top bottom',
          end: 'bottom top',
          scrub: 2,
        },
      })

      // Horizontal parallax for decorative elements
      gsap.to('.float-left', {
        x: -50,
        ease: 'none',
        scrollTrigger: {
          trigger: sectionRef.current,
          start: 'top bottom',
          end: 'bottom top',
          scrub: true,
        },
      })

      gsap.to('.float-right', {
        x: 50,
        ease: 'none',
        scrollTrigger: {
          trigger: sectionRef.current,
          start: 'top bottom',
          end: 'bottom top',
          scrub: true,
        },
      })
    }, sectionRef)

    return () => ctx.revert()
  }, [])

  return (
    <div ref={sectionRef} className="relative h-screen overflow-hidden">
      <div className="bg-layer absolute inset-0 bg-cover bg-center"
        style={{ backgroundImage: "url('/bg-mountains.jpg')", height: '120%', top: '-10%' }}
      />
      <div className="mid-layer absolute inset-0 flex items-center justify-center">
        <h2 className="text-6xl font-bold text-white">Title</h2>
      </div>
      <div className="fg-layer absolute bottom-0 w-full">
        <svg viewBox="0 0 1440 200">{/* clouds, trees */}</svg>
      </div>
    </div>
  )
}

Mouse Parallax (3D Card Tilt)

// components/TiltCard.tsx
import { useRef, MouseEvent } from 'react'
import { motion, useMotionValue, useSpring, useTransform } from 'framer-motion'

export function TiltCard({ children }: { children: React.ReactNode }) {
  const cardRef = useRef<HTMLDivElement>(null)
  const mouseX = useMotionValue(0)
  const mouseY = useMotionValue(0)

  const rotateX = useSpring(useTransform(mouseY, [-0.5, 0.5], [10, -10]), {
    stiffness: 200, damping: 20
  })
  const rotateY = useSpring(useTransform(mouseX, [-0.5, 0.5], [-10, 10]), {
    stiffness: 200, damping: 20
  })

  const handleMouseMove = (e: MouseEvent) => {
    const rect = cardRef.current!.getBoundingClientRect()
    const x = (e.clientX - rect.left) / rect.width - 0.5
    const y = (e.clientY - rect.top) / rect.height - 0.5
    mouseX.set(x)
    mouseY.set(y)
  }

  const handleMouseLeave = () => {
    mouseX.set(0)
    mouseY.set(0)
  }

  return (
    <motion.div
      ref={cardRef}
      style={{ rotateX, rotateY, transformStyle: 'preserve-3d' }}
      onMouseMove={handleMouseMove}
      onMouseLeave={handleMouseLeave}
      className="cursor-pointer"
    >
      {children}
    </motion.div>
  )
}

Respect prefers-reduced-motion

// hooks/useReducedMotion.ts
import { useEffect, useState } from 'react'

export function useReducedMotion(): boolean {
  const [reducedMotion, setReducedMotion] = useState(false)

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
    setReducedMotion(mq.matches)
    const handler = (e: MediaQueryListEvent) => setReducedMotion(e.matches)
    mq.addEventListener('change', handler)
    return () => mq.removeEventListener('change', handler)
  }, [])

  return reducedMotion
}

Typical Timelines

CSS parallax for one hero section — 3–4 hours. JS/GSAP parallax for 3–5 sections with multiple layers — 2–3 working days. Full scene with mouse, 3D tilt, mobile fallback, and tests — 3–5 days.