Mar 29, 2023

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! 🚀