Marquee
Endless ticker of words or logos that scrolls at a steady speed with softly faded edges.
Marquee, bands
Installation
npx shadcn add https://www.reelcn.dev/r/marquee.jsonFiles land in src/reelcn/ with the items it builds on.
Usage
<Marquee items={["Remotion", "React", "TypeScript", "shadcn"]} speed={180} />Props
PropTypeDescription
delaynumberFrames to wait before entering.
durationnumberEnter duration in frames. Defaults to 0.6s. Some items reuse
duration for an unrelated local timing. See that item's own prop docs.exitnumber | booleanExit at the end of the parent `<Sequence>`.
false keeps it on screen, a number sets the exit length in frames.motionMotionPersonalityOverride the theme's motion personality.
posterbooleanRenders fully entered, with no exit: for a still frame or thumbnail. Defaults to
false.holdFramesnumberDelays the start of the exit window by this many frames, for a minimum on-screen hold. Defaults to
0.itemsstring[]Words to scroll. Ignored when
children are given. Default [].childrenReactNodeCustom elements to scroll instead of
items. Each one is assumed to be at least one font size wide.speednumberScroll speed in design units per second. Default
160.direction"left" | "right" Default
"left".separatorReactNodeDrawn between items. Defaults to a small four-point star in the accent color.
sizenumberFont size in design units. Default
64.font"mono" | "heading" | "body" Default
"heading".weightnumber
gapnumberSpace on each side of the separator, in design units. Default
40.fadenumberWidth of the faded edges, in design units. 0 turns the fade off. Default
160.colorstring
accentColorstringDefault separator color. Defaults to the theme accent.
backgroundstringBand fill. Transparent by default.
styleCSSProperties
classNamestring
Built from
Use
- Logo walls, keyword bands, "as seen in" strips and breaking-news tickers
- A tilted accent band behind a headline for extra movement
Avoid
- A short list that should arrive and then hold still, use
stagger
Guides
Components take their colors from a theme, their timing from motion props and their layout from the canvas.
Source
registry/items/marquee.tsx
/**
* @title Marquee
* @category motion
* @description Endless ticker of words or logos that scrolls at a steady speed with softly faded edges.
* @duration sustained
* @use Logo walls, keyword bands, "as seen in" strips and breaking-news tickers
* @use A tilted accent band behind a headline for extra movement
* @avoid A short list that should arrive and then hold still, use `stagger`
* @tags marquee, ticker, scroll, logos, band, loop
* @example
* <Marquee items={["Remotion", "React", "TypeScript", "shadcn"]} speed={180} />
*/
import type React from "react";
import { Children } from "react";
import { type MotionProps, useLoop, useMotion, useTheme, useViewport } from "./core";
export type MarqueeProps = MotionProps & {
/** Words to scroll. Ignored when `children` are given. */
items?: string[];
/** Custom elements to scroll instead of `items`. Each one is assumed to be at least one font size wide. */
children?: React.ReactNode;
/** Scroll speed in design units per second. */
speed?: number;
direction?: "left" | "right";
/** Drawn between items. Defaults to a small four-point star in the accent color. */
separator?: React.ReactNode;
/** Font size in design units. */
size?: number;
font?: "heading" | "body" | "mono";
weight?: number;
/** Space on each side of the separator, in design units. */
gap?: number;
/** Width of the faded edges, in design units. 0 turns the fade off. */
fade?: number;
color?: string;
/** Default separator color. Defaults to the theme accent. */
accentColor?: string;
/** Band fill. Transparent by default. */
background?: string;
style?: React.CSSProperties;
className?: string;
};
function Star({ color }: { color: string }) {
return (
<svg viewBox="0 0 24 24" width="0.42em" height="0.42em" style={{ display: "block", flexShrink: 0 }} aria-hidden>
<path d="M12 0C13 7 17 11 24 12C17 13 13 17 12 24C11 17 7 13 0 12C7 11 11 7 12 0Z" fill={color} />
</svg>
);
}
export function Marquee({
items = [],
children,
speed = 160,
direction = "left",
separator,
size = 64,
font = "heading",
weight,
gap = 40,
fade = 160,
color,
accentColor,
background,
style,
className,
...motion
}: MarqueeProps) {
const theme = useTheme();
const { u, width } = useViewport();
const m = useMotion(motion);
const fontPx = u(size);
const gapPx = u(gap);
const custom = Children.toArray(children);
const units: React.ReactNode[] = custom.length > 0 ? custom : items;
const mark = separator ?? <Star color={accentColor ?? theme.colors.accent} />;
// No measuring: the track never wraps. It holds enough copies to cover the canvas plus the whole distance it will
// travel, using a conservative lower bound for each copy's width (0.2em per character, 1em per custom element).
const minCopyWidth = units.reduce<number>(
(sum, unit) => sum + 2 * gapPx + fontPx * 0.42 + fontPx * (typeof unit === "string" ? unit.length * 0.2 : 1),
0,
);
const travel = (m.durationInFrames / m.fps) * u(speed);
const copies = minCopyWidth > 0 ? Math.min(Math.ceil((width + travel) / minCopyWidth) + 1, 400) : 0;
// One copy-width per loop, so a Remotion <Loop> wrapped around Marquee never jumps at the seam.
const loopFrames =
minCopyWidth > 0 && u(speed) > 0 ? Math.max(1, Math.round((minCopyWidth / u(speed)) * m.fps)) : m.durationInFrames;
const offset = (useLoop({ durationInFrames: loopFrames }) / m.fps) * u(speed);
const fadePx = u(fade);
const mask =
fadePx > 0
? `linear-gradient(to right, transparent, #000 ${fadePx}px, #000 calc(100% - ${fadePx}px), transparent)`
: undefined;
const cells: React.ReactNode[] = [];
for (let copy = 0; copy < copies; copy++) {
units.forEach((unit, index) => {
cells.push(
<div
key={`${copy}-${index}`}
style={{ display: "flex", alignItems: "center", gap: gapPx, paddingRight: gapPx }}
>
<div style={{ whiteSpace: "nowrap", flexShrink: 0 }}>{unit}</div>
{mark}
</div>,
);
});
}
return (
<div
className={className}
style={{
display: "flex",
justifyContent: direction === "left" ? "flex-start" : "flex-end",
width: "100%",
overflow: "hidden",
paddingBlock: fontPx * 0.3,
background,
fontFamily: theme.fonts[font],
fontSize: fontPx,
fontWeight: weight ?? (font === "heading" ? theme.headingWeight : 500),
lineHeight: 1.1,
letterSpacing: font === "heading" ? "-0.02em" : 0,
color: color ?? theme.colors.foreground,
maskImage: mask,
WebkitMaskImage: mask,
opacity: m.presence,
...style,
}}
>
<div
style={{
display: "flex",
flexShrink: 0,
alignItems: "center",
paddingLeft: gapPx,
translate: `${direction === "left" ? -offset : offset}px 0`,
}}
>
{cells}
</div>
</div>
);
}