Domphy

animatedList

A Core block/component from Magic UI — clean-room reimplemented for Domphy (see methodology). Call animatedList() with no arguments for a working demo, or edit the code below live.

Implementation notes

Full behavior: interval-driven insertion (configurable delay/maxItems/direction/loop), motion()-driven fade+translateY+scale entrance with an ease-out-back cubic-bezier curve, transitionGroup() FLIP reflow for the push-down of existing cards, CSS hover scale-up (isolated on an inner wrapper so it doesn't fight the outer WAAPI entrance transform), and a bottom (or top, for direction:'bottom') gradient fade mask. Only simplification: the entrance easing is a cubic-bezier approximation of a spring, not a literal mass/stiffness/damping integrator (Domphy's motion() patch has no such primitive) — matches the same approximation used elsewhere in this port batch (e.g. terminal.ts).

Status: ported · Reference: Magic UI original

// magicui "Animated List" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A
// vertically stacked feed of notification-style cards that enter one after
// another on an interval timer, giving a live activity-feed feel. New cards
// fade/slide/scale in via the Web Animations API (`motion()`), existing cards
// smoothly shift position as new ones are inserted (`transitionGroup()`'s
// FLIP reflow), and a bottom gradient mask dissolves cards that scroll past
// the visible edge instead of clipping them abruptly.

import type { DomphyElement, Listener } from "@domphy/core";
import { toState } from "@domphy/core";
import { motion, small, strong, transitionGroup } from "@domphy/ui";
import { type ThemeColor, themeColor, themeDensity, themeSize, themeSpacing } from "@domphy/theme";

export interface AnimatedListItem {
  /** Emoji or short glyph rendered inside the colored badge square. */
  icon: string;
  /** Badge/accent color for this notification type. */
  color: ThemeColor;
  title: string;
  time: string;
  description: string;
}

export interface AnimatedListProps {
  /** Source notifications cycled one at a time into the feed. Defaults to a sample activity stream. */
  items?: AnimatedListItem[];
  /** Milliseconds between each new item's insertion. Defaults to 1000. */
  intervalDelay?: number;
  /** Max items kept mounted before the oldest are recycled out. Defaults to 5. */
  maxItems?: number;
  /** Insertion edge: "top" pushes new items in above (list grows downward), "bottom" appends below (list grows upward). Defaults to "top". */
  direction?: "top" | "bottom";
  /** Wrap back to the start of `items` once exhausted. Defaults to true. */
  loop?: boolean;
  /** Container max-height, in `themeSpacing` units. Defaults to 112 (~28em). */
  maxHeightUnits?: number;
}

const DEFAULT_ITEMS: AnimatedListItem[] = [
  { icon: "💸", color: "info", title: "Payment received", time: "2m ago", description: "$249.00 from Aiden Cole" },
  { icon: "👤", color: "success", title: "New signup", time: "5m ago", description: "Priya Shah joined the workspace" },
  { icon: "💬", color: "secondary", title: "New comment", time: "9m ago", description: "\"Looks great, ship it!\" on Q3 report" },
  { icon: "⭐", color: "warning", title: "5-star review", time: "14m ago", description: "Marcus left feedback on your app" },
  { icon: "📦", color: "info", title: "Order shipped", time: "21m ago", description: "Order #4821 is on its way" },
];

/** Small colored square badge holding the notification's emoji/icon glyph. */
function iconBadge(item: AnimatedListItem): DomphyElement<"span"> {
  return {
    span: item.icon,
    ariaHidden: "true",
    dataTone: "shift-2",
    style: {
      display: "flex",
      alignItems: "center",
      justifyContent: "center",
      flexShrink: "0",
      width: themeSpacing(10),
      height: themeSpacing(10),
      borderRadius: themeSpacing(2.5),
      fontSize: (listener: Listener) => themeSize(listener, "increase-2"),
      backgroundColor: (listener: Listener) => themeColor(listener, "inherit", item.color),
      color: (listener: Listener) => themeColor(listener, "shift-11", item.color),
    },
  };
}

/** Title + timestamp row, and a muted description line below it. */
function textColumn(item: AnimatedListItem): DomphyElement<"div"> {
  return {
    div: [
      {
        div: [
          { strong: item.title, $: [strong()] },
          { small: item.time, $: [small()] },
        ],
        style: { display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: themeSpacing(3) },
      },
      { small: item.description, $: [small()] },
    ],
    style: {
      display: "flex",
      flexDirection: "column",
      gap: themeSpacing(1),
      minWidth: "0",
      overflow: "hidden",
      flex: "1 1 auto",
    },
  };
}

/**
 * One notification card, wrapped in an outer keyed entry that carries the
 * mount (`motion()`) transition. The hover scale-up lives on the inner card
 * chrome instead, so it doesn't fight the outer element's WAAPI-driven enter
 * transform (Web Animations composite above CSS transitions on the same
 * property/element, which would otherwise suppress the hover effect once the
 * entrance animation settles).
 */
