Back To Top Button

A fixed back-to-top button that appears after the user scrolls a configurable distance using GSAP ScrollTrigger, animating in with a rotate and scale entrance. Includes a duplicate arrow for a CSS overflow-clip hover effect. Supports native scrollTo, Lenis, and Locomotive Scroll.

gsapscroll-triggerback-to-topbuttonfixedanimation

Setup — External Scripts

Setup: External Scripts
html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.13.0/dist/ScrollTrigger.min.js"></script>

Code

index.html
html
<div data-back-to-top="wrap" class="back-top__wrap">
  <button data-back-to-top="button" class="back-top__button">
    <div class="back-top__arrow-wrap">
      <div class="back-top__arrow-row">
        <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 60 60" fill="none" class="back-top__arrow">
          <path d="M47.5 25L30 7.5L12.5 25" stroke="currentColor" stroke-width="6" stroke-miterlimit="10" stroke-linecap="round"></path>
          <path d="M30 7.5L30 55" stroke="currentColor" stroke-width="6" stroke-miterlimit="10" stroke-linecap="round"></path>
        </svg>
      </div>
      <div class="back-top__arrow-row">
        <svg xmlns="http://www.w3.org/2000/svg" width="100%" viewbox="0 0 60 60" fill="none" class="back-top__arrow">
          <path d="M47.5 25L30 7.5L12.5 25" stroke="currentColor" stroke-width="6" stroke-miterlimit="10" stroke-linecap="round"></path>
          <path d="M30 7.5L30 55" stroke="currentColor" stroke-width="6" stroke-miterlimit="10" stroke-linecap="round"></path>
        </svg>
      </div>
    </div>
  </button>
</div>
styles.css
css
.back-top__wrap {
  z-index: 100;
  pointer-events: none;
  flex-flow: column;
  justify-content: flex-end;
  align-items: flex-end;
  width: 100%;
  height: 100vh;
  padding: 2em;
  display: flex;
  position: fixed;
  inset: 0%;
}

.back-top__button {
  pointer-events: auto;
  background-color: #fca5a0;
  border: min(.5em, 5px) solid #efeeec;
  border-radius: 1em;
  outline-style: none;
  width: max(5vw, 2.5rem);
  height: max(5vw, 2.5rem);
  padding: .5em;
  position: relative;
}

.back-top__arrow {
  width: 2.5em;
}

.back-top__arrow-wrap {
  flex-flow: column;
  justify-content: flex-start;
  align-items: center;
  width: 100%;
  height: 100%;
  display: flex;
  position: relative;
  overflow: hidden;
}

.back-top__arrow-row {
  flex: none;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 100%;
  display: flex;
}

/* Hide the back-to-top wrapper on default (to prevent flash on loaded site) */
[data-back-to-top="wrap"] { opacity: 0; }

.back-top__arrow-row {
  transition: transform 0.5s cubic-bezier(.65, 0, 0, 1);
}

.back-top__button {
  transition: border-width 0.5s cubic-bezier(.65, 0, 0, 1);
}

/* Keyboard focus state */
.back-top__button:focus-visible {
  border-width: 0.6em;
}

.back-top__button:focus-visible .back-top__arrow-row {
  transform: translate(0px, -100%);
}

/* Hover styling */
@media (hover: hover) {
  .back-top__button:hover {
    border-width: 0.6em;
  }

  .back-top__button:hover .back-top__arrow-row {
    transform: translate(0px, -100%);
  }
}

@media screen and (max-width: 991px) {
  .back-top__wrap {
    padding: 1.25em;
  }

  .back-top__button {
    border-radius: .5em;
    padding: .4em;
  }
}
script.js
javascript
gsap.registerPlugin(ScrollTrigger);

function initBackToTop() {
  const buttonWrap = document.querySelector('[data-back-to-top="wrap"]');
  const button = document.querySelector('[data-back-to-top="button"]');
  if (!button || !buttonWrap) return;

  // The minimum distance the page must be scrolled (in VH) for the button to appear
  let minimumScrollDistance = 50;

  // Un-do the initial CSS styling to hide the wrapper
  gsap.set(buttonWrap, { autoAlpha: 1 });

  // Hide the button itself
  gsap.set(button, { autoAlpha: 0 });

  ScrollTrigger.create({
    trigger: document.body,
    start: `top top-=${minimumScrollDistance}%`,
    onEnter: () => {
      gsap.fromTo(button, {
        autoAlpha: 0,
        rotate: -65,
        scale: 0.4,
      }, {
        autoAlpha: 1,
        rotate: 0,
        scale: 1,
        duration: 0.45,
        ease: "power4.out"
      });
    },
    onLeaveBack: () => {
      gsap.to(button, {
        autoAlpha: 0,
        rotate: -65,
        scale: 0.6,
        duration: 0.4,
        ease: "power4.out"
      });
    },
  });

  button.addEventListener('click', () => {
    window.scrollTo({ top: 0, behavior: 'smooth' });
  });
}

// Initialize Back to Top Button
document.addEventListener("DOMContentLoaded", () => {
  initBackToTop();
});

Guide

Structure

Two elements are required: a fixed wrapper with data-back-to-top="wrap" (pointer-events: none so it doesn't block page interaction) and a button inside with data-back-to-top="button" (pointer-events: auto). The wrapper needs a high z-index to sit on top of other content.

Visibility on Load

The wrapper has opacity: 0 in CSS to prevent a flash of the button before JavaScript runs. The script uses gsap.set to restore it to autoAlpha: 1, then immediately hides the button itself, keeping it invisible until the scroll threshold is met.

Scroll Threshold

The minimumScrollDistance variable (default 50) controls how many percent of the viewport height the user must scroll before the button appears. Set it to 100 to require a full viewport scroll.

Arrow Hover Effect

The HTML includes two identical arrow SVGs stacked in .back-top__arrow-wrap with overflow: hidden. On hover or focus, both rows translate up by 100%, revealing the second arrow climbing in from below.

Scroll Alternatives

The default click handler uses window.scrollTo. For Lenis, replace it with: lenis.scrollTo(0, { lerp: 0.1 }). For Locomotive Scroll v5: locomotiveScroll.scrollTo(0, { lerp: 0.1 }).