Fixed links being clicked, when draggind a slide

This commit is contained in:
vollstock committed 2026-10-05 09:53:35 +02:00
1 parent 586fef4fb7
commit 53238d2a1b
2 files changed
+113 -99

No files matched your search

+4 -4
View File
@@ -8,10 +8,10 @@ It does not implement every feature found in other carousel libraries. Instead,
Mouse-drag behavior closely follows native touch scrolling in the browser, including inertia and snapping to a slide. Thus dragging with the mouse feels pretty identical to native scrolling on a touch device or using a trackpad.
| File | Size | gzip |
| ---------- | ------: | -----: |
| snappy.js | 7.5k kB | 2.2 KB |
| snappy.css | 0.9 B | 0.4 KB |
| File | Size | Ggzip | Brotli |
| ---------- | ------: | ------: | ------: |
| `snappy.css` | 0.91 kB | 0.43 kB | 0.36 kB |
| `snappy.js` | 7.65 kB | 2.20 kB | 1.95 kB |
## Usage
+109 -95
View File
@@ -30,9 +30,12 @@ class Carousel {
// Behavioral constants
inertia = 0.92; // Decay rate (0.9 = slower stop, 0.99 = longer slide)
velocityFrameCount = 2;
dragThreshold = 5;
// State variables
isDragging = false;
hasDragged = false;
suppressNextClick = false;
dragFrame = null;
pendingDragPosition = null;
scrollUpdateFrame = null;
@@ -65,8 +68,20 @@ class Carousel {
this.scrollContainer.addEventListener('mousemove', this.drag.bind(this));
this.scrollContainer.addEventListener('mouseup', this.endDrag.bind(this));
this.scrollContainer.addEventListener('mouseleave', this.endDrag.bind(this));
this.scrollContainer.addEventListener('click', event => {
if (!this.suppressNextClick) return;
this.scrollContainer.addEventListener('touchstart', this.resetDragStates.bind(this));
this.suppressNextClick = false;
if (event.detail === 0) return;
event.preventDefault();
event.stopPropagation();
}, true);
this.scrollContainer.addEventListener('touchstart', () => {
this.suppressNextClick = false;
this.resetDragStates();
});
this.scrollContainer.addEventListener('wheel', this.resetDragStates.bind(this));
this.scrollContainer.addEventListener('scroll', () => {
@@ -100,6 +115,8 @@ class Carousel {
* @returns {void}
*/
startDrag(e) {
this.suppressNextClick = false;
this.hasDragged = false;
this.isDragging = true;
this.autoScrolling = false;
this.scrollContainer.classList.add('is-dragging'); // Disable scroll snapping
@@ -126,6 +143,7 @@ class Carousel {
this.pendingPointerPosition = pointerPosition;
const distance = pointerPosition - this.startPosition;
if (Math.abs(distance) >= this.dragThreshold) this.hasDragged = true;
this.pendingDragPosition = this.startScrollPosition - distance;
// Keep at most one pending write; the callback applies the newest position.
@@ -145,6 +163,7 @@ class Carousel {
if (!this.isDragging) return;
this.flushPendingDragPosition();
this.isDragging = false;
this.suppressNextClick = this.hasDragged;
// If the drag velocity is low at release, let the native scroll snap take over
if (Math.abs(this.velocity) < 0.05) {
@@ -233,68 +252,88 @@ class Carousel {
}
}
/**
* Read the current snapport geometry and reachable scroll range.
* @returns {object} The snapport metrics for the scrolling axis.
*/
getSnapport() {
const container = this.scrollContainer;
const isVertical = this.direction === 'y';
const startEdge = isVertical ? 'top' : 'left';
const paddingStart = isVertical ? 'scrollPaddingTop' : 'scrollPaddingLeft';
const paddingEnd = isVertical ? 'scrollPaddingBottom' : 'scrollPaddingRight';
const cssPixels = value => Number.parseFloat(value) || 0;
const containerRect = container.getBoundingClientRect();
const containerStyle = getComputedStyle(container);
const snapportStartEdge = containerRect[startEdge] +
(isVertical ? container.clientTop : container.clientLeft);
const clientExtent = isVertical ? container.clientHeight : container.clientWidth;
const snapportStart = snapportStartEdge + cssPixels(containerStyle[paddingStart]);
const snapportEnd = snapportStartEdge + clientExtent - cssPixels(containerStyle[paddingEnd]);
const maxScrollPosition = isVertical
? container.scrollHeight - container.clientHeight
: container.scrollWidth - container.clientWidth;
return {
isVertical,
snapportStart,
snapportEnd,
scrollPosition: this.getScrollPosition(),
maxScrollPosition,
clamp: value => Math.max(0, Math.min(value, maxScrollPosition))
};
}
/**
* Calculate a slide's snap position within the current snapport.
* @param {HTMLElement} slide - The slide to measure.
* @param {object} snapport - The current snapport metrics.
* @returns {number} The target scroll offset before range clamping.
*/
getSlideSnapPosition(slide, snapport) {
const { isVertical, snapportStart, snapportEnd, scrollPosition } = snapport;
const startEdge = isVertical ? 'top' : 'left';
const endEdge = isVertical ? 'bottom' : 'right';
const marginStart = isVertical ? 'scrollMarginTop' : 'scrollMarginLeft';
const marginEnd = isVertical ? 'scrollMarginBottom' : 'scrollMarginRight';
const cssPixels = value => Number.parseFloat(value) || 0;
const slideRect = slide.getBoundingClientRect();
const slideStyle = getComputedStyle(slide);
const snapAreaStart = slideRect[startEdge] - cssPixels(slideStyle[marginStart]);
const snapAreaEnd = slideRect[endEdge] + cssPixels(slideStyle[marginEnd]);
const alignments = slideStyle.scrollSnapAlign.trim().split(/\s+/);
const align = isVertical ? alignments[0] : alignments[1] ?? alignments[0];
if (align === 'start') {
return scrollPosition + snapAreaStart - snapportStart;
}
if (align === 'end') {
return scrollPosition + snapAreaEnd - snapportEnd;
}
const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2;
const snapportCenter = (snapportStart + snapportEnd) / 2;
return scrollPosition + snapAreaCenter - snapportCenter;
}
/**
* Choose the reachable snap target closest to the inertia-projected stopping point.
* @returns {number} The target scroll offset in pixels.
*/
calculateDragTarget() {
const element = this.scrollContainer;
const isVertical = this.direction === 'y';
const maxScrollPosition = isVertical
? element.scrollHeight - element.clientHeight
: element.scrollWidth - element.clientWidth;
const clamp = value => Math.max(0, Math.min(value, maxScrollPosition));
const cssPixels = value => Number.parseFloat(value) || 0;
const startEdge = isVertical ? 'top' : 'left';
const endEdge = isVertical ? 'bottom' : 'right';
const paddingStart = isVertical ? 'scrollPaddingTop' : 'scrollPaddingLeft';
const paddingEnd = isVertical ? 'scrollPaddingBottom' : 'scrollPaddingRight';
const marginStart = isVertical ? 'scrollMarginTop' : 'scrollMarginLeft';
const marginEnd = isVertical ? 'scrollMarginBottom' : 'scrollMarginRight';
const snapport = this.getSnapport();
// Project the stopping point from velocity, then constrain it to the scroll range.
const projectedStop = clamp(
const projectedStop = snapport.clamp(
this.getScrollPosition() - this.velocity * 10 / (1 - this.inertia)
);
if (!this.slides.length) return clamp(projectedStop);
if (!this.slides.length) return projectedStop;
const elementRect = element.getBoundingClientRect();
const elementStyle = getComputedStyle(element);
// Scroll padding defines the snapport; scroll margins define each slide's snap area.
const snapportStartEdge =
elementRect[startEdge] + (isVertical ? element.clientTop : element.clientLeft);
const clientExtent = isVertical ? element.clientHeight : element.clientWidth;
const snapportStart =
snapportStartEdge + cssPixels(elementStyle[paddingStart]);
const snapportEnd =
snapportStartEdge + clientExtent - cssPixels(elementStyle[paddingEnd]);
const targets = [...this.slides].map(slide => {
const slideRect = slide.getBoundingClientRect();
const slideStyle = getComputedStyle(slide);
const snapAreaStart =
slideRect[startEdge] - cssPixels(slideStyle[marginStart]);
const snapAreaEnd =
slideRect[endEdge] + cssPixels(slideStyle[marginEnd]);
const alignments = slideStyle.scrollSnapAlign.trim().split(/\s+/);
const align = isVertical ? alignments[0] : alignments[1] ?? alignments[0];
const scrollPosition = this.getScrollPosition();
let target;
if (align === 'start') {
target = scrollPosition + snapAreaStart - snapportStart;
} else if (align === 'end') {
target = scrollPosition + snapAreaEnd - snapportEnd;
} else {
const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2;
const snapportCenter = (snapportStart + snapportEnd) / 2;
target = scrollPosition + snapAreaCenter - snapportCenter;
}
return clamp(target);
});
const targets = [...this.slides].map(slide =>
snapport.clamp(this.getSlideSnapPosition(slide, snapport))
);
// Select the snap target nearest the projected stopping point.
return targets.reduce((nearest, target) =>
@@ -386,53 +425,17 @@ class Carousel {
getActiveSlideIndex() {
if (!this.slides.length) return -1;
const isVertical = this.direction === 'y';
const startEdge = isVertical ? 'top' : 'left';
const endEdge = isVertical ? 'bottom' : 'right';
const paddingStart = isVertical ? 'scrollPaddingTop' : 'scrollPaddingLeft';
const paddingEnd = isVertical ? 'scrollPaddingBottom' : 'scrollPaddingRight';
const marginStart = isVertical ? 'scrollMarginTop' : 'scrollMarginLeft';
const marginEnd = isVertical ? 'scrollMarginBottom' : 'scrollMarginRight';
const cssPixels = value => Number.parseFloat(value) || 0;
const containerRect = this.scrollContainer.getBoundingClientRect();
const containerStyle = getComputedStyle(this.scrollContainer);
// Compare reachable snap positions so end-of-scroll clamping matches browser snapping.
const snapportStartEdge = containerRect[startEdge] +
(isVertical ? this.scrollContainer.clientTop : this.scrollContainer.clientLeft);
const clientExtent = isVertical ? this.scrollContainer.clientHeight : this.scrollContainer.clientWidth;
const snapportStart = snapportStartEdge + cssPixels(containerStyle[paddingStart]);
const snapportEnd = snapportStartEdge + clientExtent - cssPixels(containerStyle[paddingEnd]);
const snapportCenter = (snapportStart + snapportEnd) / 2;
const scrollPosition = this.getScrollPosition();
const maxScrollPosition = isVertical
? this.scrollContainer.scrollHeight - this.scrollContainer.clientHeight
: this.scrollContainer.scrollWidth - this.scrollContainer.clientWidth;
const clamp = value => Math.max(0, Math.min(value, maxScrollPosition));
const isAtEnd = maxScrollPosition > 0 && scrollPosition >= maxScrollPosition - 1;
const snapport = this.getSnapport();
// At the scroll limit, prefer the later slide when clamping makes targets tie.
const isAtEnd = snapport.maxScrollPosition > 0 &&
snapport.scrollPosition >= snapport.maxScrollPosition - 1;
let activeIndex = 0;
let closestDistance = Infinity;
this.slides.forEach((slide, index) => {
const rect = slide.getBoundingClientRect();
const slideStyle = getComputedStyle(slide);
const snapAreaStart = rect[startEdge] - cssPixels(slideStyle[marginStart]);
const snapAreaEnd = rect[endEdge] + cssPixels(slideStyle[marginEnd]);
const alignments = slideStyle.scrollSnapAlign.trim().split(/\s+/);
const align = isVertical ? alignments[0] : alignments[1] ?? alignments[0];
let snapPosition;
if (align === 'start') {
snapPosition = scrollPosition + snapAreaStart - snapportStart;
} else if (align === 'end') {
snapPosition = scrollPosition + snapAreaEnd - snapportEnd;
} else {
const snapAreaCenter = (snapAreaStart + snapAreaEnd) / 2;
snapPosition = scrollPosition + snapAreaCenter - snapportCenter;
}
const distance = Math.abs(clamp(snapPosition) - scrollPosition);
const snapPosition = this.getSlideSnapPosition(slide, snapport);
const distance = Math.abs(snapport.clamp(snapPosition) - snapport.scrollPosition);
// At the scroll limit, prefer the later slide when clamping makes targets tie.
if (distance < closestDistance || (isAtEnd && distance === closestDistance)) {
@@ -475,10 +478,21 @@ class Carousel {
* @returns {void}
*/
goToSlide(index) {
if (!this.slides.length) return;
this.activeSlideIndex = Math.max(0, Math.min(index, this.slides.length - 1));
this.autoScrolling = true;
this.updateActiveSlide();
this.slides[this.activeSlideIndex].scrollIntoView();
const container = this.scrollContainer;
const slide = this.slides[this.activeSlideIndex];
const snapport = this.getSnapport();
const targetPosition = snapport.clamp(this.getSlideSnapPosition(slide, snapport));
container.scrollTo({
[snapport.isVertical ? 'top' : 'left']: targetPosition,
behavior: 'smooth'
});
}
/**