function notificationEntry(item: AnimatedListItem, renderKey: string): DomphyElement<"div"> {
  return {
    div: [
      {
        div: [iconBadge(item), textColumn(item)],
        dataTone: "shift-1",
        style: {
          display: "flex",
          alignItems: "center",
          width: "100%",
          gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
          padding: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
          borderRadius: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
          backgroundColor: (listener: Listener) => themeColor(listener, "inherit"),
          color: (listener: Listener) => themeColor(listener, "shift-10"),
          boxShadow: (listener: Listener) =>
            `0 ${themeSpacing(2)} ${themeSpacing(8)} ${themeColor(listener, "shift-4")}`,
          backdropFilter: (listener: Listener) => `blur(${themeSpacing(3)})`,
          cursor: "default",
          transition: "transform 150ms ease",
          "&:hover": { transform: "scale(1.02)" },
        },
      },
    ],
    _key: renderKey,
    style: { width: "100%" },
    $: [
      motion({
        initial: { opacity: 0, y: -16, scale: 0.92 },
        animate: { opacity: 1, y: 0, scale: 1 },
        transition: { duration: 420, easing: "cubic-bezier(0.16, 1, 0.3, 1)" },
      }),
    ],
  };
}

/** Decorative bottom (or top, for `direction: "bottom"`) fade-to-background mask. */
function edgeFadeMask(fadeAtBottom: boolean): DomphyElement<"div"> {
  return {
    div: null,
    ariaHidden: "true",
    style: {
      position: "absolute",
      insetInline: "0",
      top: fadeAtBottom ? undefined : "0",
      bottom: fadeAtBottom ? "0" : undefined,
      height: themeSpacing(28),
      pointerEvents: "none",
      color: (listener: Listener) => themeColor(listener, "shift-9"),
      backgroundImage: (listener: Listener) =>
        `linear-gradient(${fadeAtBottom ? "to bottom" : "to top"}, transparent, ${themeColor(listener, "inherit")})`,
    },
  };
}

/**
 * Vertically stacked feed of notification-style cards that stream in one at
 * a time on an interval timer, each animating in with a fade/slide/scale
 * entrance while older cards reflow to make room. Call with no arguments for
 * a working demo — a sample activity stream cycling every second.
 */
function animatedList(props: AnimatedListProps = {}): DomphyElement<"div"> {
  const items = props.items ?? DEFAULT_ITEMS;
  const intervalDelay = props.intervalDelay ?? 1000;
  const maxItems = Math.max(1, props.maxItems ?? 5);
  const direction = props.direction ?? "top";
  const loop = props.loop ?? true;
  const maxHeightUnits = props.maxHeightUnits ?? 112;

  interface Entry {
    item: AnimatedListItem;
    key: string;
  }

  const visibleEntries = toState<Entry[]>([]);
  let sourceIndex = 0;
  let insertCount = 0;

  const pushNext = () => {
    if (sourceIndex >= items.length) {
      if (!loop) return;
      sourceIndex = 0;
    }
    const nextItem = items[sourceIndex];
    sourceIndex += 1;
    insertCount += 1;
    const entry: Entry = { item: nextItem, key: `entry-${insertCount}` };

    const current = visibleEntries.get();
    const next = direction === "top" ? [entry, ...current] : [...current, entry];
    // Keep a small buffer beyond `maxItems` so the oldest card visually
    // scrolls under the fade mask before being trimmed, instead of popping
    // abruptly the instant the limit is reached.
    const bufferedMax = maxItems + 2;
    const trimmed =
      next.length > bufferedMax
        ? direction === "top"
          ? next.slice(0, bufferedMax)
          : next.slice(next.length - bufferedMax)
        : next;
    visibleEntries.set(trimmed);
  };

  return {
    div: [
      {
        div: (listener: Listener) =>
          visibleEntries.get(listener).map((entry) => notificationEntry(entry.item, entry.key)),
        $: [transitionGroup({ duration: 350 })],
        style: {
          display: "flex",
          flexDirection: direction === "top" ? "column" : "column-reverse",
          gap: (listenerValue: Listener) => themeSpacing(themeDensity(listenerValue) * 3),
          padding: (listenerValue: Listener) => themeSpacing(themeDensity(listenerValue) * 3),
        },
      },
      edgeFadeMask(direction === "top"),
    ],
    style: {
      position: "relative",
      overflow: "hidden",
      width: "100%",
      maxHeight: themeSpacing(maxHeightUnits),
    },
    _onMount: (node) => {
      pushNext();
      const timer = setInterval(pushNext, intervalDelay);
      node.addHook("Remove", () => clearInterval(timer));
    },
  };
}

export { animatedList };

← Back to Magic UI catalog