Recently worked on a component that its children require to loop infinitely via scrolling animation to show content like a scrolling ticker display. After that was released in the wild, feedback came back that users wanted a way to see the content after it scrolled past the viewport. So I decided to make it able to drag scroll the content. Apart from the drag, I wanted to also have a little inertia effect after the user drag the content. Let’s see how we achieve this component in this post.
Using the component
Lets see how we would use the component. Inspired by the design pattern of headless component architecture, we define a root component that represents the “viewport” of the scroller and its container element that houses the child elements. We want to allow the consumer to define which direction of the scroller should be performed on, ie horizontal
or vertical
.
<div class="overflow-hidden">
<InfiniteLoopScrollerContent
:gap="16"
direction="horizontal"
class="flex gap-4 py-8"
>
<div>item 1</div>
<div>item 2</div>
<div>item 3</div>
...
</InfiniteLoopScrollerContent>
</div>
The InfiniteLoopScrollerContent
allows the use of a preferred layout container, ie: flex
or grid
, and which direction the layout container should the child items be laid out (columns vs rows). Another important role of the InfiniteLoopScrollerContent
is to clone and append the child items which we will touch on next.
Seamless infinite looping content
The idea for the seamless infinite looping content is to clone one set of DOM elements along side the original DOM elements. When the scroll translation value is translated by half of the total length of the children (including its cloned counterparts), we update the translation to positive or negative, depending on the direction, by half the length. This way, we give the illusion that the content loops infinitely. Here is the code that handles the scroll vertically.
function handleVerticalScroll(yVelocity: number) {
if (yVelocity < 0) {
//move up
const supposedY = currentTranslate.value + yVelocity;
if (Math.abs(supposedY) > contentHeight.value / 2) {
currentTranslate.value =
-(Math.abs(supposedY) - contentHeight.value / 2) + gap.value / 2;
} else {
currentTranslate.value = supposedY;
}
} else if (yVelocity > 0) {
//move down
const supposedY = currentTranslate.value + yVelocity;
if (supposedY > 0) {
currentTranslate.value =
supposedY - contentHeight.value / 2 - gap.value / 2;
} else {
currentTranslate.value = supposedY;
}
}
}
Notice the calculation also cater to the gap inbetween the items. This is to solve the problem of a slight shift in the translation when dragging the content.
Cloning and appending child items
In order to clone the child items, we can make use of the handy cloneVNode
helper function from Vue’s API. And to use that, we need to perform this lower-level operation in the render function. So let’s create a component to do just that.
<script lang="ts">
import { type VNode, defineComponent, cloneVNode } from "vue";
export default defineComponent({
setup(_, { slots }) {
return () => {
const children = slots.default?.() as VNode[];
return [...children, ...children.map((v) => cloneVNode(v))];
};
},
});
</script>
As you can see, the render function is really straightforward and not complicated at all. We are just returning an array of VNodes with the cloned set of child items appended to the array.
InfiniteLoopScrollerContent
component
This component contains the main logic of the scrolling.
<script setup lang="ts">
import CloneChildrenSlot from "./CloneChildrenSlot.vue";
import { computed, onMounted, ref, watch, watchEffect } from "vue";
import { useElementSize } from "@vueuse/core";
import { useLinearAnimation } from "./useLinearAnimation";
import { useDragInertia } from "./useDragInertia";
const props = withDefaults(
defineProps<{
direction?: "horizontal" | "vertical";
gap?: number;
}>(),
{
direction: "vertical",
gap: 0,
}
);
const scrollContent = ref<HTMLElement>();
const currentTranslate = ref(0);
const { velocity, isDragging, exceededTriggerDistance } =
useDragInertia(scrollContent);
const { width: contentWidth, height: contentHeight } =
useElementSize(scrollContent);
const { start, stop } = useLinearAnimation(
currentTranslate,
contentHeight,
contentWidth,
computed(() => props.direction),
computed(() => props.gap)
);
const translateStyle = computed(() => {
if (props.direction === "vertical") {
return {
transform: `translateY(${currentTranslate.value}px)`,
};
} else {
return {
transform: `translateX(${currentTranslate.value}px)`,
};
}
});
function handleVerticalScroll(yVelocity: number) {
if (yVelocity < 0) {
//move up
const supposedY = currentTranslate.value + yVelocity;
if (Math.abs(supposedY) > contentHeight.value / 2) {
currentTranslate.value =
-(Math.abs(supposedY) - contentHeight.value / 2) + props.gap / 2;
} else {
currentTranslate.value = supposedY;
}
} else if (yVelocity > 0) {
//move down
const supposedY = currentTranslate.value + yVelocity;
if (supposedY > 0) {
currentTranslate.value =
supposedY - contentHeight.value / 2 - props.gap / 2;
} else {
currentTranslate.value = supposedY;
}
}
}
function handleHorizontalScroll(xVelocity: number) {
if (xVelocity < 0) {
//move left
const supposedX = currentTranslate.value + xVelocity;
if (Math.abs(supposedX) > contentWidth.value / 2) {
currentTranslate.value =
-(Math.abs(supposedX) - contentWidth.value / 2) + props.gap / 2;
} else {
currentTranslate.value = supposedX;
}
} else if (xVelocity > 0) {
//move right
const supposedX = currentTranslate.value + xVelocity;
if (supposedX > 0) {
currentTranslate.value =
supposedX - contentWidth.value / 2 - props.gap / 2;
} else {
currentTranslate.value = supposedX;
}
}
}
function onEnter() {
stop();
}
function onLeave() {
start();
}
watchEffect(() => {
if (props.direction === "vertical") {
handleVerticalScroll(velocity.value.y);
} else {
handleHorizontalScroll(velocity.value.x);
}
});
watch(isDragging, (isDraggingValue) => {
if (isDraggingValue) {
stop();
}
});
onMounted(() => {
start();
});
</script>
<template>
<div
ref="scrollContent"
class="cursor-grab data-[is-dragging='true']:cursor-grabbing"
:style="translateStyle"
:class="{ 'w-max': direction === 'horizontal' }"
:data-is-dragging="isDragging"
@pointerenter="onEnter"
@pointerleave="onLeave"
>
<CloneChildrenSlot>
<slot :exceededTriggerDistance="exceededTriggerDistance" />
</CloneChildrenSlot>
</div>
</template>
Note the use of the Tailwind CSS class w-max
on the container div
. This is to have the container properly reports its width size, because by default the div
element will be the computed to the same width as it’s parent. By setting w-max
, we are telling the container div
to have its width set to the total width of its children, which is what we want in this case. By not setting this CSS, we will have an abrupt visual shift in the content while dragging.
Also in the code, you can see the use of CloneChildrenSlot
component that we created previously.
Inertia after scroll
The other feature we want to have is to apply an inertia effect after we scroll. This makes the scrolling feels more natural as most users are used to this type of scrolling on mobile applications. So lets create a composable to handle the inertia.
import { watchDebounced } from "@vueuse/core";
import { computed, type Ref, ref, watch } from "vue";
export interface UseDragInertiaOptions {
direction: "vertical" | "horizontal";
resistance?: number;
triggerDistance?: number;
}
const DEFAULT_RESISTANCE = 0.97;
const DEFAULT_TRIGGER_DISTANCE = 10;
const DEFAULT_DIRECTION = "vertical";
const NEAR_ZERO = 0.09;
export function useDragInertia(
elementRef: Ref<HTMLElement | undefined>,
options: UseDragInertiaOptions = {
direction: DEFAULT_DIRECTION,
resistance: DEFAULT_RESISTANCE,
triggerDistance: DEFAULT_TRIGGER_DISTANCE,
}
) {
const _isDragging = ref(false);
const _velocity = ref<{ x: number; y: number }>({ x: 0, y: 0 });
let previousPosition = { x: 0, y: 0 };
let currentPosition = { x: 0, y: 0 };
let startDragPosition = { x: 0, y: 0 };
let inertiaAnimationId: number;
const resistance = options.resistance ?? DEFAULT_RESISTANCE;
const direction = options.direction ?? DEFAULT_DIRECTION;
const _deltaY = ref(0);
const _exceededTriggerDistance = ref(false);
const velocity = computed(() => _velocity.value);
const isDragging = computed(() => _isDragging.value);
const deltaY = computed(() => _deltaY.value);
const exceededTriggerDistance = computed(
() => _exceededTriggerDistance.value
);
function startDrag(e: PointerEvent) {
e.preventDefault();
_isDragging.value = true;
previousPosition = { x: e.clientX, y: e.clientY };
currentPosition = { x: e.clientX, y: e.clientY };
_velocity.value = { x: 0, y: 0 };
startDragPosition = { x: e.clientX, y: e.clientY };
_exceededTriggerDistance.value = false;
cancelAnimationFrame(inertiaAnimationId);
}
function drag(e: PointerEvent) {
if (!_isDragging.value) {
return;
}
e.preventDefault();
currentPosition = { x: e.clientX, y: e.clientY };
_velocity.value = {
x: currentPosition.x - previousPosition.x,
y: currentPosition.y - previousPosition.y,
};
previousPosition = { x: currentPosition.x, y: currentPosition.y };
if (!_exceededTriggerDistance.value) {
const distance = Math.sqrt(
Math.pow(currentPosition.x - startDragPosition.x, 2) +
Math.pow(currentPosition.y - startDragPosition.y, 2)
);
_exceededTriggerDistance.value =
distance > (options.triggerDistance ?? DEFAULT_TRIGGER_DISTANCE);
}
}
function inertiaAnimation() {
_velocity.value = {
x: _velocity.value.x * resistance,
y: _velocity.value.y * resistance,
};
if (
Math.abs(_velocity.value.x) < NEAR_ZERO &&
Math.abs(_velocity.value.y) < NEAR_ZERO
) {
_velocity.value = { x: 0, y: 0 };
cancelAnimationFrame(inertiaAnimationId);
return;
}
// continue the animation
inertiaAnimationId = requestAnimationFrame(inertiaAnimation);
}
function endDrag(e: PointerEvent) {
if (!_isDragging.value) {
return;
}
e.preventDefault();
e.stopPropagation();
_isDragging.value = false;
_exceededTriggerDistance.value = false;
if (
(direction === "vertical" && velocity.value.y === 0) ||
(direction === "horizontal" && velocity.value.x === 0)
) {
cancelAnimationFrame(inertiaAnimationId);
} else {
inertiaAnimationId = requestAnimationFrame(inertiaAnimation);
}
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
cancelAnimationFrame(inertiaAnimationId);
if (Math.abs(e.deltaY) <= 1 && Math.abs(e.deltaX) <= 1) {
_velocity.value = { x: 0, y: 0 };
} else {
_velocity.value = { x: -e.deltaX, y: -e.deltaY };
}
}
function subscribeToDragEvents(element: HTMLElement) {
element.addEventListener("pointerdown", startDrag);
document.addEventListener("pointermove", drag);
document.addEventListener("pointerup", endDrag);
element.addEventListener("wheel", handleWheel);
}
function unsubscribeFromDragEvents(element: HTMLElement) {
element.removeEventListener("pointerdown", startDrag);
document.removeEventListener("pointermove", drag);
document.removeEventListener("pointerup", endDrag);
element.removeEventListener("wheel", handleWheel);
}
watch(elementRef, (newElement, oldElement) => {
if (oldElement) {
unsubscribeFromDragEvents(oldElement);
}
if (newElement) {
subscribeToDragEvents(newElement);
}
});
watchDebounced(
velocity,
() => {
_velocity.value = { x: 0, y: 0 };
},
{ debounce: 200 }
);
return {
isDragging,
velocity,
deltaY,
exceededTriggerDistance,
};
}
Notice we are using requestAnimationFrame
function to play out the inertia animation and cancelAnimationFrame
to stop the inertia. This method should be more performant than using setInterval
.
Animating like scrolling Ticker Display
As mentioned in the overview, we also wanted the scroll content to animate like a scrolling Ticker Display. To do that, we create another composable to handle that. You can see the use of the useLinearAnimation
composable in the InfiniteLoopScrollerContent
component.
import { type Ref, computed, ref, type ComputedRef } from "vue";
export type UseLinearAnimationOptions = {
speed: number;
};
const DEFAULT_SPEED = 0.4;
export function useLinearAnimation(
currentTranslate: Ref<number>,
contentHeight: Ref<number>,
contentWidth: Ref<number>,
direction: ComputedRef<"vertical" | "horizontal">,
gap: ComputedRef<number>,
options: UseLinearAnimationOptions = { speed: DEFAULT_SPEED }
) {
let linearAnimationId: number = NaN;
const _isAnimating = ref(false);
const isAnimating = computed(() => _isAnimating.value);
function linearAnimation() {
if (direction.value === "vertical") {
const supposedTranslate =
currentTranslate.value + (options.speed ?? DEFAULT_SPEED);
if (Math.abs(supposedTranslate) > contentHeight.value / 2) {
currentTranslate.value =
-(Math.abs(supposedTranslate) - contentHeight.value / 2) +
gap.value / 2;
} else {
currentTranslate.value = supposedTranslate;
}
} else {
const supposedTranslate =
currentTranslate.value - (options.speed ?? DEFAULT_SPEED);
if (Math.abs(supposedTranslate) > contentWidth.value / 2) {
currentTranslate.value =
-(Math.abs(supposedTranslate) - contentWidth.value / 2) +
gap.value / 2;
} else {
currentTranslate.value = supposedTranslate;
}
}
linearAnimationId = requestAnimationFrame(linearAnimation);
}
function start() {
if (!_isAnimating.value) {
_isAnimating.value = true;
linearAnimationId = requestAnimationFrame(linearAnimation);
}
}
function stop() {
if (!isNaN(linearAnimationId)) {
_isAnimating.value = false;
cancelAnimationFrame(linearAnimationId);
linearAnimationId = NaN;
}
}
return {
isAnimating,
start,
stop,
};
}
In this composable, we exposed two methods: start
and stop
to do exactly just that, to either start or stop the animation. In the InfiniteLoopScrollerContent
component, you can see we start the animation in the onMounted
hook and in the @pointerleave
event and stop the animation in the @pointerstart
event and isDragging
flag is true
.
Demo
Here is the demo of the infinite loop scroller. You may try swiping to see the inertia effect in action.
One of the things we need to cater is the presence of clickable elements like a button and that we don’t activate the click when we are dragging the content. In order to prevent the click from getting activated, we can apply pointer-events-none
to the button when the user dragged beyond a distance threshold (to assume that the user is confirm dragging). In the useDragInertia
composable, you can see the default is set to 10
.
Conclusion
I really had a fun time exploring the math behind the inertia effect, using render-function with the cloneVNode
function, leveraging requestAnimationFrame
to perform cool animations. I always knew some of these cool APIs out there but didn’t know how or where to apply them. Only when we start to build custom components then we realize the power of these concepts.
I’ll be honest here, the inertia math was gratefully assisted by ChatGPT. If it weren’t for ChatGPT, I would have definitely taken more time to build. So kudos to ChatGPT, productivity truly increased.