3D Scrolling Gallery/Timeline using JavaScript

The post discusses creating a 3D scrolling timeline using HTML, CSS, and JavaScript to create an immersive, interactive experience. The walkthrough includes HTML code for a template, CSS for styling, and JavaScript for interactivity. Importantly, the author advises not to just copy the code but to understand it thoroughly. The article encourages reaching out for further assistance if needed.
Share your love

Imagine a gallery where time itself bends and twists! We’ll build a 3D scrolling timeline that lets users explore your work in a captivating, immersive journey.

I would recommend you don’t just copy and paste the code, just look at the code and type by understanding it.

Demo

HTML Code 

Starter Template

<!doctype html>
<html lang="en">

<head>
    <!-- Required meta tags -->
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- CSS -->
    <link rel="stylesheet" href="style.css">

    <title>Linear Style Cursor Glow Effect using HTML CSS and JavaScript - Coding Torque</title>
</head>

<body>
    <!-- Further code here -->

    <script src="script.js"></script>
</body>

</html>

Paste the below code in your <body> tag.

<div id="scrollDist"></div>

<div id="app">
  
  <svg id="headlines" fill="none" stroke="#fff" stroke-width="3" viewBox="0 0 586 150">
    
    <g id="txt1">
      <path d="M40.2,16.9c-5,0-9.1,1-12.3,3.1S23,25.1,23,29.3c0,4.1,1.6,7.3,4.8,9.5s10,4.6,20.5,7.1 c10.5,2.5,18.3,6.1,23.7,10.7c5.3,4.6,8,11.3,8,20.2c0,8.9-3.4,16.1-10.1,21.7c-6.7,5.5-15.5,8.3-26.4,8.3 c-16,0-30.1-5.5-42.5-16.5l10.8-13c10.3,9,21,13.4,32.1,13.4c5.5,0,10-1.2,13.2-3.6c3.3-2.4,4.9-5.5,4.9-9.5s-1.5-7-4.6-9.2 s-8.3-4.2-15.8-6c-7.5-1.8-13.2-3.5-17.1-5c-3.9-1.5-7.4-3.5-10.4-5.9c-6-4.6-9.1-11.6-9.1-21c0-9.4,3.4-16.7,10.3-21.8 C22.2,3.6,30.7,1,40.9,1c6.5,0,13,1.1,19.4,3.2c6.4,2.1,12,5.2,16.6,9.1l-9.2,13c-3-2.7-7.1-5-12.3-6.7 C50.3,17.8,45.2,16.9,40.2,16.9z"/>
      <path d="M147.9,89.9c5.9,0,11-1,15.3-3c4.3-2,8.8-5.2,13.4-9.6l11.1,11.4c-10.8,12-23.9,18-39.3,18 c-15.4,0-28.2-5-38.4-14.9c-10.2-9.9-15.3-22.5-15.3-37.7s5.2-27.8,15.5-38C120.6,6.1,133.7,1,149.6,1c15.8,0,29,5.8,39.6,17.5 l-11,12c-4.9-4.7-9.5-7.9-13.8-9.8c-4.3-1.8-9.4-2.8-15.3-2.8c-10.3,0-19,3.3-26,10c-7,6.7-10.5,15.2-10.5,25.6 c0,10.4,3.5,19,10.4,26C130.1,86.4,138.3,89.9,147.9,89.9z"/>
      <path d="M290.2,36.6c0,16.8-7.3,27.4-22,31.8l26.7,37.1H273l-24.4-34.3H226v34.3h-17.2V3.5h38 c15.6,0,26.7,2.6,33.4,7.9C286.9,16.6,290.2,25,290.2,36.6z M267.3,51.1c3.5-3,5.3-7.9,5.3-14.5c0-6.7-1.8-11.2-5.4-13.7 c-3.6-2.5-10-3.7-19.3-3.7H226v36.5h21.5C257.2,55.6,263.8,54.1,267.3,51.1z"/>
      <path d="M400.5,91.4c-10.3,10.1-23.1,15.1-38.3,15.1c-15.2,0-27.9-5-38.3-15.1c-10.3-10.1-15.5-22.6-15.5-37.7 s5.2-27.6,15.5-37.7C334.3,6,347,1,362.2,1c15.2,0,27.9,5,38.3,15.1c10.3,10.1,15.5,22.6,15.5,37.7S410.8,81.4,400.5,91.4z  M387.8,27.6c-7-7.2-15.5-10.8-25.6-10.8c-10.1,0-18.7,3.6-25.6,10.8c-7,7.2-10.4,15.9-10.4,26.2c0,10.3,3.5,19,10.4,26.2 c7,7.2,15.5,10.8,25.6,10.8c10.1,0,18.7-3.6,25.6-10.8c7-7.2,10.4-15.9,10.4-26.2C398.3,43.5,394.8,34.8,387.8,27.6z"/>
      <path d="M437.7,105.5V3.5h17.2v85.7h46.6v16.4H437.7z"/>
      <path d="M520.3,105.5V3.5h17.2v85.7h46.6v16.4H520.3z"/>
    </g>
    
    <g id="txt2">
      <path d="M210.7,1v16.2h-54.5v27h48.9v15.3h-48.9v27.3h56.2v16.2H139V1H210.7z"/>
	    <path d="M311,1h17.2v102.1h-18.7l-57.8-74.5v74.5h-17.2V1h17.2L311,77.2V1z"/>
	    <path d="M433.8,14.4c9.8,8.9,14.7,21.3,14.7,37.2c0,15.9-4.8,28.4-14.3,37.7c-9.5,9.2-24.1,13.9-43.8,13.9h-33.9V1h35 C409.9,1,423.9,5.5,433.8,14.4z M431.1,52c0-23.4-13.4-35-40.1-35h-17.2v69.9h19.1c12.4,0,21.8-2.9,28.4-8.8 C427.9,72.1,431.1,63.4,431.1,52z"/>
    </g>
    
  </svg>
  
  <div id="imgGroup">
    <img src="https://picsum.photos/id/100/1000/800" data-x="300" data-y="0" alt="Sepia tone beach">
    <img src="https://picsum.photos/id/111/1000/800" data-x="200" data-y="250" alt="Vintage car">
    <img src="https://picsum.photos/id/140/1000/800" data-x="-100" data-y="-150" alt="Bare tree">
    <img src="https://picsum.photos/id/160/1000/800" data-x="-500" data-y="50" alt="Bottom edge of a phone">
    <img src="https://picsum.photos/id/180/1000/800" data-x="-60" data-y="-10" alt="Laptop and Moleskine">
    <img src="https://picsum.photos/id/198/1000/800" data-x="-200" data-y="-200" alt="Grassy hillside">
    <img src="https://picsum.photos/id/210/1000/800" data-x="100" data-y="-150" alt="Bricks and mortar">
    <img src="https://picsum.photos/id/220/1000/800" data-x="-300" data-y="50" alt="Foggy train tracks">
    <img src="https://picsum.photos/id/240/1000/800" data-x="-50" data-y="-200" alt="Stairs to water">
    <img src="https://picsum.photos/id/260/1000/800" data-x="-120" data-y="60" alt="Snowy mountain forest">
    <img src="https://picsum.photos/id/280/1000/800" data-x="400" data-y="-100" alt="Rocky jetty">
    <img src="https://picsum.photos/id/360/1000/800" data-x="-60" data-y="150" alt="Peachy flowers">
    <img src="https://picsum.photos/id/320/1000/800" data-x="-200" data-y="200" alt="City street">
    <img src="https://picsum.photos/id/340/1000/800" data-x="300" data-y="-120" alt="Mossy tree">
  </div>
  
  <div id="detail">
    <div id="detailImg"></div>
    <div id="detailTxt"></div>
  </div>
  
  <svg width="100%" height="100%" fill="none" stroke="#fff">
    <g id="cursor">
      <circle id="cursorCircle" cx="0" cy="0" r="12" stroke-width="3"/>
      <path id="cursorClose" d="M-25,-25 L25,25 M-25,25 L25,-25" opacity="0" stroke-width="3.5"/>
    </g>
  </svg>
