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>
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;
}