Marquee

Content that travels across its container on a loop.

Repeats content end to end and travels it across the container, for a strip of logos, a ticker of prices or a row of names that should keep moving rather than stop at the edge.

The content is measured once and tiled enough times to cover the container. One track holds every copy and it is the track that moves, so the animation is a single UI-thread node. At most 32 copies are mounted: exceptionally short content receives extra whitespace instead of multiplying its React subtree hundreds of times.

The loop is exactly one layout period long, which is why the seam never shows: the state it ends on is the state it began on.

Content has to have a size of its own to be measured. A child stretched to flex-1, or an image with no width and height, measures as nothing and the marquee will not start.

For content the reader should be able to move themselves, use a ScrollView and put ScrollFade around it.

Installation

Marquee ships with the library — no separate install.

import { Marquee, Badge, Chip, Post, Surface, Text, Switch, MessageCircleIcon } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add marquee

Usage

<Marquee spacing={12} speed={40}>
  <View className="flex-row gap-3">
    {stack.map((label) => (
      <Badge key={label} variant="secondary">{label}</Badge>
    ))}
  </View>
</Marquee>

Composition

<Marquee.Group>
  <Marquee>{topRow}</Marquee>
  <Marquee reverse>{bottomRow}</Marquee>
</Marquee.Group>
  • Marquee.Group — See props below.

Examples

A strip of badges

The default: horizontal, travelling toward the end of the line at 40 points a second.

<Marquee spacing={12} speed={40}>
  <View className="flex-row gap-3">
    {stack.map((label) => (
      <Badge key={label} variant="secondary">{label}</Badge>
    ))}
  </View>
</Marquee>

Two rows, opposite ways

Two rows travelling against each other read as motion, where one row reads as something sliding. They are one piece of motion, so they take one pause control: the group draws it below them, and the marquees inside stop drawing their own.

<Marquee.Group className="w-full">
  <Marquee spacing={12} speed={35}>
    <View className="flex-row gap-3">
      {stack.map((label) => (
        <Chip key={label}>{label}</Chip>
      ))}
    </View>
  </Marquee>
  <Marquee spacing={12} speed={35} reverse>
    <View className="flex-row gap-3">
      {releases.map((label) => (
        <Chip key={label} variant="outline">{label}</Chip>
      ))}
    </View>
  </Marquee>
</Marquee.Group>

Vertical

There is no content width to take a height from on this axis, so the container is given one.

<Marquee direction="vertical" spacing={12} speed={30} className="h-40">
  <View className="gap-3">
    {releases.map((label) => (
      <Surface key={label} className="w-full rounded-xl px-4 py-3">
        <Text weight="medium">{label}</Text>
      </Surface>
    ))}
  </View>
</Marquee>

Holding it still

The built-in visible control meets the default pause requirement. Externally owned playing is available when the page already has an equivalent control.

<Marquee spacing={12} speed={40} pauseLabel="Pause ticker" playLabel="Play ticker">
  <View className="flex-row gap-3">
    {stack.map((label) => (
      <Badge key={label} variant="secondary">{label}</Badge>
    ))}
  </View>
</Marquee>

{/* Existing page-level controls can own the same state. Turn the built-in
    one off only when there is already a visible equivalent. */}
<Marquee playing={playing} showPauseControl={false}>{/* … */}</Marquee>
<Switch value={playing} onValueChange={setPlaying} />

Versions

A wall of logos

The customer strip a marketing page runs across its width. Two rows travelling against each other, because one row of marks reads as a list that happens to be sliding.

The logos are drawn as SVG paths rather than fetched as images, and that is the part worth copying: content is measured once, and an image with no width and height measures as nothing — so a strip of remote logos is the arrangement most likely to never start. A path has its size the moment it mounts. Fetching them instead is fine as long as every Image is given explicit dimensions.

The marks take the foreground colour rather than each brand's own. Eight brand colours in one strip is eight things competing with the page they sit on.

const second = [...brands.slice(4), ...brands.slice(0, 4)];

<Marquee.Group>
  <Marquee spacing={12} speed={28} className="h-11">
    <View className="flex-row gap-3">
      {brands.map((brand) => (
        <BrandMark key={brand.name} name={brand.name} path={brand.path} />
      ))}
    </View>
  </Marquee>
  <Marquee spacing={12} speed={28} reverse className="h-11">
    <View className="flex-row gap-3">
      {second.map((brand) => (
        <BrandMark key={brand.name} name={brand.name} path={brand.path} />
      ))}
    </View>
  </Marquee>
</Marquee.Group>

A feed that keeps moving

