javascriptcssscrollshopify

Create scrolling effect on images


I am creating a website for my business using shopify, nothing too fancy. I am trying to recreate a fun scrolling effect I noticed on : https://www.highsnobiety.com/ The "highsnobiety New York" part a little down the page. I successfully created the layout (a grid) with correct sizes on desktop and mobile. I added a fade in effect to scroll of the user for the sticky title. All of that is ok. However I am stuck now on this effect: the images move a bit when you scroll down. As an example, when you scroll way down the page and go up again, the images appear as if stacked (in a tetris like manner not on top of each other). When inspecting the Highsnobiety page I noticed the elements of the grid have a TranslateY function that updates with each scroll. Would love to know how I could get close to that effect. My page for now is nice but looks too "flat".

Here is the code I got (I spared you the schema definition of the shopify section) :

{% style %}
  .scroll-media-section {
    position: relative;
    background-color: {{ section.settings.background_color }};
    min-height: 100vh;
    width: 100%;
    box-sizing: border-box;
    overflow: hidden;
  }

  .scroll-media-container {
    position: relative;
    background-color: {{ section.settings.background_color }};
    padding: 0px 0px;
    width: 100%;
    box-sizing: border-box;
    will-change: transform;
    transition-timing-function:ease-out;
    transition-duration:0.5s;
  }


  .scroll-media-sticky-title {
    position: sticky;
    top: 50%;
    transform: translateY(-50px);
    z-index: 10;
    text-align: center;
    pointer-events: none;
    margin: 0;
    opacity: 0;
    transition: opacity 0.6s ease;
  }

  .scroll-media-sticky-title.visible {
    opacity: 1;
  }

  .scroll-media-title-text {
    font-size: {{ section.settings.title_size }}px;
    color: {{ section.settings.title_color }};
    margin: 0;
    font-weight: 900;
    text-transform: uppercase;
    letter-spacing: 2px;
  }

  .scroll-media-title-image {
    max-width: {{ section.settings.logo_max_width }}px;
    height: auto;
  }

  .scroll-media-grid {
    position: relative;
    z-index: 1;
    display: grid;
    grid-template-columns: repeat({{ section.settings.columns_desktop }}, 1fr);
    grid-auto-rows: min-content;
    column-gap:8px;
    width: 100%;
    box-sizing: border-box;
  }

  @media (max-width: 749px) {
    .scroll-media-grid {
      grid-template-columns: repeat({{ section.settings.columns_mobile }}, 1fr);
      column-gap:8px;
    }
  }

  .scroll-media-item {
    width: 100%;
    opacity: 0;
    {%comment %}transform: translateY(50px);{%  endcomment %} 
    transition: opacity 0.8s ease, transform 0.8s ease;
    grid-row: auto;
  }

  .scroll-media-item.visible {
    opacity: 1;
    {%comment %}transform: translateY(var(--y-offset-desktop, 0px));{%  endcomment %} 
  }

  @media (max-width: 749px) {
    .scroll-media-item {
      grid-column: var(--mobile-column-start) / span var(--mobile-column-span) !important;
    }
    
    .scroll-media-item.visible {
     {%comment %} transform: translateY(var(--y-offset-mobile, 0px));{%  endcomment %} 
    }
  }

  .scroll-media-item.visible {
    opacity: 1;
    {%comment %}transform: translateY(0);{%  endcomment %} 
  }

  .scroll-media-content {
    width: 100%;
    border-radius: {{ section.settings.media_border_radius }}px;
    overflow: hidden;
    box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
  }

  .scroll-media-content img,
  .scroll-media-content video {
    width: 100%;
    height: auto;
    display: block;
  }

  .scroll-media-placeholder {
    background: #000;
    aspect-ratio: 1080/1350;
    display: inline-block;
    justify-content: center;
    align-items: center;
    border-radius: {{ section.settings.media_border_radius }}px;
    position: relative;
  }

  .scroll-media-placeholder svg {
    width: 60%;
    height: 60%;
    opacity: 0.3;
  }

  .scroll-media-empty-state {
    position: absolute;
    bottom: 10px;
    left: 50%;
    transform: translateX(-50%);
    background: rgba(255,255,255,0.9);
    padding: 6px 12px;
    font-size: 12px;
    color: #333;
    border-radius: 4px;
  }

  html{
    scroll-behavior: smooth;
    }
{% endstyle %}

<scroll-media-section class="scroll-media-section" {{ section.shopify_attributes }}>
  <div class="scroll-media-container">
    
    <!-- Sticky Title -->
    <div class="scroll-media-sticky-title" id="sticky-title-{{ section.id }}" >
      {% if section.settings.title_type == 'text' and section.settings.title_text != blank %}
        <h2 class="scroll-media-title-text">{{ section.settings.title_text }}</h2>
      {% elsif section.settings.title_type == 'image' and section.settings.title_image %}
        <img
          src="{{ section.settings.title_image | image_url: width: 800 }}"
          alt="{{ section.settings.title_image.alt | escape }}"
          class="scroll-media-title-image"
          loading="lazy"
          width="{{ section.settings.title_image.width }}"
          height="{{ section.settings.title_image.height }}"
        >
      {% endif %}
    </div>

    <div class="scroll-media-grid">
      {% for block in section.blocks %}
        {% assign type = block.settings.media_type %}
        {% assign image = block.settings.media_image %}
        {% assign video = block.settings.media_video %}
        {% assign top_margin = block.settings.top_margin %}
        {% assign bottom_margin = block.settings.bottom_margin %}
        {% assign column_start_desktop = block.settings.column_start_desktop %}
        {% assign column_span_desktop = block.settings.column_span_desktop %}
        {% assign column_start_mobile = block.settings.column_start_mobile %}
        {% assign column_span_mobile = block.settings.column_span_mobile %}
        {% assign y_offset = block.settings.y_offset %}
        {% if type != 'none' %}
          <div class="scroll-media-item" 
               style="
                 margin-top: {{ top_margin }}px; 
                 margin-bottom: {{ bottom_margin }}px;
                 grid-column: {{ column_start_desktop }} / span {{ column_span_desktop }};
                 --mobile-column-start: {{ column_start_mobile }};
                 --mobile-column-span: {{ column_span_mobile }};
                 transform: translateY( {{ y_offset }}%);
                 {% comment %}--y-offset-mobile: {{ y_offset_mobile }}%; {% endcomment %}
                 grid-row:{{ forloop.index }};
               "
               data-scroll-item>
            <div class="scroll-media-content">
              {% if type == 'image' and image %}
                <img
                  src="{{ image | image_url: width: 1200 }}"
                  alt="{{ image.alt | escape }}"
                  loading="lazy"
                  width="{{ image.width }}"
                  height="{{ image.height }}"
                >
              {% elsif type == 'video' and video %}
                <video autoplay muted loop playsinline preload="metadata">
                  <source src="{{ video }}" type="video/mp4">
                </video>
              {% else %}
                <div class="scroll-media-placeholder">
                  {{ 'image' | placeholder_svg_tag }}
                  <div class="scroll-media-empty-state">Add media</div>
                </div>
              {% endif %}
            </div>
          </div>
        {% endif %}
      {% endfor %}
    </div>
  </div>
</scroll-media-section>

<script>
  (function() {
    class ScrollMediaSection extends HTMLElement {
      constructor() {
        super();
        this.items = [];
        this.stickyTitle = null;
        this.observer = null;
        this.scrollObserver = null;
        this.container = null;
      }

      connectedCallback() {
        this.items = this.querySelectorAll('[data-scroll-item]');
        this.stickyTitle = this.querySelector('.scroll-media-sticky-title');
        this.container = this.querySelector('.scroll-media-container');
        this.setupIntersectionObserver();
        this.setupScrollObserver();
      }

      disconnectedCallback() {
        if (this.observer) {
          this.observer.disconnect();
        }
        if (this.scrollObserver) {
          window.removeEventListener('scroll', this.scrollObserver);
        }
      }

      setupIntersectionObserver() {
        const options = {
          root: null,
          rootMargin: '-10% 0px -10% 0px',
          threshold: 0.1
        };

        this.observer = new IntersectionObserver((entries) => {
          entries.forEach((entry) => {
            if (entry.isIntersecting) {
              entry.target.classList.add('visible');
            }
          });
        }, options);

        this.items.forEach((item) => {
          this.observer.observe(item);
        });
      }

      setupScrollObserver() {
        if (!this.stickyTitle || !this.container) return;

        this.scrollObserver = () => {
          const containerRect = this.container.getBoundingClientRect();
          const containerTop = containerRect.top;
          const containerHeight = containerRect.height;
          const scrollPercent = Math.max(0, -containerTop / containerHeight);
          
          if (scrollPercent >= 0.001) {
            this.stickyTitle.classList.add('visible');
          } else {
            this.stickyTitle.classList.remove('visible');
          }
        };

        window.addEventListener('scroll', this.scrollObserver);
      }
    }

    customElements.define('scroll-media-section', ScrollMediaSection);
  })();
</script>


Solution

  • You already have the layout and fade-in working, so you only need to add a small script that updates the position of each grid item based on the scroll position.

    Update your setupScrollObserver function to this:

    setupScrollObserver() {
      if (!this.stickyTitle || !this.container) return;
    
      const updatePositions = () => {
        const containerRect = this.container.getBoundingClientRect();
        const containerTop = containerRect.top;
        const containerHeight = containerRect.height;
        const scrollPercent = Math.max(0, -containerTop / containerHeight);
    
    
        if (scrollPercent >= 0.001) {
          this.stickyTitle.classList.add('visible');
        } else {
          this.stickyTitle.classList.remove('visible');
        }
    
      
        this.items.forEach((item, index) => {
          // Adjust the speed multiplier for more/less motion
          const speed = 20 + (index % 3) * 10;
          const offset = (window.scrollY * speed) / 2000;
          item.style.transform = `translateY(${offset}px)`;
        });
      };
    
    
      let ticking = false;
      window.addEventListener('scroll', () => {
        if (!ticking) {
          window.requestAnimationFrame(() => {
            updatePositions();
            ticking = false;
          });
          ticking = true;
        }
      });
    
      updatePositions(); 
    }
    

    And for smoother animation transitions, you can add this to your CSS:

    .scroll-media-item {
      transition: transform 0.4s cubic-bezier(0.23, 1, 0.32, 1);
      will-change: transform;
    }