# Animated List (Moonarc)

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.

- Import: `import AnimatedList from '@moonarc/core/AnimatedList'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/animated-list.json`
- Tier A · category ui · trigger always, load
- Readout: `<AnimatedList interval="base" max={4}>`
- Browser support: newly (Chrome 125 · Firefox 129 · Safari 17.5); elsewhere: without round() or registered properties the interval mode shows the first items still; live items appear at once
- Measured cost: 0 B JS (CSS 10.2 kB raw)
- Page: https://moonarc.dev/components/animated-list/

## Props

| 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. |

## Usage

```astro
<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" />
```

## 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.

## Craft

- The cycle is one registered <number> animated 0 → 1 with literal keyframes (Firefox will not interpolate a var() end value) over n × slot, phased per item with a negative delay of (n − index) × slot, so the items arrive in source order.
- The item's slot k = round(down, t × n) is the only state. Visible while k < max, leaving at k = max, parked above the list after that. These are three numbers from clamp() and a square, with no abs() needed, and translate, opacity and scale are calc() on them.
- Every change of k is a plain CSS transition on the preset curve: the arrival from above, each push down a row and the exit below the box are the same motion, and the parked → 0 jump happens at opacity 0 where no one sees it. That is why the list needs max + 2 items: one slot to leave, one to hide.
- Rows are absolutely positioned at a fixed height so translate is a length the CSS can compute; the box is max rows tall and clips. A notification card is the same height every time, so the constraint costs nothing.
- Live mode is the Accordion trick per item (a grid row from 0fr to 1fr through @starting-style), so a prepended item pushes the others down on the same curve instead of jumping the layout, and the one pushed past max leaves through allow-discrete. Visibility is by position (the first max children), so the newest goes first: appended to a full list, it would be past max itself.

## Replaces

- AnimatedList (Magic UI)
- AnimatedList (React Bits)
- AnimatePresence list (Framer Motion)

## Source

```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>

```
