Motiva
Мои коллекции
← каталог

Animated List

Живой список уведомлений: новое въезжает сверху, остальные сдвигаются layout-анимацией, лишнее тает. Таймер спит вне кадра и на скрытой вкладке.

motionautointermediate~33 КБreduced-motion ✓
🎛 Настройки — крути и копируй

Пропсы

Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.

ПропТипПо умолчаниюЧто это
interval?number1.8Секунд между вставками.
max?number4Сколько видно одновременно.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/animated-list.json

зависимости: motion@^12.42.2

Что нужно в проекте до установки
  1. components.json в корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.
  2. Алиас @ объявлен дважды: "paths": {"@/*": ["./src/*"]} в корневом tsconfig.json (в Vite он часто содержит только references — тогда CLI создаёт папку@) и resolve.alias в конфиге сборщика.
  3. Токены подключены один раз на приложение:import "@/styles/motiva-tokens.css" — они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему.
  4. Своя типографика не должна спорить: если у вас на обёрткеtext-align: center, он протечёт внутрь блоков.
  5. Проект без TypeScript? Всё равно нужен минимальныйtsconfig.json с pathsи "tsx": true в components.json: CLI грузит tsconfig безусловно, а с "tsx": false идёт за jsconfig.json и падает так же. Приехавший рядом.tsx можно не трогать — вам нужен.vanilla.js.

Экспорт

↓ Скачать .zip

Код

"use client";

import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import { AnimatePresence, motion } from "motion/react";

export interface AnimatedListItem {
  /** Символ на плашке (моноширинный). */
  glyph: string;
  /** Заголовок уведомления. */
  title: string;
  /** Подпись (время, источник). */
  sub: string;
  /** Тон плашки, градусы oklch. */
  hue: number;
}

export interface AnimatedListProps {
  /** Лента уведомлений; крутится по кругу. */
  items?: AnimatedListItem[];
  /** Секунд между вставками. */
  interval?: number;
  /** Сколько видно одновременно. */
  max?: number;
}

const DEMO: AnimatedListItem[] = [
  { glyph: "$", title: "Платёж получен", sub: "только что", hue: 150 },
  { glyph: "+", title: "Новый пользователь", sub: "1 мин назад", hue: 300 },
  { glyph: "@", title: "Упоминание в треде", sub: "2 мин назад", hue: 230 },
  { glyph: "#", title: "Отчёт за неделю готов", sub: "5 мин назад", hue: 60 },
  { glyph: "!", title: "Пик трафика: ×3", sub: "8 мин назад", hue: 20 },
];

const card: CSSProperties = {
  display: "grid",
  gridTemplateColumns: "auto 1fr",
  alignItems: "center",
  gap: 12,
  padding: "12px 14px",
  borderRadius: 14,
  background: "oklch(0.2 0.02 270)",
  border: "1px solid oklch(0.3 0.02 270)",
};

function Card({ item }: { item: AnimatedListItem }) {
  return (
    <>
      <span
        style={{
          display: "grid",
          placeItems: "center",
          width: 38,
          height: 38,
          borderRadius: 10,
          fontFamily: "ui-monospace, monospace",
          fontSize: "1rem",
          background: `oklch(0.32 0.07 ${item.hue})`,
          color: `oklch(0.88 0.06 ${item.hue})`,
        }}
      >
        {item.glyph}
      </span>
      <span style={{ minWidth: 0 }}>
        <span
          style={{
            display: "block",
            fontSize: "0.92rem",
            fontWeight: 600,
            color: "oklch(0.92 0.01 265)",
            whiteSpace: "nowrap",
            overflow: "hidden",
            textOverflow: "ellipsis",
          }}
        >
          {item.title}
        </span>
        <span style={{ display: "block", fontSize: "0.75rem", color: "oklch(0.7 0.02 265)" }}>{item.sub}</span>
      </span>
    </>
  );
}

/**
 * Живой список уведомлений: новое въезжает сверху, остальные сдвигаются layout-анимацией,
 * лишнее тает снизу. Таймер вставки останавливается, когда список не виден (IntersectionObserver)
 * и на скрытой вкладке. Reduced-motion / data-shot — статичный список без таймера.
 */
export function AnimatedList({ items = DEMO, interval = 1.8, max = 4 }: AnimatedListProps = {}) {
  const hostRef = useRef<HTMLDivElement>(null);
  const [count, setCount] = useState(max);
  const [frozen, setFrozen] = useState(false);

  useEffect(() => {
    const host = hostRef.current;
    if (!host) return;
    if (
      document.documentElement.hasAttribute("data-shot") ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches
    ) {
      setFrozen(true);
      return;
    }
    let timer = 0;
    let inView = true;
    const start = () => {
      if (!timer && inView && !document.hidden) timer = window.setInterval(() => setCount((c) => c + 1), interval * 1000);
    };
    const stop = () => {
      if (timer) clearInterval(timer);
      timer = 0;
    };
    const io = new IntersectionObserver(([e]) => {
      inView = !!e?.isIntersecting;
      inView ? start() : stop();
    });
    io.observe(host);
    const onVis = () => (document.hidden ? stop() : start());
    document.addEventListener("visibilitychange", onVis);
    start();
    return () => {
      stop();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, [interval]);

  // видимые карточки — последние max из бесконечного счётчика; ключ — сам счётчик
  const list = items.length ? items : DEMO;
  const visible = Array.from({ length: Math.min(max, count) }, (_, k) => {
    const idx = count - k; // сверху самый свежий
    return { key: idx, item: list[(idx - 1) % list.length] as AnimatedListItem };
  });

  return (
    <div ref={hostRef} style={{ display: "grid", gap: 10, width: "100%" }}>
      {frozen
        ? visible.map(({ key, item }) => (
            <div key={key} style={card}>
              <Card item={item} />
            </div>
          ))
        : (
            <AnimatePresence initial={false} mode="popLayout">
              {visible.map(({ key, item }) => (
                <motion.div
                  key={key}
                  layout
                  initial={{ opacity: 0, y: -22, scale: 0.94 }}
                  animate={{ opacity: 1, y: 0, scale: 1 }}
                  exit={{ opacity: 0, y: 18, scale: 0.96 }}
                  transition={{ type: "spring", stiffness: 380, damping: 32 }}
                  style={card}
                >
                  <Card item={item} />
                </motion.div>
              ))}
            </AnimatePresence>
          )}
    </div>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Magic UI Animated List (MIT)v1.0.0