/** * Marquee — content that travels across its container and never runs out. * * For a strip of logos, a ticker of prices, a row of testimonials: anything * whose job is to keep moving past a boundary rather than to be scrolled. * * ```tsx * * * {sponsors.map((s) => )} * * * ``` * * ## How it loops * * The content is measured once, then laid out end to end enough times to cover * the container. One track holds every copy and it is that track, not the * copies, that moves. The track has a fixed copy budget: exceptionally short * content gets extra whitespace instead of multiplying its React subtree * hundreds of times. It travels exactly one layout period and starts over, so * the state it ends on is the state it began on and the seam never shows. * * The travel is a linear timing driven on the UI thread, so it costs nothing * per frame in JavaScript and keeps running while the thread is busy. * * ## Measurement needs room * * The copy that gets measured sits in a hidden scroller, because that is what * lets the content report the width it *wants* rather than the width the * container would give it. Content with no intrinsic size of its own — a child * stretched to `flex-1`, an image with no dimensions — measures as nothing and * the marquee will not start. * * ## Reading direction and reduced motion * * A horizontal marquee travels toward the end of the line, so it reverses in a * right-to-left subtree. `reverse` flips it again from wherever it landed. * * With the operating system set to reduce motion the content is rendered once * and held still. A ticker that never stops is the exact thing that setting is * there to turn off, so this is not a shorter animation — it is none. * * Screen readers get one copy. The rest are duplicates of content already * announced, and hearing a sponsor list four times over is not thoroughness. */ import { createContext, useCallback, useContext, useEffect, useId, useMemo, useState, type ComponentType, type ReactNode, } from 'react'; import { Platform, Pressable, ScrollView, StyleSheet, View, type LayoutChangeEvent, type ViewProps, } from 'react-native'; import Animated, { cancelAnimation, Easing, useAnimatedStyle, useReducedMotion, useSharedValue, withRepeat, withSequence, withTiming, } from 'react-native-reanimated'; import { useDirectionSign } from '../../hooks/use-direction'; import { Text } from '../../primitives/text'; import { cn } from '../../utils/cn'; import { DEFAULT_MARQUEE_SPEED, marqueeCopyCount, normalizeMarqueeSpeed, } from './marquee-math'; export type MarqueeDirection = 'horizontal' | 'vertical'; type InertViewProps = ViewProps & { inert?: boolean }; const InertView = View as ComponentType; export interface MarqueeProps extends Omit { className?: string; /** The content to repeat. Measured once, then tiled along the axis. */ children: ReactNode; /** * Travel speed in points per second. 40 by default, which is slow enough * that a word stays readable as it crosses. The cycle time follows from this * and the measured content, so longer content takes proportionally longer * rather than moving faster. */ speed?: number; /** Minimum gap between the end of one copy and the start of the next. */ spacing?: number; /** Axis the content travels along. */ direction?: MarqueeDirection; /** * Send it the other way: toward the start of the line, or upward. Applied * after the reading direction, not instead of it. */ reverse?: boolean; /** Set false to hold the content where it is. */ playing?: boolean; /** * Show the built-in user pause/play control while motion is enabled. * * Defaults to `true` on its own, and to `false` inside a `Marquee.Group` — * the group draws one control for everything in it, and a control per row is * how two of them end up stacked on top of each other. */ showPauseControl?: boolean; /** Visible and spoken label for the moving state. */ pauseLabel?: string; /** Visible and spoken label for the user-paused state. */ playLabel?: string; /** Reports changes made by the built-in control. */ onPlayingChange?: (playing: boolean) => void; } /** * Set by `Marquee.Group` so the marquees inside it stop drawing a pause control * each and take the group's instead. */ interface MarqueeGroupContextValue { /** True while the group's control is holding everything in it still. */ paused: boolean; /** * How a marquee inside the group reports whether it has a loop to stop. * * The group draws one control for everything in it, and it should not offer * to pause content that never measured — the failure mode a marquee already * has, where a child with no intrinsic size never starts. Only the marquees * themselves know that, so they say so. */ report: (id: string, live: boolean) => void; } const MarqueeGroupContext = createContext(null); function MarqueeRoot({ className, children, speed: speedProp = DEFAULT_MARQUEE_SPEED, spacing = 0, direction = 'horizontal', reverse = false, playing = true, showPauseControl, pauseLabel = 'Pause', playLabel = 'Play', onPlayingChange, style, onLayout, ...props }: MarqueeProps) { const horizontal = direction === 'horizontal'; const reducedMotion = useReducedMotion(); // Only the horizontal axis has a reading direction to follow; up is up. const sign = useDirectionSign(); const flip = horizontal ? sign : 1; const [viewport, setViewport] = useState(0); const [content, setContent] = useState(0); const [userPaused, setUserPaused] = useState(false); const speed = normalizeMarqueeSpeed(speedProp); // A group's pause is the same instruction as this one's, so it is read here // rather than pushed down as a `playing` prop — a caller who set `playing` // themselves should not have it overwritten by the container. const group = useContext(MarqueeGroupContext); const showControl = showPauseControl ?? group === null; const moving = playing && !userPaused && !(group?.paused ?? false); // The distance from one copy to the same point on the next, and therefore // both the layout step and the exact loop length. They are the same number // on purpose: taking the gap from anywhere else is how a seam appears. const layout = useMemo( () => marqueeCopyCount(viewport, content, spacing), [viewport, content, spacing] ); const { period } = layout; const copies = useMemo(() => { // Enough to span the container, plus one trailing into view and one // already past it — the two the travel consumes before the loop restarts. return Array.from({ length: layout.count }, (_, index) => index); }, [layout.count]); const offset = useSharedValue(0); /* * A re-measure is the only thing allowed to move the content back to the * start. The loop length has changed, so an offset taken against the old one * no longer means anything. */ useEffect(() => { cancelAnimation(offset); offset.value = 0; }, [offset, period]); /* * Pausing freezes where it is. `cancelAnimation` already leaves the shared * value at whatever it had reached, so stopping is simply not starting again * — the content stays exactly where the reader stopped it, which is the * whole point of a pause control on something they are trying to read. * * Resuming picks up from there. The first leg is shortened to the distance * actually left, or the lap after a pause would run at a fraction of the * speed every other lap runs at. */ useEffect(() => { cancelAnimation(offset); if (reducedMotion || !moving || period <= 0 || speed <= 0) return undefined; const cycle = (period / speed) * 1000; const from = ((offset.value % period) + period) % period; const remaining = period - from; offset.value = from; offset.value = withSequence( withTiming(period, { duration: cycle * (remaining / period), easing: Easing.linear }), // Back to the seam in no time at all. The state at `period` is the state // at `0`, so this is a bookkeeping step rather than a visible one. withTiming(0, { duration: 0 }), withRepeat(withTiming(period, { duration: cycle, easing: Easing.linear }), -1, false) ); return () => cancelAnimation(offset); }, [offset, period, moving, reducedMotion, speed]); const trackStyle = useAnimatedStyle(() => { const travel = (reverse ? offset.value : -offset.value) * flip; return { transform: [horizontal ? { translateX: travel } : { translateY: travel }], }; }, [horizontal, reverse, flip]); // The container's own measurement is what sizes the loop, so an `onLayout` // passed in is called alongside it rather than replacing it. const onContainerLayout = (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setViewport(horizontal ? width : height); onLayout?.(event); }; const onContentLayout = (event: LayoutChangeEvent) => { const { width, height } = event.nativeEvent.layout; setContent(horizontal ? width : height); }; /* * Whether there is motion here at all: copies on screen, a speed to travel * them at, and a `playing` that has not already stopped it from outside. A * control offering to pause a still marquee is the same lie as a disabled * button with no reason on it. * * The copy count, not the period. Content that measured while its container * did not has a period — it is the content's own length — and no copies to * draw, because there is no width to lay them across. Asking the period * meant a row that renders nothing still reported itself live, and a group * drew a pause control for it: a marquee that is one button and no content, * which is the shape of the bug this replaced. */ const live = playing && !reducedMotion && layout.count > 0 && speed > 0; // Reported to the group, if there is one, so its single control can be // decided the same way this one's is. const id = useId(); const report = group?.report; useEffect(() => { if (!report) return undefined; report(id, live); return () => report(id, false); }, [report, id, live]); // Nothing to loop, so nothing to clip or clone: render the content plainly // and let it sit where it falls. if (reducedMotion) { return ( {children} ); } return ( {/* The track, and only the track. The control below it is not clipped by this and does not travel with it. */} {/* Measured, never seen. A scroller on this axis is what frees the content to report its own size instead of the container's. */} {children} {copies.map((index) => { // Index 1 is the copy that starts flush with the container's edge, // and it is the one a screen reader is given. const spoken = index === 1; const at = (index - 1) * period; return ( {children} ); })} {showControl && live ? ( { const next = !userPaused; setUserPaused(next); onPlayingChange?.(!next); }} > {userPaused ? playLabel : pauseLabel} ) : null} ); } export interface MarqueeGroupProps extends ViewProps { className?: string; /** Set false to hold every marquee in the group where it is. */ playing?: boolean; /** Show the group's pause/play control. */ showPauseControl?: boolean; /** Visible and spoken label for the moving state. */ pauseLabel?: string; /** Visible and spoken label for the user-paused state. */ playLabel?: string; /** Reports changes made by the group's control. */ onPlayingChange?: (playing: boolean) => void; children?: ReactNode; } /** * Rows of travelling content that stop and start together, under one control. * * Moving content needs a way to stop it, so a marquee draws its own pause * button. Stacked — two rows of logos travelling against each other — that is * one button per row, each pinned to its own bottom corner, and the upper one * lands on top of the row beneath it. Two buttons for one piece of motion, and * one of them covering the content it belongs to. * * A group is the answer to both: the marquees inside it stop drawing their own * control, and the group draws a single one below them rather than over them. * * ```tsx * * {topRow} * {bottomRow} * * ``` * * Each marquee keeps its own `playing` prop. The group's pause is an additional * hold, not a replacement — a row already held still stays still. */ function MarqueeGroup({ className, children, playing = true, showPauseControl = true, pauseLabel = 'Pause', playLabel = 'Play', onPlayingChange, ...props }: MarqueeGroupProps) { const [userPaused, setUserPaused] = useState(false); const [liveRows, setLiveRows] = useState>(() => new Set()); const reducedMotion = useReducedMotion(); // Each marquee inside says whether it has a loop to stop, and the group's // control is drawn only if at least one of them does. const report = useCallback((id: string, live: boolean) => { setLiveRows((current) => { if (current.has(id) === live) return current; const next = new Set(current); if (live) next.add(id); else next.delete(id); return next; }); }, []); const value = useMemo(() => ({ paused: userPaused, report }), [userPaused, report]); // Nothing is moving, so there is nothing to stop. The control is not hidden // to save space — offering to pause content that is already still is the // same lie as a disabled button with no reason on it. const showControl = showPauseControl && playing && !reducedMotion && liveRows.size > 0; return ( {children} {showControl ? ( { const next = !userPaused; setUserPaused(next); onPlayingChange?.(!next); }} > {userPaused ? playLabel : pauseLabel} ) : null} ); } MarqueeGroup.displayName = 'Marquee.Group'; MarqueeRoot.displayName = 'Marquee'; export const Marquee = Object.assign(MarqueeRoot, { Group: MarqueeGroup, }); const styles = StyleSheet.create({ /* Laid out so it measures, hidden so it does not draw, and behind everything so it can never intercept anything. */ measure: { opacity: 0, zIndex: -1 }, copy: { position: 'absolute' }, });