{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "animated-list",
  "type": "registry:ui",
  "title": "Animated List",
  "description": "A feed that reveals its children one at a time, newest on top, each entry springing in from `scale: 0`. One more appears every `delay` ms (1000 by default) until the list is full, then it stops — or restarts, with `loop`.",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "decorative",
    "effect"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/use-reduced-motion.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/magicui/animated-list.tsx",
      "target": "components/ui/magicui/animated-list.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport {\n  Children,\n  forwardRef,\n  useEffect,\n  useMemo,\n  useState,\n  type ComponentPropsWithoutRef,\n  type ReactElement,\n  type ReactNode,\n} from \"react\";\n\n// @interlace/animated-list v1.2.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/animated-list\n// What changed since: https://ds.interlace.tools/c/animated-list#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — AnimatedList + AnimatedListItem\n *\n * A feed that reveals its children one at a time, newest on top, each entry\n * springing in from `scale: 0`. One more appears every `delay` ms (1000 by\n * default) until the list is full, then it stops — or restarts, with `loop`.\n *\n * The list is `aria-live=\"polite\"`, so each arrival is announced.\n *\n * ## Provenance\n *\n * Our reimplementation of the Magic UI `AnimatedList` concept. What is ours:\n * the reduced-motion gating, the visible pause control, and a `loop` that\n * really restarts — upstream advanced with `% length` but never reset the\n * visible window, so it stalled at the end instead of looping. The spring\n * physics, gap, direction and per-step delay are all props with structural\n * defaults, so nothing product-specific is baked in.\n *\n * ## Anatomy\n *\n *   div.relative\n *     ├─ div                         (data-slot=\"animated-list\", aria-live=polite)\n *     │   └─ AnimatedListItem ×      (data-slot=\"animated-list-item\", motion.div)\n *     └─ button                      (data-slot=\"animated-list-pause\", aria-pressed)\n *\n * ## Motion\n *\n * The sequencing is JS-driven (a `setTimeout` chain) and the pop-in is\n * `motion/react`, so the CSS reset in `styles/preflight.css` reaches neither.\n * Every layer is gated in JS instead:\n *\n * | Layer            | Driven by                    | Under `reduce`                       |\n * | ---------------- | ---------------------------- | ------------------------------------ |\n * | auto-advance     | `setTimeout` chain           | `isPlaying` false — never ticks      |\n * | visible window   | `revealCount` state          | full child count, rendered at once   |\n * | pause control    | —                            | not rendered (nothing left to pause) |\n * | entry pop-in     | `motion/react` spring        | `initial={false}` — mounts settled   |\n * | reflow slide     | `motion/react` `layout` FLIP | `layout` off                         |\n *\n * `AnimatedListItem` reads the preference itself rather than inheriting it\n * from the list, because it is exported and composed standalone. No\n * `MotionConfig` wraps this tree, so there is no ambient fallback — the gate\n * has to live on the component that emits the animation.\n *\n * ## WCAG 2.2.2 (Pause, Stop, Hide)\n *\n * A reveal running longer than 5s is auto-updating content and needs an\n * explicit pause affordance, so `showPauseControl` defaults to `true` and\n * renders a Tab-reachable button carrying `aria-pressed`. `pauseOnHover` is\n * the pointer-only complement and defaults to `false`.\n */\n\nimport { AnimatePresence, motion, type Transition } from \"motion/react\";\nimport { Pause, Play } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"@/hooks/use-reduced-motion\";\n\nconst DEFAULT_TRANSITION: Transition = {\n  type: \"spring\",\n  stiffness: 350,\n  damping: 40,\n};\n\ninterface AnimatedListItemProps extends ComponentPropsWithoutRef<typeof motion.div> {\n  /**\n   * The single feed entry to animate in. Slot it as a `ReactNode` so consumers\n   * compose their own card/row/toast without this component owning the shape.\n   */\n  children: ReactNode;\n  /**\n   * Spring transition applied to the pop-in. Override to retune the physics\n   * (e.g. softer damping for large cards).\n   * @default { type: \"spring\", stiffness: 350, damping: 40 }\n   */\n  transition?: Transition;\n}\n\n/**\n * A single animated entry in an {@link AnimatedList}. Exposed so consumers can\n * compose the list manually (e.g. drive the visible window from their own\n * state) instead of relying on the auto-advance behavior.\n */\n/** The at-rest keyframe. Under `reduce` every phase collapses onto it. */\nconst SETTLED = { scale: 1, opacity: 1 } as const;\n\nexport const AnimatedListItem = forwardRef<HTMLDivElement, AnimatedListItemProps>(\n  function AnimatedListItem(\n    { children, transition = DEFAULT_TRANSITION, className, ...props },\n    ref,\n  ) {\n    // Gated here rather than at the list, because this component is exported\n    // and composed on its own — a consumer driving the visible window from\n    // their own state gets the same contract as `AnimatedList` does.\n    //\n    // `initial={false}` is motion's \"mount at the `animate` values\", which is\n    // the only spelling that skips the pop-in outright; `initial={SETTLED}`\n    // would still run a zero-distance animation and still write a transform.\n    const reducedMotion = useReducedMotion();\n\n    return (\n      <motion.div\n        ref={ref}\n        data-slot=\"animated-list-item\"\n        // `layout` is a JS-driven FLIP on every reflow — a sibling arriving\n        // slides this one down. That is motion too, so it goes with the rest.\n        layout={!reducedMotion}\n        initial={reducedMotion ? false : { scale: 0, opacity: 0 }}\n        animate={SETTLED}\n        exit={reducedMotion ? SETTLED : { scale: 0, opacity: 0 }}\n        transition={reducedMotion ? { duration: 0 } : transition}\n        className={cn(\"mx-auto w-full\", className)}\n        {...props}\n      >\n        {children}\n      </motion.div>\n    );\n  },\n);\n\nexport interface AnimatedListProps extends ComponentPropsWithoutRef<\"div\"> {\n  /**\n   * The feed entries to reveal in sequence. Each child should carry a stable\n   * `key`; the list animates entries in one-at-a-time, newest first.\n   */\n  children: ReactNode;\n  /**\n   * Milliseconds between each entry being revealed.\n   * @default 1000\n   */\n  delay?: number;\n  /**\n   * Restart the reveal from the first entry once the last one has appeared,\n   * producing a continuous loop. When `false`, the list settles on the full\n   * set and stops.\n   * @default false\n   */\n  loop?: boolean;\n  /**\n   * Spring transition for each entry's pop-in. Forwarded to every\n   * {@link AnimatedListItem}.\n   * @default { type: \"spring\", stiffness: 350, damping: 40 }\n   */\n  transition?: Transition;\n  /**\n   * Pause the reveal while the pointer is over the list. Pairs with the\n   * keyboard-reachable pause control for full WCAG 2.2.2 coverage.\n   * @default false\n   */\n  pauseOnHover?: boolean;\n  /**\n   * Render a visible play/pause button (WCAG 2.2.2 — Pause, Stop, Hide for\n   * auto-updating content that runs longer than 5s). Turn off only when an\n   * enclosing surface exposes its own pause control.\n   * @default true\n   */\n  showPauseControl?: boolean;\n  /**\n   * Accessible label for the pause control. Customize per context\n   * (e.g. \"Pause activity feed\").\n   * @default \"Pause animated feed\"\n   */\n  pauseLabel?: string;\n  /**\n   * Stable selector hook for E2E tests. No runtime default — supply one per\n   * usage so omissions surface instead of silently sharing a selector.\n   */\n  \"data-testid\"?: string;\n}\n\n/**\n * AnimatedList — reveals its children one at a time, newest on top, with a\n * spring pop-in.\n *\n * Motion control is layered like the `Marquee` primitive:\n *   1. `prefers-reduced-motion: reduce` → renders the full list at once, with\n *      no auto-advance and no pop-in. See the table in the file header.\n *   2. `pauseOnHover` → pointer users can hold the reveal.\n *   3. Visible play/pause button → keyboard + screen-reader users get an\n *      explicit, Tab-reachable control.\n *\n * Consumer-agnostic: extends `<div>`, forwards `ref`, merges `className`, and\n * spreads `...props` onto the scroll root.\n */\nexport const AnimatedList = forwardRef<HTMLDivElement, AnimatedListProps>(\n  function AnimatedList(\n    {\n      children,\n      className,\n      delay = 1000,\n      loop = false,\n      transition = DEFAULT_TRANSITION,\n      pauseOnHover = false,\n      showPauseControl = true,\n      pauseLabel = \"Pause animated feed\",\n      ...props\n    },\n    ref,\n  ) {\n    const reducedMotion = useReducedMotion();\n    const [paused, setPaused] = useState(false);\n    const [hovering, setHovering] = useState(false);\n    const [revealCount, setRevealCount] = useState(1);\n\n    const childrenArray = useMemo(() => Children.toArray(children), [children]);\n    const total = childrenArray.length;\n\n    // The reveal advances iff the user hasn't paused it, the pointer isn't\n    // holding it (when pauseOnHover), and reduced-motion isn't set. Reduced\n    // motion is the hard override — it shows everything and never ticks.\n    const isPlaying = !paused && !(pauseOnHover && hovering) && !reducedMotion;\n\n    // Reduced-motion users see the complete feed immediately.\n    const effectiveCount = reducedMotion ? total : revealCount;\n\n    useEffect(() => {\n      // Reset the window whenever the children identity or length changes so a\n      // new feed always starts from the first entry.\n      setRevealCount(total > 0 ? 1 : 0);\n    }, [total]);\n\n    useEffect(() => {\n      if (!isPlaying || total === 0) return;\n      if (!loop && revealCount >= total) return;\n\n      const timeout = setTimeout(() => {\n        setRevealCount((count) => {\n          if (count >= total) return loop ? 1 : count;\n          return count + 1;\n        });\n      }, delay);\n\n      return () => clearTimeout(timeout);\n    }, [isPlaying, revealCount, total, delay, loop]);\n\n    // Newest entry on top, matching the upstream \"incoming feed\" feel.\n    const itemsToShow = useMemo(\n      () => childrenArray.slice(0, effectiveCount).reverse(),\n      [childrenArray, effectiveCount],\n    );\n\n    const showControl = showPauseControl && !reducedMotion && total > 0;\n    const resumeLabel = `Resume ${pauseLabel.toLowerCase().replace(/^pause\\s+/, \"\")}`;\n\n    return (\n      <div className=\"relative\">\n        <div\n          ref={ref}\n          data-slot=\"animated-list\"\n          className={cn(\"flex flex-col items-center gap-4\", className)}\n          onMouseEnter={pauseOnHover ? () => setHovering(true) : undefined}\n          onMouseLeave={pauseOnHover ? () => setHovering(false) : undefined}\n          aria-live=\"polite\"\n          {...props}\n        >\n          <AnimatePresence>\n            {itemsToShow.map((item) => (\n              <AnimatedListItem\n                key={(item as ReactElement).key}\n                transition={transition}\n              >\n                {item}\n              </AnimatedListItem>\n            ))}\n          </AnimatePresence>\n        </div>\n\n        {showControl && (\n          <button\n            type=\"button\"\n            data-slot=\"animated-list-pause\"\n            onClick={() => setPaused((value) => !value)}\n            aria-label={isPlaying ? pauseLabel : resumeLabel}\n            aria-pressed={!isPlaying}\n            className=\"absolute right-2 top-2 z-10 inline-flex size-9 items-center justify-center rounded-full border border-border bg-background/80 text-foreground backdrop-blur-sm transition-colors hover:bg-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n          >\n            {isPlaying ? (\n              <Pause className=\"size-4\" aria-hidden=\"true\" />\n            ) : (\n              <Play className=\"size-4\" aria-hidden=\"true\" />\n            )}\n          </button>\n        )}\n      </div>\n    );\n  },\n);\n"
    }
  ],
  "meta": {
    "tier": "effect",
    "client": true,
    "minViewport": null,
    "loading": false,
    "version": "1.2.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/animated-list\n\nInstalled to `components/ui/magicui/animated-list.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/magicui/animated-list';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/animated-list\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
