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