setState({ index, itemIndex, page })}
+ />
+
+ State
+
+ {state.index}{' '}
+
+
+ {state.itemIndex}{' '}
+
+
+ {state.page}
+
+ Controls
+
+ slide(direction: 'left' | 'right')
+
+ {' '}
+
+
+
+ slideToIndex(index: number)
+
+ {' '}
+ {' '}
+
+
+
+
+ slideToPage(page: number)
+
+ {' '}
+
+
+ >
+ );
+};
+```
diff --git a/docs/docs/examples/no-pages.mdx b/docs/docs/examples/no-pages.mdx
new file mode 100644
index 0000000..3c4c7ba
--- /dev/null
+++ b/docs/docs/examples/no-pages.mdx
@@ -0,0 +1,61 @@
+---
+sidebar_position: 6
+---
+
+# No pages
+
+
+## Example
+
+import { TileSlider } from '../../../src';
+import { items, renderLeftControl, renderRightControl, renderTile } from '../helpers';
+import '../../../src/style.css';
+
+
+
+## Code
+
+```tsx
+import { TileSlider, type RenderTile, type RenderControl } from '@videodock/tile-slider';
+
+type Tile = {
+ title: string;
+ image: string;
+};
+
+// create example data set with 10 tiles
+const items: Tile[] = Array.from({ length: 10 }, (_, index) => ({
+ title: `Tile ${index}`,
+ image: `/img/${index}.jpg`,
+}));
+
+const renderTile: RenderTile = ({ item, isVisible }) => (
+
+
+
+);
+
+const renderLeftControl: RenderControl = ({ onClick }) => (
+
+);
+
+const renderRightControl: RenderControl = ({ onClick }) => (
+
+);
+
+const Slider = () => {
+ return (
+
+ );
+};
+```
diff --git a/docs/docs/helpers.tsx b/docs/docs/helpers.tsx
index f353db0..9172af9 100644
--- a/docs/docs/helpers.tsx
+++ b/docs/docs/helpers.tsx
@@ -1,7 +1,7 @@
-import React from 'react';
+import React, { useRef, useState } from 'react';
import siteConfig from '@generated/docusaurus.config';
-import { RenderControl, RenderPagination, RenderTile } from '../../src';
+import { RenderControl, RenderPagination, RenderTile, TileSlider, TileSliderRef } from '../../src';
export type Tile = {
title: string;
@@ -97,7 +97,6 @@ export const makeItems = (length: number): Tile[] =>
};
});
-
export function easeOutElastic(currentTime: number, startValue: number, changeInValue: number, duration: number): number {
if (currentTime === 0) {
return startValue;
@@ -116,6 +115,51 @@ export function easeOutElastic(currentTime: number, startValue: number, changeIn
);
}
+export const WithRefExample = () => {
+ const tileSliderRef = useRef();
+ const [state, setState] = useState({ index: 0, itemIndex: 0, page: 0 });
+
+ return (
+ <>
+ setState({ index, itemIndex, page })}
+ />
+
+ State
+ {state.index}
+ {state.itemIndex}
+ {state.page}
+ Controls
+
+ slide(direction: 'left' | 'right')
+
+ {' '}
+
+
+
+ slideToIndex(index: number)
+
+ {' '}
+ {' '}
+
+
+
+
+ slideToPage(page: number)
+
+ {' '}
+
+
+ >
+ );
+};
+
export const items = makeItems(10);
export const moreItems = makeItems(50);
export const manyItems = makeItems(5000);
diff --git a/src/TileSlider.tsx b/src/TileSlider.tsx
index 0a944e1..30606da 100644
--- a/src/TileSlider.tsx
+++ b/src/TileSlider.tsx
@@ -1,8 +1,8 @@
-import React, { useCallback, useEffect, useRef, useState } from 'react';
+import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
import { useEventCallback } from './hooks/useEventCallback';
-import { AnimationFn, easeOut } from './utils/easing';
-import { getCircularIndex } from './utils/math';
+import { AnimationFn, easeOut, easeOutQuartic } from './utils/easing';
+import { getCircularIndex, interpolate } from './utils/math';
import { clx } from './utils/clx';
import { getVelocity, Position, registerMove, TouchMoves } from './utils/drag';
@@ -13,8 +13,8 @@ export const CYCLE_MODE_ENDLESS = 'endless';
export const PREFERS_REDUCED_MOTION = typeof window !== 'undefined' ? !window.matchMedia('(prefers-reduced-motion)').matches : false;
const DRAG_EDGE_SNAP = 50;
-const VELOCITY_SPEED = 10;
-const SNAPPING_DAMPING = 300;
+const SLIDE_SNAPPING_DAMPING = 500;
+const DRAG_SNAPPING_DAMPING = 1500;
export type Direction = 'left' | 'right';
export type CycleMode = 'stop' | 'restart' | 'endless';
@@ -32,6 +32,7 @@ export type ControlProps = {
};
export type PaginationProps = {
index: number;
+ itemIndex: number;
total: number;
page: number;
pages: number;
@@ -65,26 +66,35 @@ export type TileSliderProps = {
overscan?: number;
};
-export const TileSlider = ({
- items,
- tilesToShow = 6,
- cycleMode = 'endless',
- spacing = 12,
- showControls = true,
- animated = PREFERS_REDUCED_MOTION,
- animationFn = easeOut,
- pageStep = 'page',
- renderTile,
- renderLeftControl,
- renderRightControl,
- renderPagination,
- className,
- onSwipeStart,
- onSwipeEnd,
- onSlideStart,
- onSlideEnd,
- overscan = tilesToShow,
-}: TileSliderProps) => {
+export type TileSliderRef = {
+ slide: (direction: Direction) => void;
+ slideToIndex: (index: number, closest?: boolean) => void;
+ slideToPage: (page: number) => void;
+};
+
+const TileSliderComponent = (
+ {
+ items,
+ tilesToShow = 6,
+ cycleMode = 'endless',
+ spacing = 12,
+ showControls = true,
+ animated = PREFERS_REDUCED_MOTION,
+ animationFn = easeOut,
+ pageStep = 'page',
+ renderTile,
+ renderLeftControl,
+ renderRightControl,
+ renderPagination,
+ className,
+ onSwipeStart,
+ onSwipeEnd,
+ onSlideStart,
+ onSlideEnd,
+ overscan = tilesToShow,
+ }: TileSliderProps,
+ ref: React.ForwardedRef,
+) => {
const frameRef = useRef() as React.MutableRefObject;
const gesturesRef = useRef() as React.MutableRefObject;
const responsiveTileWidth = 100 / tilesToShow;
@@ -92,7 +102,14 @@ export const TileSlider = ({
const pages = Math.ceil(items.length / tilesToShow);
const needControls: boolean = showControls && isMultiPage;
- const [state, setState] = useState({ index: 0, page: 0, hasSlideBefore: false });
+ const [state, setState] = useState({
+ index: 0,
+ fromIndex: 0,
+ toIndex: 0,
+ sliding: false,
+ page: 0,
+ hasSlideBefore: false,
+ });
const showLeftControl: boolean = needControls && !(cycleMode === 'stop' && state.index === 0);
const showRightControl: boolean = needControls && !(cycleMode === 'stop' && state.index === items.length - tilesToShow);
@@ -115,13 +132,19 @@ export const TileSlider = ({
origin: { x: 0, y: 0 } as Position,
moves: [] as TouchMoves,
scrolling: false,
+ cancelled: false,
position: 0,
velocity: 0,
lastRenderedIndex: 0,
+ animationId: 0,
+ });
+
+ const getSliderWidth = useEventCallback(() => {
+ return parseFloat(getComputedStyle(frameRef.current).width);
});
const calculateIndex = useCallback(() => {
- const tileWidth = frameRef.current.offsetWidth / tilesToShow;
+ const tileWidth = getSliderWidth() / tilesToShow;
let index = Math.round((sliderDataRef.current.position / tileWidth) * -1);
if (!isMultiPage) {
@@ -133,7 +156,7 @@ export const TileSlider = ({
}
return index;
- }, [cycleMode, isMultiPage, items.length, tilesToShow]);
+ }, [cycleMode, getSliderWidth, isMultiPage, items.length, tilesToShow]);
const getSliderPosition = useEventCallback(() => {
const transform = getComputedStyle(frameRef.current).transform?.split(', ')[4];
@@ -141,19 +164,21 @@ export const TileSlider = ({
return transform ? parseInt(transform) : 0;
});
- const handleSnapping = useEventCallback((index: number, animationFn: AnimationFn) => {
- const tileWidth = frameRef.current.offsetWidth / tilesToShow;
- const from = sliderDataRef.current.position;
+ const handleSnapping = useEventCallback((index: number, animationFn: AnimationFn, duration = SLIDE_SNAPPING_DAMPING) => {
+ const tileWidth = getSliderWidth() / tilesToShow;
+ const from = getSliderPosition();
+ const relativeToPosition = -responsiveTileWidth * index;
const to = -(index * tileWidth);
const change = to - from;
const startTime = Date.now();
const page = Math.floor(getCircularIndex(index, items.length) / tilesToShow);
if (!animated) {
- setState((state) => ({ index, page, hasSlideBefore: true }));
- frameRef.current.style.transform = `translateX(${-responsiveTileWidth * index}%)`;
+ setState((state) => ({ ...state, index, page, hasSlideBefore: true, sliding: false }));
+ frameRef.current.style.transform = `translateX(${relativeToPosition}%)`;
onSlideEnd?.({
index: index,
+ itemIndex: getCircularIndex(index, items.length),
total: items.length,
page,
pages,
@@ -161,10 +186,13 @@ export const TileSlider = ({
return;
}
+ cancelAnimationFrame(sliderDataRef.current.animationId);
+ setState((state) => ({ ...state, toIndex: index, fromIndex: state.index, sliding: true }));
+
const snappingDampening = () => {
const currentTime = Date.now() - startTime;
- const position = animationFn(currentTime, from, change, SNAPPING_DAMPING);
- const index = calculateIndex();
+ const position = animationFn(currentTime, from, change, duration);
+ const currentIndex = calculateIndex();
sliderDataRef.current.position = position;
frameRef.current.style.transform = `translateX(${position}px)`;
@@ -172,21 +200,28 @@ export const TileSlider = ({
// interrupt
if (sliderDataRef.current.scrolling) return;
- if (currentTime <= SNAPPING_DAMPING) {
- requestAnimationFrame(snappingDampening);
+ if (currentTime <= duration) {
+ sliderDataRef.current.animationId = requestAnimationFrame(snappingDampening);
} else {
- frameRef.current.style.transform = `translateX(${-responsiveTileWidth * index}%)`;
+ frameRef.current.style.transform = `translateX(${relativeToPosition}%)`;
onSlideEnd?.({
- index,
+ index: currentIndex,
+ itemIndex: getCircularIndex(currentIndex, items.length),
total: items.length,
page,
pages,
});
+ setState((state) => ({
+ ...state,
+ index,
+ page,
+ sliding: false,
+ }));
}
- if (sliderDataRef.current.lastRenderedIndex !== index) {
- sliderDataRef.current.lastRenderedIndex = index;
- setState({ ...state, index: index, page, hasSlideBefore: true });
+ if (sliderDataRef.current.lastRenderedIndex !== currentIndex) {
+ sliderDataRef.current.lastRenderedIndex = currentIndex;
+ setState((state) => ({ ...state, index: currentIndex, page }));
}
};
@@ -199,29 +234,104 @@ export const TileSlider = ({
}, [responsiveTileWidth, tilesToShow]);
const handleVelocity = useEventCallback(() => {
- const startVelocity = sliderDataRef.current.velocity;
- const startPosition = sliderDataRef.current.position;
- const velocityTargetPosition = startPosition + startVelocity * VELOCITY_SPEED;
+ const startTime = Date.now();
+ const startVelocity = sliderDataRef.current.velocity * 16;
+ const tileWidth = getSliderWidth() / tilesToShow;
+
+ // Animation duration based on the speed
+ const extraDuration = Math.pow(Math.abs(startVelocity), 2) / 5;
+ const totalDuration = DRAG_SNAPPING_DAMPING + extraDuration;
+ const blendDuration = Math.round(totalDuration / 2);
+
+ // Snap the slider to the current tile when the velocity is near zero
+ if (startVelocity > -1 && startVelocity < 1) {
+ return handleSnapping(calculateIndex(), easeOutQuartic, 500);
+ }
+
+ // Consider a velocity between -8 and 8 to be a swipe (snap to prev/next index)
+ // A velocity of 8 is little more than a gentle swipe
+ if (startVelocity > -8 && startVelocity < 8) {
+ return handleSnapping(calculateIndex() + (startVelocity > 0 ? -1 : 1), easeOutQuartic, SLIDE_SNAPPING_DAMPING);
+ }
- const tileWidth = frameRef.current.offsetWidth / tilesToShow;
- const targetIndex = Math.round((velocityTargetPosition / tileWidth) * -1);
+ cancelAnimationFrame(sliderDataRef.current.animationId);
- handleSnapping(targetIndex, easeOut);
+ // @todo set `toIndex` based on the velocity?
+ setState((state) => ({ ...state, fromIndex: state.index, sliding: true }));
+
+ const velocityDampening = () => {
+ const currentTime = Date.now() - startTime;
+ const currentIndex = calculateIndex(); //startVelocity > 0 ? 'floor' : 'ceil');
+ const page = Math.floor(getCircularIndex(currentIndex, items.length) / tilesToShow);
+ const blending = currentTime >= totalDuration - blendDuration;
+
+ const velocity = easeOutQuartic(currentTime, startVelocity, -startVelocity, totalDuration);
+ sliderDataRef.current.position += velocity;
+
+ if (blending) {
+ const targetPosition = -(currentIndex * tileWidth);
+ const blendProgress = (currentTime - (totalDuration - blendDuration)) / blendDuration;
+
+ sliderDataRef.current.position = interpolate(sliderDataRef.current.position, targetPosition, blendProgress);
+ }
+
+ // interrupt
+ if (sliderDataRef.current.scrolling) return;
+
+ frameRef.current.style.transform = `translateX(${sliderDataRef.current.position}px)`;
+
+ if (currentTime <= totalDuration) {
+ sliderDataRef.current.animationId = requestAnimationFrame(velocityDampening);
+ } else {
+ frameRef.current.style.transform = `translateX(${-responsiveTileWidth * currentIndex}%)`;
+ onSlideEnd?.({
+ index: currentIndex,
+ itemIndex: getCircularIndex(currentIndex, items.length),
+ total: items.length,
+ page,
+ pages,
+ });
+ setState((state) => ({
+ ...state,
+ index: currentIndex,
+ page,
+ sliding: false,
+ }));
+ }
+
+ if (sliderDataRef.current.lastRenderedIndex !== currentIndex) {
+ sliderDataRef.current.lastRenderedIndex = currentIndex;
+ setState((state) => ({ ...state, index: currentIndex, page }));
+ }
+ };
+
+ sliderDataRef.current.animationId = requestAnimationFrame(velocityDampening);
});
const slideToIndex = useCallback(
- (index: number) => {
- const page = Math.floor(getCircularIndex(state.index, items.length) / tilesToShow);
- setState((state) => ({ ...state, index, page }));
+ (index: number, closest = false) => {
+ const itemIndex = getCircularIndex(state.index, items.length);
+ const page = Math.floor(itemIndex / tilesToShow);
+
+ if (closest) {
+ const toItemIndex = getCircularIndex(index, items.length);
+ const delta = toItemIndex - itemIndex;
+ index = state.index + delta;
+ }
+
+ if (!isMultiPage) return;
+
+ setState((state) => ({ ...state, page }));
onSlideStart?.({
index: index,
+ itemIndex,
total: items.length,
page,
pages,
});
handleSnapping(index, stableAnimationFn);
},
- [handleSnapping, items.length, onSlideStart, pages, stableAnimationFn, state.index, tilesToShow],
+ [handleSnapping, isMultiPage, items.length, onSlideStart, pages, stableAnimationFn, state.index, tilesToShow],
);
const slideToPage = useCallback(
@@ -245,6 +355,18 @@ export const TileSlider = ({
[slideToIndex, state.index, stepCount],
);
+ useImperativeHandle(
+ ref,
+ () => {
+ return {
+ slide,
+ slideToPage,
+ slideToIndex,
+ };
+ },
+ [slide, slideToIndex, slideToPage],
+ );
+
const handleTouchStart = useCallback(
(event: TouchEvent): void => {
sliderDataRef.current.origin = {
@@ -256,8 +378,17 @@ export const TileSlider = ({
sliderDataRef.current.moves = registerMove([], sliderDataRef.current.origin);
sliderDataRef.current.position = getSliderPosition();
+ sliderDataRef.current.scrolling = true;
+ sliderDataRef.current.cancelled = false;
+
onSwipeStart?.();
- onSlideStart?.({ index: state.index, page: state.page, pages, total: items.length });
+ onSlideStart?.({
+ index: state.index,
+ itemIndex: getCircularIndex(state.index, items.length),
+ page: state.page,
+ pages,
+ total: items.length,
+ });
},
[getSliderPosition, items.length, onSlideStart, onSwipeStart, pages, state.index, state.page],
);
@@ -276,7 +407,12 @@ export const TileSlider = ({
const movementX: number = Math.abs(newPosition.x - origin.x);
const movementY: number = Math.abs(newPosition.y - origin.y);
- if ((movementX > movementY && movementX > 10) || scrolling) {
+ if (movementX < movementY || sliderDataRef.current.cancelled) {
+ sliderDataRef.current.cancelled = true;
+ return;
+ }
+
+ if (movementX > movementY || scrolling) {
event.preventDefault();
event.stopPropagation();
@@ -310,7 +446,13 @@ export const TileSlider = ({
// snap to edges when there is nothing to scroll
if (!isMultiPage) delta = Math.max(-DRAG_EDGE_SNAP, Math.min(DRAG_EDGE_SNAP, delta));
+ sliderDataRef.current.scrolling = false;
sliderDataRef.current.velocity = 0;
+
+ if (sliderDataRef.current.cancelled || !isMultiPage) {
+ return handleVelocity();
+ }
+
sliderDataRef.current.position += delta;
// we slide when the movement was mostly horizontal
@@ -318,9 +460,9 @@ export const TileSlider = ({
sliderDataRef.current.velocity = velocity;
}
- sliderDataRef.current.scrolling = false;
- handleVelocity();
onSwipeEnd?.();
+
+ handleVelocity();
},
[handleVelocity, isMultiPage, onSwipeEnd],
);
@@ -343,7 +485,9 @@ export const TileSlider = ({
const renderTileContainer = (index: number) => {
const itemIndex = getCircularIndex(index, items.length);
- const isVisible = index >= state.index && index < state.index + tilesToShow;
+ const fromIndex = state.sliding ? Math.min(state.index, state.fromIndex, state.toIndex) : state.index;
+ const toIndex = (state.sliding ? Math.max(state.index, state.fromIndex, state.toIndex) : state.index) + tilesToShow;
+ const isVisible = index >= fromIndex && index < toIndex;
return (
({
)}
{renderPagination?.({
index: state.index,
+ itemIndex: getCircularIndex(state.index, items.length),
total: items.length,
page: state.page,
pages,
@@ -396,3 +541,5 @@ export const TileSlider = ({
);
};
+
+export const TileSlider = forwardRef(TileSliderComponent);
diff --git a/src/index.ts b/src/index.ts
index a5ac3fd..0773ec3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,5 +1,5 @@
export { TileSlider, CYCLE_MODE_ENDLESS, CYCLE_MODE_RESTART, CYCLE_MODE_STOP } from './TileSlider';
-export type { RenderTile, RenderControl, RenderPagination, TileSliderProps } from './TileSlider';
+export type { RenderTile, RenderControl, RenderPagination, TileSliderProps, TileSliderRef } from './TileSlider';
export { useResponsiveSize } from './hooks/useResponsiveSize';
export * as easing from './utils/easing';
diff --git a/src/utils/drag.ts b/src/utils/drag.ts
index 2012385..79b8c33 100644
--- a/src/utils/drag.ts
+++ b/src/utils/drag.ts
@@ -2,12 +2,15 @@ export type Position = { x: number; y: number };
export type TouchMoves = { position: Position; ts: number }[];
export const registerMove = (lastMoves: TouchMoves, position: Position) => {
- return [{ position, ts: Date.now() }, ...lastMoves].slice(0, 2);
+ return [{ position, ts: Date.now() }, ...lastMoves].slice(0, 5).filter((move) => move.ts > Date.now() - 250);
};
export const getVelocity = (lastMoves: TouchMoves) => {
- const distance = lastMoves[0]?.position.x - lastMoves[1]?.position.x;
- const time = lastMoves[0]?.ts - lastMoves[1]?.ts;
+ if (lastMoves.length < 2) return 0;
- return distance * (time / 32);
+ const distance = lastMoves[0].position.x - lastMoves[lastMoves.length - 1].position.x;
+ const time = lastMoves[0].ts - lastMoves[lastMoves.length - 1].ts;
+
+ // velocity is movement per millisecond
+ return distance / time;
};
diff --git a/src/utils/easing.ts b/src/utils/easing.ts
index 348a4cf..5cd1d30 100644
--- a/src/utils/easing.ts
+++ b/src/utils/easing.ts
@@ -6,6 +6,17 @@ export const easeOut: AnimationFn = (currentTime, startValue, changeInValue, dur
return changeInValue * (currentTime * currentTime * currentTime + 1) + startValue;
};
+export const easeOutQuartic: AnimationFn = (currentTime, startValue, changeInValue, duration) => {
+ currentTime /= duration;
+ currentTime--;
+ return -changeInValue * (currentTime * currentTime * currentTime * currentTime - 1) + startValue;
+};
+
+export const easeInOutCubic: AnimationFn = (currentTime, startValue, changeInValue, duration) => {
+ currentTime /= duration;
+ return changeInValue * (currentTime < 0.5 ? 4 * currentTime * currentTime * currentTime : 1 - Math.pow(-2 * currentTime + 2, 3) / 2) + startValue;
+}
+
export const easeInOut: AnimationFn = (currentTime, startValue, changeInValue, duration) => {
currentTime /= duration / 2;
if (currentTime < 1) {
diff --git a/src/utils/math.ts b/src/utils/math.ts
index 393b15b..4a1002b 100644
--- a/src/utils/math.ts
+++ b/src/utils/math.ts
@@ -1 +1,5 @@
export const getCircularIndex = (index: number, length: number) => ((index % length) + length) % length;
+
+export function interpolate(a: number, b: number, t: number): number {
+ return a * (1 - t) + b * t;
+}