Scroll Reveal
ScrollEntrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation.
Infinite scrolling strip for logos, quotes or tags in any direction. The content is rendered twice (or repeat times) and the track moves by exactly one copy, so there is never a seam; a copy narrower than the strip stretches to fill it, so a short list never leaves a blank band. Pauses on hover; becomes a static scrollable strip under reduced motion. Pure CSS.
Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.
Adjust · props are custom properties, set inline, so you edit the running instance
JavaScript of its own
0 B
CSS 6.0 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.
Browser support
Chrome 120 · Firefox 53 · Safari 15.4; needs mask-image.
One command adds the integration, the base tokens and every component. Then import what you use.
npx astro add moonarcpnpm astro add moonarcbunx astro add moonarc---
import Marquee from '@moonarc/core/Marquee';
---
<Marquee duration={28} gap="3rem">
<img src="/logos/a.svg" alt="A" />
<img src="/logos/b.svg" alt="B" />
</Marquee>Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/marquee.jsonThe CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.
The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.
---
/**
* Marquee — seamless infinite strip, zero JS.
*
* The slot is rendered twice (or `repeat` times); the track translates by
* exactly one copy, so the loop never shows a seam. The track is at least one
* strip plus one copy's share wide and the copies stretch to fill it, spacing
* their items evenly, so a copy narrower than the strip never leaves a blank
* band: a percentage of the strip, nothing measured. Pauses on hover so a
* reader can stop a logo or a link. Reduced motion turns it into a static,
* scrollable strip.
*
* The strip is a tab stop (unless it is aria-hidden): focus pauses it — the
* keyboard's way to stop moving content (WCAG 2.2.2) — and under reduced motion
* the arrow keys scroll it. The extra copies are inert, so their links are
* never reached twice.
*/
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'div'> {
/** Seconds for one full loop. Longer strips need more. */
duration?: number;
/** Travel direction; left and right are physical, the same on a right-to-left page. Up and down need a height on the strip. */
direction?: 'left' | 'right' | 'up' | 'down';
/** Copies of the content in the strip, at least 2. More copies make a short list denser; the strip is covered either way. */
repeat?: number;
/** Space between items and between the copies. Any CSS length. */
gap?: string;
/** Pause while hovered (pointer devices only). */
pauseOnHover?: boolean;
/** Fade the strip's edges. */
fade?: boolean;
/** Accessible name; makes the strip a named region ("Customers"). */
label?: string;
}
const { duration = 28, direction = 'left', repeat = 2, gap = '2rem', pauseOnHover = true, fade = true, label, class: className, style, ...rest } = Astro.props;
const hidden = rest['aria-hidden'] === true || rest['aria-hidden'] === 'true';
const vertical = direction === 'up' || direction === 'down';
const reverse = direction === 'right' || direction === 'down';
const copies = Math.max(2, Math.round(repeat) || 2);
const vars = [`--ma-marquee-duration:${duration}s`, `--ma-marquee-gap:${gap}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---
<div
class:list={['ma-marquee', className]}
data-vertical={vertical ? '' : undefined}
data-reverse={reverse ? '' : undefined}
data-pause={pauseOnHover ? '' : undefined}
data-fade={fade ? '' : undefined}
tabindex={hidden ? undefined : 0}
role={label ? 'region' : undefined}
aria-label={label}
style={vars}
{...rest}
>
<div class="ma-marquee__track" style={copies > 2 ? `--ma-marquee-copies:${copies}` : undefined}>
<div class="ma-marquee__group"><slot /></div>
{Array.from({ length: copies - 1 }, () => <div class="ma-marquee__group" aria-hidden="true" inert><slot /></div>)}
</div>
</div>
<style is:global>
@layer components {
:where(.ma-marquee) {
overflow: hidden;
/* A flex/grid child defaults to min-width: auto = its min-content, and the
track is max-content wide, so without this the strip leaks its whole
width into the parent column and locks the grid. */
min-width: 0;
max-width: 100%;
contain: layout paint;
}
:where(.ma-marquee[data-fade]) {
mask-image: linear-gradient(90deg, transparent, #000 10%, #000 90%, transparent);
}
:where(.ma-marquee[data-fade][data-vertical]) {
mask-image: linear-gradient(180deg, transparent, #000 10%, #000 90%, transparent);
}
/* at least n / (n - 1) strips wide for n copies (two copies: twice the strip), and the copies grow to fill it, so
each is at least 1 / (n - 1) of the strip and the n - 1 copies in view always cover it: the loop never runs out
of content before it wraps */
:where(.ma-marquee__track) {
display: flex;
width: max-content;
min-inline-size: calc(100% * var(--ma-marquee-copies, 2) / (var(--ma-marquee-copies, 2) - 1));
animation: ma-marquee var(--ma-marquee-duration, 28s) linear infinite;
}
:where(.ma-marquee[data-vertical] .ma-marquee__track) {
flex-direction: column;
width: auto;
height: max-content;
min-inline-size: auto;
/* a percentage of a definite height only: a strip with no height set has nothing to fill */
min-block-size: calc(100% * var(--ma-marquee-copies, 2) / (var(--ma-marquee-copies, 2) - 1));
animation-name: ma-marquee-vertical;
}
:where(.ma-marquee[data-reverse] .ma-marquee__track) {
animation-direction: reverse;
}
/* right to left the track sits at the strip's right edge and the copies lie to the left of the first, so the loop
starts one copy to the right and comes back to rest (--ma-marquee-rtl in the keyframes); left and right stay
physical. [dir] and not :dir(): :dir() reached Chrome in 120, and a build that targets an older Chrome rewrites it into a guess from lang, which a
page that sets only dir never matches. An ltr island one level inside an rtl page is set back */
:where([dir='rtl'] .ma-marquee, .ma-marquee[dir='rtl']) {
--ma-marquee-rtl: 1;
}
:where([dir='rtl'] [dir='ltr'] .ma-marquee, [dir='rtl'] .ma-marquee[dir='ltr']) {
--ma-marquee-rtl: 0;
}
/* space-around: a stretched copy spreads its items, and the half spaces at its two ends add up to one full space
across the seam, so the rhythm is even all the way round */
:where(.ma-marquee__group) {
display: flex;
flex: 1 0 auto;
align-items: center;
justify-content: space-around;
gap: var(--ma-marquee-gap, 2rem);
padding-inline-end: var(--ma-marquee-gap, 2rem);
}
:where(.ma-marquee[data-vertical] .ma-marquee__group) {
flex-direction: column;
padding-inline-end: 0;
padding-block-end: var(--ma-marquee-gap, 2rem);
}
@media (hover: hover) and (pointer: fine) {
:where(.ma-marquee[data-pause]:hover .ma-marquee__track) {
animation-play-state: paused;
}
}
/* focus pauses whatever pauseOnHover says: it is the keyboard's only way to stop the strip */
:where(.ma-marquee:focus-within .ma-marquee__track) {
animation-play-state: paused;
}
@media (prefers-reduced-motion: reduce) {
:where(.ma-marquee__track) {
animation: none;
min-inline-size: auto;
min-block-size: auto;
}
:where(.ma-marquee) {
overflow: auto;
mask-image: none;
}
:where(.ma-marquee__group[aria-hidden]) {
display: none;
}
}
}
/* one copy to the left: from rest, or right to left from one copy to the right back to rest */
@keyframes ma-marquee {
from {
translate: calc(var(--ma-marquee-rtl, 0) * 100% / var(--ma-marquee-copies, 2)) 0;
}
to {
translate: calc((var(--ma-marquee-rtl, 0) - 1) * 100% / var(--ma-marquee-copies, 2)) 0;
}
}
@keyframes ma-marquee-vertical {
to {
translate: 0 calc(-100% / var(--ma-marquee-copies, 2));
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
duration | number | 28 | Seconds for one full loop. Scale with content width: roughly 1 s per 60 px keeps logos readable. |
direction | 'left' | 'right' | 'up' | 'down' | 'left' | Travel direction. Left and right are physical: the same on a right-to-left page. Up and down need a height on the strip (a class or a style), which the copies then fill. |
repeat | number | 2 | Copies of the content, at least 2. A copy narrower than the strip is stretched and its items spaced out; more copies keep a short list dense instead. |
gap | string | '2rem' | Space between items and between the copies. |
pauseOnHover | boolean | true | Pause while hovered, on pointer devices only. |
fade | boolean | true | Fade the strip edges with a mask. |
label | string | none | Accessible name; the strip becomes a named region ("Customers"). |
The strip stops, the extra copies are hidden, and the container becomes scrollable so nothing is unreachable.
ClientRouterCSS-only; nothing to rebind.
Replaces: LogoLoop (React Bits) · Marquee (Magic UI) · InfiniteMovingCards (Aceternity) · react-fast-marquee. See the migration table.
Entrance on scroll that plays once and stays, staggers its children, scrubs with the scroll position when asked, honours reduced motion, and keeps working after every ClientRouter navigation.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown