Skip to content

Components / UI

Animated List

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 demo

  • Deployedmain → production in 41 s
  • New signupari@studio.example
  • Payment received$120 · Plus, yearly
  • Comment"looks great on mobile"
  • Build passed1 216 checks, 0 failed
  • Starredsomeone starred the repo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

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

Baseline · newly available

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.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
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" />
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/animated-list.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

AnimatedList.astro
---
/**
 * 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>

Props

PropTypeDefaultDescription
items{ title: string; body?: string; time?: string; icon?: string; color?: string }[]noneCards. Or slot your own <li> children, each with one child element.
interval'fast' | 'base' | 'slow'noneCycle 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.
maxnumber4Items visible at once, 2–12.
rowstring'3.5rem'Row height in interval mode; every item is this tall.
gapstring'0.5rem'Space between rows.
livebooleanfalseIn 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.

Reduced motion

The first max items, still, in a plain column; live additions appear without motion.

With ClientRouter

CSS-only; nothing to rebind. The cycle restarts with the page.

Why it is built this way

Replaces: AnimatedList (Magic UI) · AnimatedList (React Bits) · AnimatePresence list (Framer Motion). See the migration table.

Marquee

Ticker

Infinite scrolling strip for logos, quotes or tags in any direction.

every browseralwayshover

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.

every browserscroll

Notifications that slide in, stack behind each other, push up as new ones arrive, and expand into a list on hover.

newly · Chrome 117 · Firefox 129 · Safari 17.5clickhover

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown