← back to Dw Theme Compact Toolbar
snippets/layout.sticky-scroller.liquid
153 lines
{%- doc -%}
A sticky scroller component that wraps an element. This allows for an
element to be scrollable so that if it's taller than the viewport, it will
scroll as the page scrolls, while also remaining sticky.
@param slot {string} The slot content to wrap
@example
{% render 'layout.sticky-scroller', slot: collection_sidebar %}
{%- enddoc -%}
{% stylesheet %}
:root {
--layout-sticky-scroller-top: 20px;
}
sticky-scroller {
--_top: var(--layout-sticky-scroller-top);
display: block;
position: sticky;
top: var(--_top);
}
{% endstylesheet %}
<sticky-scroller>
{{ slot }}
</sticky-scroller>
{% javascript %}
class StickyScroller extends HTMLElement {
constructor() {
super()
this.resizeObserver = new ResizeObserver(() => this.recalculateStyles())
this.abortController = new AbortController()
this.isScrolling = false
this.position = 'relative'
this.lastKnownScrollY = 0
this.initialTop = 0
this.currentTop = 0
this.boundOnScroll = this.onScroll.bind(this)
}
connectedCallback() {
this.inView(this, this.setupObservers.bind(this), { margin: '500px' })
}
disconnectedCallback() {
this.cleanupObservers()
}
setupObservers() {
this.resizeObserver.observe(this)
window.addEventListener('scroll', this.boundOnScroll, { passive: true, signal: this.abortController.signal })
return this.cleanupObservers.bind(this)
}
cleanupObservers() {
this.abortController.abort()
this.resizeObserver.disconnect()
}
onScroll() {
if (!this.isScrolling) {
requestAnimationFrame(() => {
this.updatePosition()
this.isScrolling = false
})
this.isScrolling = true
}
}
recalculateStyles() {
this.style.removeProperty('top')
const { top, position } = getComputedStyle(this)
this.initialTop = parseInt(top, 10)
this.position = position
this.updatePosition()
}
updatePosition() {
if (this.position !== 'sticky') {
this.style.removeProperty('top')
return
}
const { top, height } = this.getBoundingClientRect()
const maxTop = top + window.scrollY - this.offsetTop + this.initialTop
const minTop = height - window.innerHeight + 20
this.currentTop += this.lastKnownScrollY - window.scrollY
this.currentTop = Math.min(Math.max(this.currentTop, -minTop), maxTop, this.initialTop)
this.lastKnownScrollY = window.scrollY
this.style.top = `${Math.round(this.currentTop)}px`
}
/**
* Original source from https://www.npmjs.com/package/@motionone/dom
* Usage: https://motion.dev/dom/in-view
*/
inView(elementOrSelector, onStart, { root, margin: rootMargin, amount = 'any' } = {}) {
const thresholds = {
any: 0,
all: 1
}
if (typeof IntersectionObserver === 'undefined') {
return () => {}
}
let elements
if (typeof elementOrSelector === 'string') {
elements = document.querySelectorAll(elementOrSelector)
} else if (elementOrSelector instanceof Element) {
elements = [elementOrSelector]
} else {
elements = Array.from(elementOrSelector || [])
}
const activeIntersections = new WeakMap()
const onIntersectionChange = (entries) => {
entries.forEach((entry) => {
const onEnd = activeIntersections.get(entry.target)
if (entry.isIntersecting === Boolean(onEnd)) return
if (entry.isIntersecting) {
const newOnEnd = onStart(entry)
if (typeof newOnEnd === 'function') {
activeIntersections.set(entry.target, newOnEnd)
} else {
observer.unobserve(entry.target)
}
} else if (onEnd) {
onEnd(entry)
activeIntersections.delete(entry.target)
}
})
}
const observer = new IntersectionObserver(onIntersectionChange, {
root,
rootMargin,
threshold: typeof amount === 'number' ? amount : thresholds[amount]
})
elements.forEach((element) => observer.observe(element))
return () => observer.disconnect()
}
}
customElements.define('sticky-scroller', StickyScroller)
{% endjavascript %}