Marquee
Horizontally scrolling marquee for any content with a seamless loop — supports reverse direction, tap-to-pause, and press-and-hold-to-speed-up.
AniUI · Beautiful React Native components · Copy · Paste · Ship ·AniUI · Beautiful React Native components · Copy · Paste · Ship ·
Web preview — components render natively on iOS & Android
import { Marquee } from "@/components/ui/marquee";
<Marquee reverse pauseOnPress>Beautiful React Native components</Marquee>Installation#
npx @aniui/cli add marqueeUsage#
import { Marquee } from "@/components/ui/marquee";
<Marquee reverse pauseOnPress>Beautiful React Native components</Marquee>Source#
components/ui/marquee.tsx
import React, { useCallback, useState } from "react";
import { View, Pressable, LayoutChangeEvent } from "react-native";
import Animated, {
useAnimatedStyle,
useFrameCallback,
useSharedValue,
type FrameInfo,
} from "react-native-reanimated";
import { Text } from "@/components/ui/text";
import { useReducedMotion } from "@/components/ui/animate";
import { cn } from "@/lib/utils";
export interface MarqueeProps extends React.ComponentPropsWithoutRef<typeof View> {
className?: string;
/** Content to tick — a single copy is measured and repeated to fill the track. */
children: React.ReactNode;
/** Pixels per second. */
speed?: number;
/** Gap between repeated copies, in pixels. */
spacing?: number;
/** Scroll right-to-left instead of the default left-to-right. */
reverse?: boolean;
/** Tap to pause/resume the ticker. */
pauseOnPress?: boolean;
/** Press-and-hold to fast-forward. Mutually exclusive with pauseOnPress's tap toggle. */
holdToSpeedUp?: boolean;
speedUpMultiplier?: number;
}
export function Marquee({
className,
children,
speed = 50,
spacing = 20,
reverse = false,
pauseOnPress = false,
holdToSpeedUp = false,
speedUpMultiplier = 3,
...props
}: MarqueeProps) {
const reducedMotion = useReducedMotion();
const offset = useSharedValue(0);
const isPaused = useSharedValue(false);
const speedMultiplier = useSharedValue(1);
const [trackWidth, setTrackWidth] = useState(0);
const [contentWidth, setContentWidth] = useState(0);
const segmentWidth = contentWidth + spacing;
// Enough copies to cover the visible track continuously, plus a couple of
// spares so a wrap never exposes a gap at the track's edges.
const copies =
contentWidth > 0 && trackWidth > 0 ? Math.max(2, Math.ceil(trackWidth / segmentWidth) + 2) : 2;
useFrameCallback((frameInfo: FrameInfo) => {
"worklet";
if (reducedMotion || isPaused.value || contentWidth === 0) return;
const dt = frameInfo.timeSincePreviousFrame ?? 0;
const distance = (speed * speedMultiplier.value * dt) / 1000;
if (reverse) {
offset.value -= distance;
if (offset.value <= -segmentWidth) offset.value += segmentWidth;
} else {
offset.value += distance;
if (offset.value >= segmentWidth) offset.value -= segmentWidth;
}
});
const style = useAnimatedStyle(() => ({ transform: [{ translateX: -offset.value }] }));
const onTrackLayout = useCallback((e: LayoutChangeEvent) => setTrackWidth(e.nativeEvent.layout.width), []);
const onContentLayout = useCallback((e: LayoutChangeEvent) => setContentWidth(e.nativeEvent.layout.width), []);
const handlePress = () => {
if (holdToSpeedUp) return;
if (pauseOnPress) isPaused.value = !isPaused.value;
};
const handlePressIn = () => {
if (holdToSpeedUp) speedMultiplier.value = speedUpMultiplier;
};
const handlePressOut = () => {
if (holdToSpeedUp) speedMultiplier.value = 1;
};
// Bare strings can't be rendered directly inside a View/Pressable in RN.
const content = typeof children === "string" ? <Text variant="p">{children}</Text> : children;
const track = (
<Animated.View className="flex-row" style={style}>
{Array.from({ length: copies }).map((_, i) => (
<View
key={i}
className="flex-row"
style={i > 0 ? { marginLeft: spacing } : undefined}
onLayout={i === 0 ? onContentLayout : undefined}
>
{content}
</View>
))}
</Animated.View>
);
const interactive = pauseOnPress || holdToSpeedUp;
return (
<View
className={cn("w-full overflow-hidden", className)}
onLayout={onTrackLayout}
accessibilityRole={interactive ? undefined : "text"}
{...props}
>
{interactive ? (
<Pressable
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
hitSlop={{ top: 14, bottom: 14 }}
accessible
accessibilityRole="button"
accessibilityLabel={pauseOnPress ? "Marquee, double tap to pause or resume" : "Marquee"}
>
{track}
</Pressable>
) : (
track
)}
</View>
);
}

