Vue 3 & React Marquee, Ticker, Carousel Component
A example of a Ticker, Marquee or Carousel component using Vue 3 and React, support hover slowdown, pause on hover, and more.
IntroductionSection titled Introduction
I felt in love with Framer Marquees when they came out, and wanted to add them in one my personal projects, but there was a few problems:
- ON React and Vue i could only find packages/components that did meet my requirements
- There were not polished enough for my design standards
Here is a few of my requirements:
- It needed to support hover slowdown, so when you place your mouse hover it should slow down
- It needed to be able to pause on hover
- It needed to be able to clone the items if there was not enough to fill the screen or div
- It needed to repeat endlessly
- Fade on the edges
- Support for touch devices
- Vertical and Horizontal support
- Performance with only render on screen intersect
You may be asking yourself, why not use a carousel? Well, carousels are not the same as marquees, carousels are usually used to show a few items at a time, and marquees are used to show a lot of items in a row, and they are usually used for news, or stock tickers or in this case sliding content.
You could in theory achieve this very sample example mostly with CSS ( or i lack the skills ), but i encountered a few problems with that approach, as CSS animations caused a flaky behaviour specially on slow down ( on hover ).
So i went inspecting a little bit deeper and decide to create my own Marquee/Ticker for both React and Vue.
ChallengesSection titled Challenges
These are some interesting challenges that i faced when creating this component:
- We need to measure the size of the parent and children, and we need to do this on the next tick, because the DOM is not ready yet, to be able to know how much childrens we need to repeat
- Because slots work differently on Vue and React, we need to handle them differently for both versions
- We needed to create a ticker method that was able to requestAnimation frame perfectly, otherwise you might notice the items looping over on the repeat, it needs to be pixel perfect, here ChatGPT helped a bit :p
Vue 3 ImplementationSection titled Vue 3 Implementation
<script setup lang="ts">
import type { ComputedRef, CSSProperties } from 'vue'
import { useFade } from '@/utils/fade-mask'
/**
* Some notes:
* - We cannot apply any kind of transition to the ticker because it will break the animation ( say tailwindcss classes )
* - Types imported from vue are not working properly so we must keep styles inline
*/
export interface SizingOptions {
widthType: boolean
heightType: boolean
}
export interface FadeOptions {
content: boolean
overflow: boolean
width: number
alpha: number
inset: number
}
export interface PaddingOptions {
padding: number
perSide: boolean
top: number
right: number
bottom: number
left: number
}
export interface TickerProps {
gap?: number | string
speed?: number | string
hoverFactor?: number | string
direction?: string | boolean | 'left' | 'right' | 'top' | 'bottom'
alignment?: string | 'flex-start' | 'center' | 'flex-end'
sizingOptions?: SizingOptions
fadeOptions?: FadeOptions
paddingOptions?: PaddingOptions
}
defineOptions({
inheritAttrs: false,
})
const props = withDefaults(defineProps<TickerProps>(), {
gap: 10,
speed: 20,
hoverFactor: 3,
direction: 'right',
alignment: 'center',
paddingOptions: () => ({
padding: 10,
perSide: false,
top: 0,
right: 0,
bottom: 0,
left: 0
}),
sizingOptions: () => ({
widthType: true,
heightType: true
}),
fadeOptions: () => ({
content: true,
overflow: false,
width: 50,
alpha: 0,
inset: 0
}),
})
const containerStyle = {
display: 'flex',
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
placeItems: 'center',
margin: 0,
padding: 0,
listStyleType: 'none',
textIndent: 'none'
}
// Utility: Wrap Utility
const wrap = (min: number, max: number, v: number) => {
const rangeSize = max - min
return ((((v - min) % rangeSize) + rangeSize) % rangeSize) + min
}
// The main container ref
const container = ref<HTMLElement>()
// The UL container that contains the children
const containerItems = ref<HTMLElement>()
// Stores the Refs for the original elements
const elementsOriginal = shallowRef()
// Stores the Refs of the cloned elements
const elementsCloned = shallowRef()
// Checks if its hovered or not
const isHovered = ref(false)
// Amount of duplicates required to fill the container and provide a smooth animation
const duplicateBy = ref(1)
// Sizes of Container
const sizes = reactive({
parent: 0,
children: 0,
})
// First and last child to calculate the size properly
const firstChild = computed(() => unrefElement(elementsOriginal.value[0])) as unknown as ComputedRef<HTMLElement>
const lastChild = computed(() => unrefElement(elementsOriginal.value[elementsOriginal.value.length - 1])) as unknown as ComputedRef<HTMLElement>
// Get the padding on each side if required
const containerPadding = computed(() => props.paddingOptions.perSide
? `${props.paddingOptions.top}px ${props.paddingOptions.right}px ${props.paddingOptions.bottom}px ${props.paddingOptions.left}px`
: `${props.paddingOptions.padding}px`
)
// Check if the container has children
const hasItems = computed(() => {
if (!containerItems.value) {
return false
}
return containerItems.value.children?.length > 0
})
// Checks if the container is horizontal or vertical
const isHorizontal = computed(() => props.direction === 'left' || props.direction === 'right')
// Checks the direction of the animation
const direction = computed(() => props.direction)
// The speed of the animation
const speed = computed(() => Number(props.speed))
// The value to animate to, this a very important value because it determines the end of the animation
// Usually this is the size of the children + the size of the parent so lets say 3x multiplier
const animateToValue = computed(() => sizes.children + sizes.children * Math.round(sizes.parent / sizes.children))
// Value that holds the main animation translate value
const xOrY = ref<number>(0)
// The initial time of the animation & time related values
const timeInitial = ref<number | null>(null)
const timePrevious = ref<number | null>(null)
// Store the frame of the animation via requestAnimationFrame
const animationFrame = ref<number | null>(null)
// Fade Options
const fadeMask = useFade(isHorizontal.value ? 'horizontal' : 'vertical', props.fadeOptions)
// Since vue resets the manual styles on update, we will use reactive styles
const reactiveStyles = computed(() => ({
...containerStyle,
gap: `${props.gap}px`,
placeItems: props.alignment,
position: 'relative',
flexDirection: isHorizontal.value ? 'row' : 'column',
willChange: 'transform',
top: props.direction === 'bottom' ? `-${animateToValue.value}px` || 0 : undefined,
left: props.direction === 'right' ? `-${animateToValue.value}px` || 0 : undefined,
perspective: '1000px',
backfaceVisibility: 'hidden',
})) as unknown as CSSProperties
/**
* Measures the container and children
*/
const measure = () => {
// Must have items, container and first child
if (!hasItems.value || !container.value || !containerItems.value) {
return
}
// Must have first and last child
if (!firstChild.value || !lastChild.value) {
console.warn('First and last child must be present. Ensure the ticker has only one child and that child has one root')
return
}
// Get the size of the parent
const parentLength = isHorizontal.value ? container.value.offsetWidth : container.value.offsetHeight
// Get the size of the first child
const start = firstChild.value
? (isHorizontal.value ? firstChild.value.offsetLeft : firstChild.value.offsetTop)
: 0
// Get The size of the last child
const end = lastChild.value
? (isHorizontal.value ? lastChild.value.offsetLeft + lastChild.value.offsetWidth : lastChild.value.offsetTop + lastChild.value.offsetHeight)
: 0
// Get the size of the children with the gap in mind
const childrenLength = end - start + Number(props.gap)
// Finally set the sizes
sizes.parent = parentLength
sizes.children = childrenLength
if (sizes.parent <= 0 || sizes.children <= 0) {
// console.warn('Unable to get parent or child size. Ensure the ticker has only one child and that child has one root')
return
}
// Also update the duplication
const duplicateResult = Math.round(sizes.parent / sizes.children * 2) + 1
// If value is different and more then 0 and less then 200
if (duplicateResult !== duplicateBy.value && duplicateResult > 0 && duplicateResult <= 200) {
duplicateBy.value = duplicateResult
}
}
const ticker = (animationTiming: number) => {
if (!containerItems.value) {
return
}
// Set initial time if it's null
if (timeInitial.value === null) {
timeInitial.value = animationTiming
}
// Adjust time elapsed
animationTiming -= timeInitial.value
const timeSinceLast = timePrevious.value === null ? 0 : animationTiming - timePrevious.value
let delta = timeSinceLast * (speed.value / 1e3)
// If its hover we want to reduce the speed, so we divide by the hover factor
if (isHovered.value) {
delta = delta / Number(props.hoverFactor)
}
// Wrap the value so it doesn't go over the max
xOrY.value = wrap(0, animateToValue.value, xOrY.value + delta)
// Apply transform based on direction
if (direction.value === 'left') {
containerItems.value.style.transform = `translate3d(-${xOrY.value}px, 0, 0)`
}
if (direction.value === 'right') {
containerItems.value.style.transform = `translate3d(${xOrY.value}px, 0, 0)`
}
if (direction.value === 'top') {
containerItems.value.style.transform = `translate3d(0, -${xOrY.value}px, 0)`
}
if (direction.value === 'bottom') {
containerItems.value.style.transform = `translate3d(0, ${xOrY.value}px, 0)`
}
// Point the previous time to the current time
timePrevious.value = animationTiming
// Request the next frame
animationFrame.value = requestAnimationFrame(ticker)
}
/**
* Starts the animation
*/
const animationStart = () => {
if (!containerItems.value) {
return
}
// Initial Frame Request
animationFrame.value = requestAnimationFrame(ticker)
}
/**
* Stops the animation
*/
const animationStop = () => animationFrame.value && cancelAnimationFrame(animationFrame.value)
// Intersection Observer
const { stop: stopIntersectionObserver } = useIntersectionObserver(
container,
([{ isIntersecting }]) => {
if (isIntersecting) {
animationStart()
} else {
animationStop()
}
}
)
// Await next tick to ensure the DOM is ready
tryOnMounted(() => {
nextTick(() => {
measure()
animationStart()
})
})
// Clear the animation frame on unmount
onUnmounted(() => {
animationStop()
stopIntersectionObserver()
})
defineExpose({
measure,
translate: xOrY
})
</script>
<template>
<div
ref="container"
data-ticker="true"
:class="{
'overflow-hidden': !props.fadeOptions.overflow,
'overflow-visible': props.fadeOptions.overflow,
}"
:style="{
...containerStyle,
padding: containerPadding,
maskImage: props.fadeOptions.content ? fadeMask : undefined,
}"
>
<ul
ref="containerItems"
:style="reactiveStyles"
@mouseenter="isHovered = true"
@mouseleave="isHovered = false"
>
<template
v-for="(children, i) in $slots.default?.()[0]?.children ?? []"
:key="i"
>
<x-ticker-item>
<component
:is="children"
ref="elementsOriginal"
/>
</x-ticker-item>
</template>
<template
v-for="z in duplicateBy"
:key="z"
>
<template
v-for="(children, i) in $slots.default?.()[0]?.children ?? []"
:key="i"
>
<x-ticker-item>
<component
:is="children"
ref="elementsCloned"
/>
</x-ticker-item>
</template>
</template>
</ul>
</div>
</template>
React ImplementationSection titled React Implementation
'use client'
import type { CSSProperties, ReactNode, RefObject, JSX, ReactElement, HTMLAttributes } from 'react'
import { Children, useLayoutEffect, useEffect, useRef, useMemo, createRef, useState, useCallback, cloneElement } from 'react'
import { RenderTarget } from 'framer'
import { useAnimationFrame, useReducedMotion, LayoutGroup } from 'framer-motion'
import { debounce } from 'lodash-es'
import { wrap } from 'popmotion'
interface SizingOptions {
widthType: boolean;
heightType: boolean;
}
interface FadeOptions {
fadeContent: boolean;
overflow: boolean;
fadeWidth: number;
fadeAlpha: number;
fadeInset: number;
}
interface TransitionControl {
type: string;
ease: string;
duration: number;
}
interface TickerProps extends HTMLAttributes<'div'>{
slots: ReactElement[];
gap: number;
padding: number;
paddingPerSide?: boolean;
paddingTop?: number;
paddingRight?: number;
paddingBottom?: number;
paddingLeft?: number;
speed?: number;
hoverFactor?: number;
direction?: string | boolean | 'left' | 'right' | 'top' | 'bottom';
alignment?: string | 'flex-start' | 'center' | 'flex-end';
sizingOptions?: SizingOptions;
fadeOptions?: FadeOptions;
transitionControl?: TransitionControl;
style?: CSSProperties;
}
/* Placeholder Styles */
const containerStyle = {
display: 'flex',
width: '100%',
height: '100%',
maxWidth: '100%',
maxHeight: '100%',
placeItems: 'center',
margin: 0,
padding: 0,
listStyleType: 'none',
textIndent: 'none'
}
/* Clamp function, used for fadeInset */
const clamp = (num: number, min: number, max: number) => Math.min(Math.max(num, min), max)
export default function Ticker({
gap = 10,
padding = 10,
paddingPerSide,
paddingTop,
paddingRight,
paddingBottom,
paddingLeft,
speed = 10,
hoverFactor = 5,
direction = true,
alignment = 'center',
sizingOptions = {
widthType: true,
heightType: true
},
fadeOptions = {
fadeContent: true,
overflow: false,
fadeWidth: 100,
fadeAlpha: 0,
fadeInset: 0
},
style,
slots,
className,
}: TickerProps) {
const {
fadeContent,
overflow,
fadeWidth,
fadeInset,
fadeAlpha
} = fadeOptions
const {
widthType,
heightType
} = sizingOptions
const paddingValue = paddingPerSide ? `${paddingTop}px ${paddingRight}px ${paddingBottom}px ${paddingLeft}px` : `${padding}px`
/* Checks */
const isCanvas = RenderTarget.current() === RenderTarget.canvas
const hasChildren = Children.count(slots) > 0
const isHorizontal = direction === 'left' || direction === 'right'
/* Empty state */
if (!hasChildren || !slots) {
throw new Error('You must add at least one child to the Ticker component.')
}
/* Refs and State */
const parentRef = useRef<HTMLDivElement>(null)
const childrenRef = useMemo<[RefObject<HTMLDivElement>, RefObject<HTMLDivElement>]>(() => [createRef(), createRef()], [])
const [size, setSize] = useState<{ parent: number; children: number }>({
parent: 0,
children: 0
})
/* Arrays */
let clonedChildren: ReactNode[] | JSX.Element[]
let dupedChildren: ReactNode[] | JSX.Element[] = []
/* Duplicate value */
let duplicateBy = 0
let opacity = 0
if (isCanvas) {
duplicateBy = 20
opacity = 1
}
if (!isCanvas && hasChildren && size.parent) {
duplicateBy = Math.round(size.parent / size.children * 2) + 1
opacity = 1
}
/* Measure parent and child */
const measure = useCallback(() => {
if (hasChildren && parentRef.current) {
const parentLength = isHorizontal ? parentRef.current.offsetWidth : parentRef.current.offsetHeight
const start = childrenRef[0].current ? isHorizontal ? childrenRef[0].current.offsetLeft : childrenRef[0].current.offsetTop : 0
const end = childrenRef[1].current ? isHorizontal ? childrenRef[1].current.offsetLeft + childrenRef[1].current.offsetWidth : childrenRef[1].current.offsetTop + childrenRef[1].current.offsetHeight : 0
const childrenLength = end - start + gap
setSize({
parent: parentLength,
children: childrenLength
})
}
}, [childrenRef, gap, hasChildren, isHorizontal])
/* Add refs to first and last child */
useLayoutEffect(() => {
!isCanvas && measure()
}, [isCanvas, measure])
/**
* Track whether this is the initial resize event. By default this will fire on mount,
* which we do in the useEffect. We should only fire it on subsequent resizes.
*/
let initialResize = useRef(true)
useEffect(() => {
if (isCanvas || !parentRef.current) {
return
}
const handleResize = debounce(() => {
if (!initialResize.current) {
measure()
}
initialResize.current = false
}, 1500) // 300 ms debounce time
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [isCanvas, measure])
// @ts-ignore
clonedChildren = Children.map(slots, (child, index) => {
let selectedRef = null
if (index === 0) {
selectedRef = childrenRef[0]
}
if (index === slots.length - 1) {
selectedRef = childrenRef[1]
}
return (
<LayoutGroup inherit="id">
<li
style={{ display: 'contents' }}
>
{cloneElement(child, {
key: `cloned-child-${index}`,
ref: selectedRef,
style: {
...(child.props?.style),
width: widthType ? child.props?.width : '100%',
height: heightType ? child.props?.height : '100%',
flexShrink: 0,
},
}, child.props?.children)}
</li>
</LayoutGroup>
)
})
for (let i = 0; i < duplicateBy; i++) {
dupedChildren = [
...dupedChildren,
...Children.map(slots, (child, childIndex) => {
return (
<LayoutGroup inherit="id" key={`duped-child-${i}-${childIndex}`}>
<li style={{ display: 'contents' }}>
{cloneElement(child, {
style: {
...child.props.style,
width: widthType ? child.props.width : '100%',
height: heightType ? child.props.height : '100%',
flexShrink: 0
}
}, child.props.children)}
</li>
</LayoutGroup>
)
})
]
}
const animateToValue = size.children + size.children * Math.round(size.parent / size.children)
const transformRef = useRef<HTMLUListElement>(null)
const initialTime = useRef<number| null>(null)
const prevTime = useRef<number | null>(null)
const xOrY = useRef(0)
const isHover = useRef(false)
const isReducedMotion = useReducedMotion()
useAnimationFrame(t => {
if (!transformRef.current || !animateToValue || isReducedMotion) {
return
}
/**
* In case this animation is delayed from starting because we're running a bunch
* of other work, we want to set an initial time rather than counting from 0.
* That ensures that if the animation is delayed, it starts from the first frame
* rather than jumping.
*/
if (initialTime.current === null) {
initialTime.current = t
}
t = t - initialTime.current
const timeSince = prevTime.current === null ? 0 : t - prevTime.current
let delta = timeSince * (speed / 1e3)
if (isHover.current) {
delta *= hoverFactor
}
xOrY.current += delta
xOrY.current = wrap(0, animateToValue, xOrY.current)
/* Direction */
if (direction === 'left') {
transformRef.current.style.transform = `translateX(-${xOrY.current}px)`
}
if (direction === 'right') {
transformRef.current.style.transform = `translateX(${xOrY.current}px)`
}
if (direction === 'top') {
transformRef.current.style.transform = `translateY(-${xOrY.current}px)`
}
if (direction === 'bottom') {
transformRef.current.style.transform = `translateY(${xOrY.current}px)`
}
prevTime.current = t
})
/* Fades */
const fadeDirection = isHorizontal ? 'to right' : 'to bottom'
const fadeWidthStart = fadeWidth / 2
const fadeWidthEnd = 100 - fadeWidth / 2
const fadeInsetStart = clamp(fadeInset, 0, fadeWidthStart)
const fadeInsetEnd = 100 - fadeInset
const fadeMask = `linear-gradient(${fadeDirection}, rgba(0, 0, 0, ${fadeAlpha}) ${fadeInsetStart}%, rgba(0, 0, 0, 1) ${fadeWidthStart}%, rgba(0, 0, 0, 1) ${fadeWidthEnd}%, rgba(0, 0, 0, ${fadeAlpha}) ${fadeInsetEnd}%)`
return (
<section
className={className}
data-ticker={true}
ref={parentRef}
style={{
...containerStyle,
opacity,
padding: paddingValue,
WebkitMaskImage: fadeContent ? fadeMask : undefined,
MozMaskImage: fadeContent ? fadeMask : undefined,
maskImage: fadeContent ? fadeMask : undefined,
overflow: overflow ? 'visible' : 'hidden',
} as CSSProperties}
>
<ul
className={'transform-gpu'}
ref={transformRef}
style={{
...containerStyle,
gap,
top: direction === 'bottom' ? -animateToValue || 0 : undefined,
left: direction === 'right' ? -animateToValue || 0 : undefined,
placeItems: alignment,
position: 'relative',
flexDirection: isHorizontal ? 'row' : 'column',
willChange: 'transform',
...style,
}}
onMouseEnter={() => (isHover.current = true)}
onMouseLeave={() => (isHover.current = false)}
>
<>
{clonedChildren}
{dupedChildren}
</>
</ul>
</section>
)
}
Fade UtilitySection titled Fade Utility
import type { MaybeRef } from 'vue'
import { computed } from 'vue'
export interface FadeOptions {
content: boolean
overflow: boolean
width: number
alpha: number
inset: number
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max)
}
// The useFade composable function
export function useFade(orientation: MaybeRef<'horizontal' | 'vertical' | undefined>, fadeOptions: FadeOptions) {
const orientationValue = toValue(orientation)
const isHorizontal = computed(() => orientationValue === 'horizontal')
const fadeDirection = computed(() => isHorizontal.value ? 'to right' : 'to bottom')
const fadeWidthStart = computed(() => fadeOptions.width / 2)
const fadeWidthEnd = computed(() => 100 - fadeOptions.width / 2)
const fadeInsetStart = computed(() => clamp(fadeOptions.inset, 0, fadeWidthStart.value))
const fadeInsetEnd = computed(() => 100 - fadeOptions.inset)
return computed(() => `linear-gradient(${fadeDirection.value}, rgba(0, 0, 0, ${fadeOptions.alpha}) ${fadeInsetStart.value}%, rgba(0, 0, 0, 1) ${fadeWidthStart.value}%, rgba(0, 0, 0, 1) ${fadeWidthEnd.value}%, rgba(0, 0, 0, ${fadeOptions.alpha}) ${fadeInsetEnd.value}%)`)
}
I hope you enjoy! Happy Frontend Coding! 🚀