Oh snap!

The joy and pain of CSS scroll snapping

What we want…

Horizontal scroll

— snaps to day boundaries

— infinite scrolling plane

 

Vertical scroll

— 2x inside horizontal scroll

— + timeline outside of horizontal scroll

 

Scroll directional locking

Horizontal scroll

Horizontal scroll with snap

.snap {
    scroll-snap-type: x mandatory;
}

Horizontal scroll with snap

.snap {
    scroll-snap-type: x mandatory;
}

.snap .card {
    scroll-snap-align: center;
}

Mandatory(ish) stop points

.card:nth-child(7n + 3) {
    background: #5775e4;
    scroll-snap-stop: always;
}

Browser support

Horizontal scroll on an infinite plane

Horizontal scroll on an infinite plane

const children = new Map([[0, card]]);
let start = 0;
let end = 1;
const redraw = () => {
    const left = container.scrollLeft;
    let newStart = Math.floor(left / cardWidth);
    let newEnd = Math.ceil((left + containerWidth) / cardWidth);
    for (let i = start; i < end; i += 1) {
        if (i < newStart || i >= newEnd) {
            children.get(i).remove();
            children.delete(i);
        }
    }
    for (let i = newStart; i < newEnd; i += 1) {
        if (i < start || i >= end) {
            const newCard = card.cloneNode(false);
            newCard.innerHTML = quotes[i % quotes.length];
            newCard.style.left = (i * cardWidth) + 'px';
            newCard.classList.add('n' + (i % 7));
            container.appendChild(newCard);
            children.set(i, newCard);
        }
    }
    start = newStart;
    end = newEnd;
};
redraw();
container.addEventListener('scroll', redraw, true);

Horizontal scroll on an infinite plane, with snapping

container.addEventListener('scroll', () => {
    container.classList.remove('snap');
    redraw();
}, true);
container.addEventListener('scrollend', () => {
    container.classList.add('snap');
}, true);

Horizontal scroll on an infinite plane, with snapping

const redrawSnap = () => {
    const snapcontainer = document.createElement('div');
    snapcontainer.id = 'snapcontainer';
    for (
        let i = Math.max(0, start - 20),
            l = i + 40;
        i < l;
        i += 1
    ) {
        const point = document.createElement('div');
        point.style.left = (i * cardWidth) + 'px';
        point.className = 'point';
        snapcontainer.appendChild(point);
    }
    container.replaceChild(
        snapcontainer,
        document.getElementById('snapcontainer')
    );
}
redrawSnap();
container.addEventListener('scrollend', redrawSnap, true);

2D scrolling

#scrollcontainer {
    overflow: scroll;
}
.card {
    height: 200%;
}

2D scrolling with sticky side

.timeline {
    z-index: 1;
    position: sticky;
    left: 0;
    width: 60px;
    height: 200%;
    display: flex;
    flex-direction: column;
    justify-content: space-around;
    align-items: center;
    background: #fff;
    color: #121416;
    text-align: center;
}

scroll-padding

#scrollcontainer.snap {
    scroll-snap-type: x mandatory;
    scroll-padding-left: 60px;
}

scroll-margin

Directional locking

#scrollcontainer {
    scroll-direction: lock;
}

Putting it all together…

One more gotcha…

The joy

  • Adding snapping for simple static content is really easy.
  • Widespread browser support

The pain

  • scroll-snap-stop is sometimes unreliable
  • Snap scrolling does not combine well with dynamic rendering (but there are workarounds).
  • Safari doesn't support the scrollend event yet.
  • Scroll wheel is janky, and unusable in Safari.

Neil Jenkins

www.nmjenkins.com