Back to blog
Vue 3 & React - Ticker, Marquee, Carousel Component
14 min read

Vue 3 & React - Ticker, Marquee, Carousel Component

Want a quick summary of this post? Tune in 🎧
Table of Contents
  • "Not just the numbers, the quality of engagement has improved too."
    Olivia 0
    United Kingdom
  • "Not just the numbers, the quality of engagement has improved too."
    Olivia 1
    United Kingdom
  • "Not just the numbers, the quality of engagement has improved too."
    Olivia 2
    United Kingdom
  • "Not just the numbers, the quality of engagement has improved too."
    Olivia 3
    United Kingdom
  • "Not just the numbers, the quality of engagement has improved too."
    Olivia 4
    United Kingdom
  • "Not just the numbers, the quality of engagement has improved too."
    Olivia 5
    United Kingdom
  • "Not just the numbers, the quality of engagement has improved too."
    Olivia 6
    United Kingdom
  • "Not just the numbers, the quality of engagement has improved too."
    Olivia 7
    United Kingdom
  • "Not just the numbers, the quality of engagement has improved too."
    Olivia 8
    United Kingdom
  • "Not just the numbers, the quality of engagement has improved too."
    Olivia 9
    United Kingdom

Introduction

I fell in love with Framer Marquees when they launched and wanted to integrate them into one of my projects. However, I encountered several issues:

  • In React and Vue, I couldn’t find packages/components that met my requirements
  • Existing solutions weren’t polished enough for my design standards

Here are my specific requirements:

  • Hover slowdown: Mouse hover should reduce animation speed
  • Pause on hover: Complete animation pause when hovering
  • Auto-cloning: Clone items if insufficient content to fill the container
  • Endless repeat: Seamless infinite loop
  • Edge fading: Smooth fade effects on container edges
  • Touch support: Mobile-friendly interactions
  • Multi-directional: Both vertical and horizontal orientations
  • Performance: Render only when visible (intersection observer)

You might wonder: why not use a carousel? Carousels and marquees serve different purposes. Carousels display a few items at once with navigation controls, while marquees show continuous flowing content—perfect for news feeds, stock tickers, or sliding content displays.

While you could theoretically achieve this with pure CSS, I encountered issues with that approach. CSS animations caused inconsistent behavior, especially during hover slowdown effects.

After deeper investigation, I decided to build custom Marquee/Ticker components for both React and Vue.

Technical Challenges 🛠️

Here are the key challenges I faced while building this component:

  • DOM Measurement Timing: We must measure parent and child dimensions on the next tick since the DOM isn’t ready immediately. This calculation determines how many duplicates we need for seamless scrolling
  • Framework Differences: Slots work differently in Vue and React, requiring separate handling strategies for each framework
  • Pixel-Perfect Animation: Creating a requestAnimationFrame ticker that loops seamlessly without visible jumps—precision is critical for smooth user experience

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 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 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}%)`)
}

Usage Tips 💡

Both implementations support the same core features:

  • Direction: 'left', 'right', 'top', 'bottom'
  • Speed Control: Adjustable animation speed
  • Hover Effects: Slowdown and pause on hover
  • Fade Options: Customizable edge fading
  • Performance: Built-in intersection observer

Key Differences

Vue Implementation:

  • Uses Vue 3 Composition API
  • Leverages Vue’s reactivity system
  • Template-based rendering with slots

React Implementation:

  • Uses React hooks and Framer Motion
  • Imperative ref-based DOM manipulation
  • JSX with children cloning

Both versions provide smooth, performant marquee animations perfect for modern web applications! 🚀

Credits

Like this post? Sharing it means a lot to me! ❤️