DEV Community

Francesco Esposito
Francesco Esposito

Posted on

Smooth Scroll and Fade-in in Vanilla JS: A Portfolio Experiment

In recent months, I've experimented with an alternative approach for smooth scroll and fade-in animations on my personal portfolio site. Instead of relying on libraries like GSAP—which I typically use for client projects—I wanted to test the power of vanilla JavaScript to achieve fluid, performant results without external dependencies.

Why Vanilla JS?
The goal was straightforward: reduce reliance on third-party libraries, improve initial page load times, and deepen understanding of native browser APIs. The outcome exceeded expectations—a customizable smooth scroll paired with scroll-triggered fade-ins that deliver a premium user experience without sacrificing performance.

Core Smooth Scroll Implementation
This vanilla JS smooth scroll uses requestAnimationFrame for silky animations and direct content positioning control. Here's the essential code—plug it into your project by calling initSmoothScroll(wrapper, content) with your container elements.

window._smoothScrollState = {
  rafId: null,
  handlers: {},
  wrapper: null,
  content: null,
  current: 0,
  target: 0
};

window.initSmoothScroll = function (wrapper, content) {
  if (window._smoothScrollState.rafId) {
    window.destroySmoothScroll();
  }

  const ease = 0.08;
  let touchStart = 0;
  const mobileScrollFactor = 2.5;

  Object.assign(wrapper.style, {
    position: 'fixed',
    width: '100vw',
    height: '100vh',
    overflow: 'hidden',
    top: '0',
    left: '0'
  });

  function setBodyHeight() {
    document.body.style.height = content.scrollHeight + 'px'; 
  }

  const onWheel = function(e) {
    e.preventDefault();
    window._smoothScrollState.target += e.deltaY;
    window._smoothScrollState.target = Math.max(
      0,
      Math.min(
        window._smoothScrollState.target,
        content.scrollHeight - window.innerHeight
      )
    );
  };

  const onTouchStart = function(e) {
    touchStart = e.touches[0].clientY;
  };

  const onTouchMove = function(e) {
    e.preventDefault();
    const touchY = e.touches[0].clientY;
    let delta = touchStart - touchY;
    delta *= mobileScrollFactor;
    window._smoothScrollState.target += delta;
    window._smoothScrollState.target = Math.max(
      0,
      Math.min(
        window._smoothScrollState.target,
        content.scrollHeight - window.innerHeight
      )
    );
    touchStart = touchY;
  };

  const onResize = function() {
    setBodyHeight();
    window._smoothScrollState.target = Math.max(
      0,
      Math.min(
        window._smoothScrollState.target,
        content.scrollHeight - window.innerHeight
      )
    );
  };

  function smoothScroll() {
    let state = window._smoothScrollState;
    state.current += (state.target - state.current) * ease;
    if (Math.abs(state.target - state.current) < 0.1) state.current = state.target;
    content.style.transform = `translateY(${-state.current}px)`;
    state.rafId = requestAnimationFrame(smoothScroll);
  }

  window.addEventListener('wheel', onWheel, { passive: false });
  window.addEventListener('touchstart', onTouchStart, { passive: false });
  window.addEventListener('touchmove', onTouchMove, { passive: false });
  window.addEventListener('resize', onResize);

  window._smoothScrollState.handlers = { onWheel, onTouchStart, onTouchMove, onResize };
  window._smoothScrollState.wrapper = wrapper;
  window._smoothScrollState.content = content;

  setBodyHeight();
  smoothScroll();
};

window.destroySmoothScroll = function() {
  const state = window._smoothScrollState;
  if (!state) return;

  if (state.rafId) {
    cancelAnimationFrame(state.rafId);
    state.rafId = null;
  }

  if (state.handlers) {
    window.removeEventListener('wheel', state.handlers.onWheel);
    window.removeEventListener('touchstart', state.handlers.onTouchStart);
    window.removeEventListener('touchmove', state.handlers.onTouchMove);
    window.removeEventListener('resize', state.handlers.onResize);
    state.handlers = {};
  }

  if (state.content) {
    state.content.style.transform = 'translateY(0px)';
  }

  state.current = 0;
  state.target = 0;

  if (state.wrapper) {
    state.wrapper.style.position = '';
    state.wrapper.style.width = '';
    state.wrapper.style.height = '';
    state.wrapper.style.overflow = '';
    state.wrapper.style.top = '';
    state.wrapper.style.left = '';
  }

  document.body.style.height = '';
  state.wrapper = null;
  state.content = null;
};
Enter fullscreen mode Exit fullscreen mode

Scroll-Triggered Fade-ins
For fade-in effects, IntersectionObserver detects when elements enter the viewport and triggers smooth transitions—no polling or heavy loops needed.

function initFadeIns() {
  const elements = document.querySelectorAll('.fade-in');
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.classList.add('visible');
      }
    });
  }, { threshold: 0.1 });

  elements.forEach(el => observer.observe(el));
}

// Initialize on load
initFadeIns();

Enter fullscreen mode Exit fullscreen mode

Pair it with this CSS:

.fade-in {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.6s ease, transform 0.6s ease;
}

.fade-in.visible {
  opacity: 1;
  transform: translateY(0);
}

Enter fullscreen mode Exit fullscreen mode

Key Takeaways
This vanilla approach rivals GSAP in smoothness while keeping bundle sizes tiny and control total. Tweak the ease value for faster/slower scrolls, adjust thresholds for fade timing, and scale it across projects. Drop a comment if you implement it—happy to discuss optimizations!

Top comments (0)