Marquee
TickerInfinite scrolling strip for logos, quotes or tags in any direction.
Components / UI
Shows notifications arriving one at a time: the new one drops in at the top, the older ones are pushed down, and the oldest slides out. Zero JavaScript: in interval mode one registered number runs over the period on every item and the item's time slot decides where it is. Every change is a transition on the preset curve, and no keyframe knows how many items there are. In live mode the host prepends items (newest first) and they grow in through @starting-style while the oldest leaves.
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 10.2 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 125 · Firefox 129 · Safari 17.5; needs property, css-math, has, starting-style, allow-discrete, grid-rows-animation. Elsewhere: without round() or registered properties the interval mode shows the first items still; live items appear at once.
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 AnimatedList from '@moonarc/core/AnimatedList';
---
<AnimatedList
interval="base"
max={4}
items={[
{ title: 'Deployed', body: 'main → production in 41 s', time: 'now', color: 'var(--cat-green)' },
{ title: 'New signup', body: 'ari@studio.example', time: '1m' },
{ title: 'Payment received', body: '$120 · Plus, yearly', time: '2m' },
{ title: 'Comment', body: '"looks great on mobile"', time: '3m' },
{ title: 'Build passed', body: '1 076 checks', time: '5m' },
{ title: 'Star', body: 'someone starred the repo', time: '6m' },
]}
/>
<!-- live: prepend each new <li> (one child element) yourself, newest first: feed.prepend(li); role="log" announces it -->
<AnimatedList live max={5} id="feed" />Owns the file, no dependency. The registry item also installs the base tokens.
npx shadcn@latest add https://moonarc.dev/r/animated-list.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.
---
/**
* AnimatedList — notifications arriving one at a time, the older ones
* pushed down and the oldest pushed out, zero JS.
*
* Two modes. `interval` is a pure-CSS cycle for demos and landing pages:
* one registered number runs 0 → 1 over the whole period on every item,
* phased by index; the item's time slot k = floor(t × n) says whether it is
* visible (k < max), leaving (k = max) or parked above the list waiting to
* re-enter, and its translate, opacity and scale are calc() on k — every
* change of k is a CSS transition on the preset curve, so the arrival, the
* push and the exit are all the same curve, and no keyframe knows how many
* items there are. A list too short to cycle (fewer than max + 2 items)
* stays still. Live mode is the host's: prepend an item (newest first) and
* it grows in through @starting-style while the item pushed past `max`
* leaves through allow-discrete; an item appended to a full list is past
* `max` itself and never shows. Reduced motion shows the first `max`
* items, still.
*/
import type { HTMLAttributes } from 'astro/types';
interface Item {
title: string;
body?: string;
time?: string;
/** Inline SVG or any HTML for the badge; default the first letter of the title. */
icon?: string;
/** Badge colour. */
color?: string;
}
interface Props extends HTMLAttributes<'ul'> {
/** Items rendered as cards. Or slot your own <li> children (each with one child element). */
items?: Item[];
/** Cycle on its own: the slot each item gets, as a duration token. */
interval?: 'fast' | 'base' | 'slow';
/** Items visible at once (2–12). */
max?: number;
/** Row height in interval mode (every item is this tall). */
row?: string;
/** Gap between rows. */
gap?: string;
/** Announce additions: role="log" + aria-live="polite" (live mode). */
live?: boolean;
}
const { items, interval, max = 4, row = '3.5rem', gap = '0.5rem', live = false, class: className, style, ...rest } = Astro.props;
const n = items?.length;
// the cycle needs one exit slot and one parked slot after the visible ones
const shown = Math.max(2, Math.min(12, n ? Math.min(max, n - 2) : max));
const inline = [`--ma-alist-max:${shown}`, `--ma-alist-row:${row}`, `--ma-alist-gap:${gap}`, n && `--ma-alist-n:${n}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
const glyph = (t: string) => `<b>${t.trim().charAt(0).toUpperCase()}</b>`;
---
<ul
class:list={['ma-alist', className]}
data-interval={interval}
data-max={shown}
role={live && !interval ? 'log' : undefined}
aria-live={live && !interval ? 'polite' : undefined}
style={inline}
{...rest}
>
{
items ? (
items.map((it: Item) => (
<li class="ma-alist__item">
<div class="ma-alist__card" style={it.color ? `--ma-alist-color:${it.color}` : undefined}>
<span class="ma-alist__badge" aria-hidden="true" set:html={it.icon ?? glyph(it.title)} />
<span class="ma-alist__text">
<span class="ma-alist__title">{it.title}</span>
{it.body && <span class="ma-alist__body">{it.body}</span>}
</span>
{it.time && <time class="ma-alist__time">{it.time}</time>}
</div>
</li>
))
) : (
<slot />
)
}
</ul>
<style is:global>
@property --ma-alist-t {
syntax: '<number>';
inherits: false;
initial-value: 0;
}
@layer components {
:where(.ma-alist) {
display: grid;
gap: var(--ma-alist-gap, 0.5rem);
margin: 0;
padding: 0;
list-style: none;
}
/* index and count, from position — so slotted children work the same as items. The index carries the list's own
name: it inherits into the cards, and a component inside one would read a shared --ma-i as its own index */
:where(.ma-alist > :nth-child(1)) { --ma-alist-i: 0; }
:where(.ma-alist > :nth-child(2)) { --ma-alist-i: 1; }
:where(.ma-alist > :nth-child(3)) { --ma-alist-i: 2; }
:where(.ma-alist > :nth-child(4)) { --ma-alist-i: 3; }
:where(.ma-alist > :nth-child(5)) { --ma-alist-i: 4; }
:where(.ma-alist > :nth-child(6)) { --ma-alist-i: 5; }
:where(.ma-alist > :nth-child(7)) { --ma-alist-i: 6; }
:where(.ma-alist > :nth-child(8)) { --ma-alist-i: 7; }
:where(.ma-alist > :nth-child(9)) { --ma-alist-i: 8; }
:where(.ma-alist > :nth-child(10)) { --ma-alist-i: 9; }
:where(.ma-alist > :nth-child(11)) { --ma-alist-i: 10; }
:where(.ma-alist > :nth-child(12)) { --ma-alist-i: 11; }
:where(.ma-alist:has(> :nth-child(1):last-child)) { --ma-alist-n: 1; }
:where(.ma-alist:has(> :nth-child(2):last-child)) { --ma-alist-n: 2; }
:where(.ma-alist:has(> :nth-child(3):last-child)) { --ma-alist-n: 3; }
:where(.ma-alist:has(> :nth-child(4):last-child)) { --ma-alist-n: 4; }
:where(.ma-alist:has(> :nth-child(5):last-child)) { --ma-alist-n: 5; }
:where(.ma-alist:has(> :nth-child(6):last-child)) { --ma-alist-n: 6; }
:where(.ma-alist:has(> :nth-child(7):last-child)) { --ma-alist-n: 7; }
:where(.ma-alist:has(> :nth-child(8):last-child)) { --ma-alist-n: 8; }
:where(.ma-alist:has(> :nth-child(9):last-child)) { --ma-alist-n: 9; }
:where(.ma-alist:has(> :nth-child(10):last-child)) { --ma-alist-n: 10; }
:where(.ma-alist:has(> :nth-child(11):last-child)) { --ma-alist-n: 11; }
:where(.ma-alist:has(> :nth-child(12):last-child)) { --ma-alist-n: 12; }
/* live mode: an item is a grid row that grows in; past max it leaves */
:where(.ma-alist > *) {
display: grid;
grid-template-rows: 1fr;
opacity: 1;
translate: 0 0;
scale: 1;
transition:
grid-template-rows var(--ma-duration) var(--ma-ease),
opacity var(--ma-duration) var(--ma-ease-out),
translate var(--ma-duration) var(--ma-ease),
scale var(--ma-duration) var(--ma-ease),
display var(--ma-duration) allow-discrete;
}
:where(.ma-alist > * > *) {
min-height: 0;
overflow: clip;
}
@starting-style {
:where(.ma-alist:not([data-interval]) > *) {
grid-template-rows: 0fr;
opacity: 0;
translate: 0 calc(var(--ma-travel-enter, 24px) * -1);
scale: 0.96;
}
}
:where(.ma-alist[data-max='2']:not([data-interval]) > :nth-child(n + 3)),
:where(.ma-alist[data-max='3']:not([data-interval]) > :nth-child(n + 4)),
:where(.ma-alist[data-max='4']:not([data-interval]) > :nth-child(n + 5)),
:where(.ma-alist[data-max='5']:not([data-interval]) > :nth-child(n + 6)),
:where(.ma-alist[data-max='6']:not([data-interval]) > :nth-child(n + 7)),
:where(.ma-alist[data-max='7']:not([data-interval]) > :nth-child(n + 8)),
:where(.ma-alist[data-max='8']:not([data-interval]) > :nth-child(n + 9)),
:where(.ma-alist[data-max='9']:not([data-interval]) > :nth-child(n + 10)),
:where(.ma-alist[data-max='10']:not([data-interval]) > :nth-child(n + 11)),
:where(.ma-alist[data-max='11']:not([data-interval]) > :nth-child(n + 12)),
:where(.ma-alist[data-max='12']:not([data-interval]) > :nth-child(n + 13)) {
display: none;
grid-template-rows: 0fr;
opacity: 0;
scale: 0.96;
}
/* interval mode: a fixed box of max rows; items are laid on top of each other and placed by their time slot */
:where(.ma-alist[data-interval]) {
--ma-alist-slot: var(--ma-dur-ambient);
--ma-alist-period: calc(var(--ma-alist-n, 4) * var(--ma-alist-slot));
position: relative;
display: block;
height: calc(var(--ma-alist-max, 4) * (var(--ma-alist-row, 3.5rem) + var(--ma-alist-gap, 0.5rem)) - var(--ma-alist-gap, 0.5rem));
overflow: clip;
}
:where(.ma-alist[data-interval='fast']) {
--ma-alist-slot: var(--ma-dur-gentle);
}
:where(.ma-alist[data-interval='slow']) {
--ma-alist-slot: calc(var(--ma-dur-ambient) * 2);
}
:where(.ma-alist[data-interval] > *) {
/* ok: 1 when the list can cycle (n ≥ max + 2: one slot to leave, one to hide), else 0 and every item keeps k = its index, still */
--ma-alist-ok: clamp(0, var(--ma-alist-n, 4) - var(--ma-alist-max, 4) - 1, 1);
/* k: this item's slot right now — 0 is the newest row, max is on its way out, more is parked above waiting to come back */
--ma-alist-k: calc(var(--ma-alist-ok) * round(down, var(--ma-alist-t) * var(--ma-alist-n, 4)) + (1 - var(--ma-alist-ok)) * var(--ma-alist-i, 0));
--ma-alist-v: clamp(0, var(--ma-alist-max, 4) - var(--ma-alist-k), 1);
--ma-alist-e: clamp(0, 1 - (var(--ma-alist-k) - var(--ma-alist-max, 4)) * (var(--ma-alist-k) - var(--ma-alist-max, 4)), 1);
--ma-alist-p: calc(1 - var(--ma-alist-v) - var(--ma-alist-e));
position: absolute;
inset: 0 0 auto 0;
display: block;
height: var(--ma-alist-row, 3.5rem);
translate: 0
calc(
(var(--ma-alist-v) * var(--ma-alist-k) + var(--ma-alist-e) * var(--ma-alist-max, 4)) * (var(--ma-alist-row, 3.5rem) + var(--ma-alist-gap, 0.5rem)) - var(--ma-alist-p) *
var(--ma-travel-enter, 24px)
);
opacity: var(--ma-alist-v);
scale: calc(1 - 0.06 * (1 - var(--ma-alist-v)));
animation: ma-alist-t var(--ma-alist-period) linear infinite;
animation-delay: calc((var(--ma-alist-n, 4) - var(--ma-alist-i, 0)) * var(--ma-alist-slot) * -1);
}
:where(.ma-alist[data-interval] > * > *) {
height: 100%;
}
/* the card */
:where(.ma-alist__card) {
display: flex;
align-items: center;
gap: 0.75rem;
box-sizing: border-box;
padding: 0.625rem 0.875rem;
border: 1px solid var(--ma-edge);
border-radius: 0.875rem;
background: var(--ma-panel);
box-shadow: 0 10px 28px -20px rgb(0 0 0 / 0.4);
}
:where(.ma-alist__badge) {
display: grid;
flex: none;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 0.625rem;
background: var(--ma-alist-color, var(--ma-ink));
color: var(--ma-panel);
font-size: 0.9375rem;
font-weight: 600;
line-height: 1;
}
:where(.ma-alist__badge svg) {
width: 55%;
height: 55%;
}
:where(.ma-alist__text) {
display: grid;
flex: 1;
min-width: 0;
gap: 0.1rem;
}
:where(.ma-alist__title) {
font-size: 0.875rem;
font-weight: 600;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:where(.ma-alist__body) {
font-size: 0.8125rem;
line-height: 1.3;
opacity: 0.7;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:where(.ma-alist__time) {
flex: none;
align-self: flex-start;
font-family: ui-monospace, monospace;
font-size: 0.6875rem;
opacity: var(--ma-dim, 0.7);
}
/* reduced motion: the first max items, still */
@media (prefers-reduced-motion: reduce) {
:where(.ma-alist > *) {
transition: none;
}
:where(.ma-alist[data-interval]) {
display: grid;
height: auto;
}
:where(.ma-alist[data-interval] > *) {
position: static;
translate: 0 0;
opacity: 1;
scale: 1;
animation: none;
}
:where(.ma-alist[data-max='2'][data-interval] > :nth-child(n + 3)),
:where(.ma-alist[data-max='3'][data-interval] > :nth-child(n + 4)),
:where(.ma-alist[data-max='4'][data-interval] > :nth-child(n + 5)),
:where(.ma-alist[data-max='5'][data-interval] > :nth-child(n + 6)),
:where(.ma-alist[data-max='6'][data-interval] > :nth-child(n + 7)),
:where(.ma-alist[data-max='7'][data-interval] > :nth-child(n + 8)),
:where(.ma-alist[data-max='8'][data-interval] > :nth-child(n + 9)),
:where(.ma-alist[data-max='9'][data-interval] > :nth-child(n + 10)),
:where(.ma-alist[data-max='10'][data-interval] > :nth-child(n + 11)),
:where(.ma-alist[data-max='11'][data-interval] > :nth-child(n + 12)),
:where(.ma-alist[data-max='12'][data-interval] > :nth-child(n + 13)) {
display: none;
}
}
}
@keyframes ma-alist-t {
from {
--ma-alist-t: 0;
}
to {
--ma-alist-t: 1;
}
}
</style>| Prop | Type | Default | Description |
|---|---|---|---|
items | { title: string; body?: string; time?: string; icon?: string; color?: string }[] | none | Cards. Or slot your own <li> children, each with one child element. |
interval | 'fast' | 'base' | 'slow' | none | Cycle on its own; the slot each item gets is the gentle, ambient or twice-ambient duration token. Needs at least max + 2 items: with items, max is lowered to fit; a list that still has too few (fewer than four items, or slotted children under max + 2) stays still. |
max | number | 4 | Items visible at once, 2–12. |
row | string | '3.5rem' | Row height in interval mode; every item is this tall. |
gap | string | '0.5rem' | Space between rows. |
live | boolean | false | In live mode, role="log" and aria-live="polite" so added items are announced. Prepend them: an item appended to a full list is past max and never shows. |
The first max items, still, in a plain column; live additions appear without motion.
ClientRouterCSS-only; nothing to rebind. The cycle restarts with the page.
Replaces: AnimatedList (Magic UI) · AnimatedList (React Bits) · AnimatePresence list (Framer Motion). See the migration table.
Infinite scrolling strip for logos, quotes or tags in any direction.
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.
Notifications that slide in, stack behind each other, push up as new ones arrive, and expand into a list on hover.
Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown