Components / Motion

Animated list

A list container that staggers its rows in on mount, rows that rise in, fall out and glide on reorder (ul, ol, div or tbody/tr), and a block that reveals once as it scrolls into view.

  • SSara KimAdmin
  • LLee ParkMember
  • OOmar HaddadMember
pnpm exec shadcn add @intelligo/animated-list

Inside an app made by intelligo create, or any shadcn project that names the registry.

Source

What shadcn add writes to components/ui/animated-list.tsx. It is yours from then on.

"use client";

/*
 * Lists that arrive: a container that staggers its rows in on mount, rows
 * that rise in, fall out under AnimatePresence and glide when reordered,
 * and a block that reveals once as it scrolls into view.
 */

import * as React from "react";
import {
  motion,
  useInView,
  useReducedMotion,
  type HTMLMotionProps,
  type Variants,
} from "motion/react";

import {
  EASE_OUT,
  listItem,
  listStagger,
  SPRING_LAYOUT,
} from "@showcase/components/ui/ai-motion";
import { cn } from "@showcase/lib/utils";

/* ----------------------------------------------------------------------------
 * AnimatedList: the container. Its children stagger in on mount; rows added
 * later rise in on their own. Render it as the element the markup needs —
 * a `tbody` for table rows keeps the table valid.
 * ------------------------------------------------------------------------- */

export type AnimatedListElement = "ul" | "ol" | "div" | "tbody";

export type AnimatedListProps<T extends AnimatedListElement = "ul"> = Omit<
  HTMLMotionProps<T>,
  "variants" | "initial" | "animate"
> & {
  /** The element to render; `tbody` for table rows. */
  as?: T;
  /** Seconds between one row and the next. */
  stagger?: number;
  /** Seconds before the first row starts. */
  delay?: number;
  /** Play the stagger on mount (default). False shows the rows at rest. */
  animateOnMount?: boolean;
};

const REDUCED_ITEM: Variants = {
  hidden: { opacity: 0 },
  shown: { opacity: 1, transition: { duration: 0 } },
  exit: { opacity: 0, transition: { duration: 0 } },
};

function AnimatedList<T extends AnimatedListElement = "ul">({
  as,
  stagger,
  delay,
  animateOnMount = true,
  className,
  ...props
}: AnimatedListProps<T>) {
  const reduced = useReducedMotion() ?? false;
  const Component = motion[as ?? "ul"] as React.ElementType;

  const variants = React.useMemo<Variants>(() => {
    if (reduced) return { hidden: {}, shown: {} };
    if (stagger === undefined && delay === undefined) return listStagger;
    return {
      hidden: {},
      shown: {
        transition: {
          staggerChildren: stagger ?? 0.04,
          delayChildren: delay ?? 0.02,
        },
      },
    };
  }, [reduced, stagger, delay]);

  return (
    <Component
      data-slot="animated-list"
      variants={variants}
      initial={animateOnMount ? "hidden" : false}
      animate="shown"
      className={className}
      {...props}
    />
  );
}

/* ----------------------------------------------------------------------------
 * AnimatedListItem: a row. It takes its entrance from the list's stagger,
 * leaves with its exit when wrapped in AnimatePresence, and glides to its
 * new place on a reorder. Use `tr` inside a `tbody` list. Keep
 * AnimatePresence in its default mode for table rows — `popLayout` lifts
 * the leaving row out of the table.
 * ------------------------------------------------------------------------- */

export type AnimatedListItemElement = "li" | "div" | "tr";

export type AnimatedListItemProps<T extends AnimatedListItemElement = "li"> =
  Omit<HTMLMotionProps<T>, "variants"> & {
    /** The element to render; `tr` for table rows. */
    as?: T;
  };

function AnimatedListItem<T extends AnimatedListItemElement = "li">({
  as,
  layout,
  transition,
  className,
  ...props
}: AnimatedListItemProps<T>) {
  const reduced = useReducedMotion() ?? false;
  const Component = motion[as ?? "li"] as React.ElementType;

  return (
    <Component
      data-slot="animated-list-item"
      variants={reduced ? REDUCED_ITEM : listItem}
      exit="exit"
      layout={reduced ? false : (layout ?? "position")}
      transition={
        transition ?? (reduced ? { duration: 0 } : { layout: SPRING_LAYOUT })
      }
      className={className}
      {...props}
    />
  );
}

/* ----------------------------------------------------------------------------
 * ScrollReveal: a block that rises and clears the first time enough of it
 * scrolls into view.
 * ------------------------------------------------------------------------- */

export interface ScrollRevealProps
  extends Omit<HTMLMotionProps<"div">, "initial" | "animate"> {
  /** Distance in px the block rises from. */
  y?: number;
  /** Blur in px it clears from; kept at 10 or under. */
  blur?: number;
  /** Seconds the reveal takes. */
  duration?: number;
  /** Seconds before the reveal starts once in view. */
  delay?: number;
  /** Reveal only the first time (default), or every time it enters. */
  once?: boolean;
  /** How much of the block must be visible to reveal it. */
  amount?: "some" | "all" | number;
  /** The scroll container, for blocks inside a scroll area. Defaults to the viewport. */
  root?: React.RefObject<Element | null>;
}

function ScrollReveal({
  y = 16,
  blur = 8,
  duration = 0.6,
  delay = 0,
  once = true,
  amount = 0.3,
  root,
  transition,
  className,
  children,
  ...props
}: ScrollRevealProps) {
  const reduced = useReducedMotion() ?? false;
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { root, once, amount });
  const blurPx = Math.min(Math.max(blur, 0), 10);

  const hidden = reduced
    ? { opacity: 0 }
    : { opacity: 0, y, filter: `blur(${blurPx}px)` };
  const shown = reduced
    ? { opacity: 1 }
    : { opacity: 1, y: 0, filter: "blur(0px)" };

  return (
    <motion.div
      data-slot="scroll-reveal"
      {...props}
      ref={ref}
      initial={hidden}
      animate={inView ? shown : hidden}
      transition={
        transition ??
        (reduced ? { duration: 0 } : { duration, delay, ease: EASE_OUT })
      }
      className={cn(className)}
    >
      {children}
    </motion.div>
  );
}

export { AnimatedList, AnimatedListItem, ScrollReveal };

Ready to try it? Three commands and the app runs against a stub model.