Cards on the vertical axis. The container gives the height, because a vertical marquee has no content width to take one from, and each card is given a width for the same reason a logo needs one.

Holding a card stops the travel. A post sliding out from under a finger cannot be read or tapped accurately, and the hold sits on a wrapper rather than on the card so the stats inside it keep their own presses.

The switch in the header is the visible way to stop it, so the marquee's own control is turned off — two controls for one piece of motion is one too many.

const [playing, setPlaying] = useState(true);

<Marquee direction="vertical" spacing={12} speed={22} playing={playing} showPauseControl={false} className="flex-1">
  <View className="gap-3">
    {posts.map((post) => (
      <Pressable
        key={post.id}
        className="w-72"
        onPressIn={() => setPlaying(false)}
        onPressOut={() => setPlaying(true)}
      >
        <Post variant="compact">
          <Post.Header>
            <Post.Author name={post.author} handle={post.handle} timestamp="2h" />
          </Post.Header>
          <Post.Body numberOfLines={3}>{post.body}</Post.Body>
          <Post.Footer>
            <Post.Stat icon={MessageCircleIcon} value={post.replies} />
          </Post.Footer>
        </Post>
      </Pressable>
    ))}
  </View>
</Marquee>

API Reference

Marquee

PropTypeDefaultDescription
classNamestring—
speednumber—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.
spacingnumber0Minimum gap between the end of one copy and the start of the next.
directionMarqueeDirection'horizontal'Axis the content travels along.
reversebooleanfalseSend it the other way: toward the start of the line, or upward. Applied after the reading direction, not instead of it.
playingbooleantrueSet false to hold the content where it is.
showPauseControlboolean—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.
pauseLabelstring'Pause'Visible and spoken label for the moving state.
playLabelstring'Play'Visible and spoken label for the user-paused state.
onPlayingChange(playing: boolean) => void—Reports changes made by the built-in control.

Marquee.Group

PropTypeDefaultDescription
classNamestring—
playingbooleantrueSet false to hold every marquee in the group where it is.
showPauseControlbooleantrueShow the group's pause/play control.
pauseLabelstring'Pause'Visible and spoken label for the moving state.
playLabelstring'Play'Visible and spoken label for the user-paused state.
onPlayingChange(playing: boolean) => void—Reports changes made by the group's control.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

With the operating system set to reduce motion the content is rendered once and held still. Not a slower loop — none. A ticker that never stops is the thing that setting exists to turn off.

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 that leaves it.

Pausing freezes the content where it is and resuming carries on from there, so a name stopped mid-travel is still the name you stopped on.

A marquee's track fills the row it is given rather than measuring one for itself, so give it a height wherever its container sizes to its contents. A vertical marquee always needs one — it has no content width to take a height from. A horizontal marquee is fine inside a parent with room to give, but inside a container that sizes to what is in it there is nothing to fill: the content is clipped to nothing while the pause control below it still draws, which reads as a marquee that is one button and no marquee. Set the height the content is drawn at.

speed is points per second, so the cycle time follows from it and the measured content. Longer content takes proportionally longer rather than travelling faster, and two marquees at the same speed stay in step whatever is inside them. spacing is the minimum gap between copies. When a very short child would exceed the 32-copy mount budget, Marquee adds enough whitespace to preserve continuous coverage without mounting an unbounded number of subtrees. Non-finite speed values fall back to 40 points per second; negative or non-finite spacing is treated as zero, so invalid data cannot produce a non-finite animation or layout.

Screen readers and keyboard/pointer interaction are given one copy. Every repeated visual copy and the hidden measurement copy are inert, so an interactive child appears only once in focus and hit-testing order.

Motion includes a visible 48-point Pause/Play control by default, drawn below the content rather than over it. Over it, on a strip only as tall as a row of badges, the control was taller than the marquee — clipped by the track's own edge and sitting on the content it exists to let you read. So a marquee is now as tall as its content plus its control, and a container given a fixed height spends part of that height on it. Its labels can be localized with pauseLabel and playLabel; use onPlayingChange to observe the user choice. playing={false} remains the externally owned way to hold the content still, and suppresses the built-in control.

Stacked rows go in a Marquee.Group. A control per marquee means one control per row, and two rows of logos do not need two ways to stop one piece of motion. A group draws a single control for everything inside it, and the marquees in it stop drawing their own. Each one keeps its own playing prop: the group's pause is an additional hold, not a replacement.

Set showPauseControl={false} on a lone marquee only when an equivalent visible control exists elsewhere — a switch in the header that already governs playing, for instance. Inside a group it is already the default.

Public exports

Values: Marquee

Types: MarqueeProps, MarqueeGroupProps, MarqueeDirection

On this page