</div>

CSS Code 

Create a file style.css and paste the code below.

@import url('https://fonts.googleapis.com/css2?family=Montserrat&display=swap');

html, body, #app {
  width:100%;
  height:100%;
  margin:0;
  padding:0;
  font-size:0;
  font-family: 'Montserrat', sans-serif;
}

#app {
  opacity:0;
  height:auto;
  background:radial-gradient(#ccc, #999);
}

#app img {
  display:block;
}

#detail {
  position:absolute;
  width:100%;
  height:100%;
  background:#111;
  top:100%;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:space-evenly;
}

#detailImg {
  width:85%;
  height:85%;
}

#detailTxt {
  color:#ccc;
  font-size:20px;
  letter-spacing:1px;
}

svg {
  pointer-events:none;
  position:absolute;
  top:0;
  left:0;
}

#headlines {
  max-width:800px;
  min-width:450px;
  left:50%;
  top:50%;
  transform:translate(-50%, -50%);
}

JavaScript Code 

Create a file script.js and paste the code below.

window.onload=()=>{
  
  gsap.set('#scrollDist', {
    width: '100%',
    height: gsap.getProperty('#app', 'height'), // apply the height of the image stack
    onComplete:()=>{
      gsap.set('#app, #imgGroup', {opacity:1, position:'fixed', width:'100%', height:'100%', top:0, left:0, perspective:300}) 
      gsap.set('#app img', {
        position: 'absolute',
        attr:{ id:(i,t,a)=>{ //use GSAP's built-in loop to setup each image
          initImg(i,t);
          return 'img'+i;
        }}
      })

      gsap.timeline({
        defaults:{ duration:1 },
        onUpdate:()=>{ if (gsap.getProperty('#cursorClose','opacity')==1) closeDetail() }, //close detail view on scroll
        scrollTrigger:{
        trigger: '#scrollDist',
        start: 'top top',
        end: 'bottom bottom',
        scrub: 1
      }})
      .fromTo('#txt1', {scale:0.6, transformOrigin:'50%'}, {scale:2, ease:'power1.in'}, 0)
      .to('#txt1 path', {duration:0.3, drawSVG:0, stagger:0.05, ease:'power1.in'}, 0)
      .fromTo('.imgBox', {z:-5000}, {z:350, stagger:-0.3, ease:'none'}, 0.3)
      .fromTo('.imgBox img', {scale:3}, {scale:1.15, stagger:-0.3, ease:'none'}, 0.3)      
      .to('.imgBox', {duration:0, pointerEvents:'auto', stagger:-0.3}, 0.5)
      .from('.imgBox img', {duration:0.3, opacity:0, stagger:-0.3, ease:'power1.inOut'}, 0.3)
      .to('.imgBox img', {duration:0.1, opacity:0, stagger:-0.3, ease:'expo.inOut'}, 1.2)      
      .to('.imgBox', {duration:0, pointerEvents:'none', stagger:-0.3}, 1.27)
      .add('end')
      .fromTo('#txt2', {scale:0.1, transformOrigin:'50%'},{scale:0.6, ease:'power3'}, 'end-=0.2')
      .from('#txt2 path', {duration:0.4, drawSVG:0, ease:'sine.inOut', stagger:0.15}, 'end-=0.2')      
            
      // intro animation
      gsap.from(window, {duration:1.4, scrollTo:gsap.getProperty('#scrollDist','height')/3, ease:'power2.in'});
      gsap.from('.imgBox', {duration:0.2, opacity:0, stagger:0.06, ease:'power1.inOut'})
    }  

  })  
  
  function initImg(i,t){
    const box = document.createElement('div') // make a container div
    box.appendChild(t) // move the target image into the container
    document.getElementById('imgGroup').appendChild(box) // put the container into the imgGroup div
    gsap.set(box, {
        pointerEvents:'none',
        position:'absolute',
        attr:{ id:'box'+i, class:'imgBox' },
        width:t.width,
        height:t.height,
        overflow:'hidden',
        top:'50%',
        left:'50%',
        x:t.dataset.x,
        y:t.dataset.y,
        xPercent:-50,
        yPercent:-50,
        perspective:500
      })

    t.onmouseover =()=> gsap.to('#cursorCircle', {duration:0.2, attr:{r:30, 'stroke-width':4}})
    
    t.onmousedown =()=> {
      gsap.to(t, {z:-25, ease:'power2'})
      gsap.to('#cursorCircle', {attr:{r:40}, ease:'power3'})
    }
    
    t.onmouseup =()=> gsap.to(t, {z:0, ease:'power1.inOut'})
    
    t.onmouseout =()=> gsap.to('#cursorCircle', {duration:0.2, attr:{r:11, 'stroke-width':3}})
    
    t.onclick =()=> showDetail(t)
  }  
  
  function showDetail(t){
    gsap.timeline()
        .set('#detailTxt', {textContent:t.alt}, 0)
        .set('#detailImg', {background:'url('+t.src+') center no-repeat'}, 0)
        .fromTo('#detail', {top:'100%'}, {top:0, ease:'expo.inOut'}, 0)
        .fromTo('#detailImg', {y:'100%'}, {y:'0%', ease:'expo', duration:0.7}, 0.2)
        .fromTo('#detailTxt', {opacity:0}, {opacity:1, ease:'power2.inOut'}, 0.4)
        .to('#cursorCircle', {duration:0.2, opacity:0}, 0.2)
        .to('#cursorClose', {duration:0.2, opacity:1}, 0.4)
  }
  
  function closeDetail(){
    gsap.timeline()
        .to('#detailTxt', {duration:0.3, opacity:0}, 0)    
        .to('#detailImg', {duration:0.3, y:'-100%', ease:'power1.in'}, 0)
        .to('#detail', {duration:0.3, top:'-100%', ease:'expo.in'}, 0.1)
        .to('#cursorClose', {duration:0.1, opacity:0}, 0)
        .to('#cursorCircle', {duration:0.2, opacity:1}, 0.1)
  }
  document.getElementById('detail').onclick = closeDetail;
  
  if (ScrollTrigger.isTouch==1) { // on mobile, hide mouse follower + remove the x/y positioning from the images
    gsap.set('#cursor', {opacity:0}) 
    gsap.set('.imgBox', {x:0, y:0})
  } else {
    
    // quickTo can be used to optimize x/y movement on the cursor...but it doesn't work on fancier props like 'xPercent'
    cursorX = gsap.quickTo('#cursor', 'x', {duration:0.3, ease:'power2'})
    cursorY = gsap.quickTo('#cursor', 'y', {duration:0.3, ease:'power2'})
    
    window.onmousemove =(e)=> {      
      gsap.to('.imgBox', { // move + rotate imgBoxes relative to mouse position
        xPercent:-e.clientX/innerWidth*100,
        yPercent:-25-e.clientY/innerHeight*50,
        rotateX:8-e.clientY/innerHeight*16,
        rotateY:-8+e.clientX/innerWidth*16
      })
      
      gsap.to('.imgBox img', { // move images inside each imgBox, creates additional parallax effect
        xPercent:-e.clientX/innerWidth*10,
        yPercent:-5-e.clientY/innerHeight*10
      })
      
      // mouse follower
      cursorX(e.clientX)
      cursorY(e.clientY)
    }
  }
}

Final Output

Written by: Piyush Patil

Code Credits: https://codepen.io/creativeocean/pen/gOvYEgq

If you found any mistakes or have any doubts please feel free to Contact Us

Hope you find this post helpful💖

Share your love