← каталог
Animated List
Живой список уведомлений: новое въезжает сверху, остальные сдвигаются layout-анимацией, лишнее тает. Таймер спит вне кадра и на скрытой вкладке.
motionautointermediate~33 КБreduced-motion ✓
🎛 Настройки — крути и копируй
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| interval? | number | 1.8 | Секунд между вставками. |
| max? | number | 4 | Сколько видно одновременно. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/animated-list.jsonзависимости: motion@^12.42.2
Что нужно в проекте до установки
components.jsonв корне — с алиасами в форме"components": "@/components". Нет файла — CLI не поймёт, куда класть.- Алиас
@объявлен дважды:"paths": {"@/*": ["./src/*"]}в корневомtsconfig.json(в Vite он часто содержит толькоreferences— тогда CLI создаёт папку@) иresolve.aliasв конфиге сборщика. - Токены подключены один раз на приложение:
import "@/styles/motiva-tokens.css"— они приезжают вместе с записью. Без них секция потеряет цвета, ритм и тему. - Своя типографика не должна спорить: если у вас на обёртке
text-align: center, он протечёт внутрь блоков. - Проект без TypeScript? Всё равно нужен минимальный
tsconfig.jsonсpathsи"tsx": trueвcomponents.json: CLI грузит tsconfig безусловно, а с"tsx": falseидёт заjsconfig.jsonи падает так же. Приехавший рядом.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"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>
);
}
/**
* Animated List — vanilla-версия (без React и motion): WAAPI + FLIP.
*
* Живой список уведомлений: новое въезжает сверху, остальные сдвигаются, лишнее тает.
* Сдвиг сделан FLIP'ом: позиции меряются ДО вставки, после — анимируется разность.
* Таймер спит вне кадра и на скрытой вкладке. Reduced-motion / data-shot — статичный список.
*
* HTML:
* <div id="feed" style="width:340px;"></div>
* <script type="module">
* import { initAnimatedList } from "./animated-list.vanilla.js";
* const destroy = initAnimatedList(document.querySelector("#feed"), { interval: 1.8, max: 4 });
* </script>
*
* @param {HTMLElement} element - контейнер; карточки создаются внутри
* @param {{ items?: {glyph:string,title:string,sub:string,hue:number}[], interval?: number, max?: number }} [options]
* @returns {() => void} destroy
*/
export function initAnimatedList(element, options = {}) {
const DEMO = [
{ 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 { items = DEMO, interval = 1.8, max = 4 } = options;
const list = items.length ? items : DEMO;
element.style.display = "grid";
element.style.gap = "10px";
const frozen =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const makeCard = (item) => {
const el = document.createElement("div");
el.style.cssText =
"display:grid;grid-template-columns:auto 1fr;align-items:center;gap:12px;padding:12px 14px;" +
"border-radius:14px;background:oklch(0.2 0.02 270);border:1px solid oklch(0.3 0.02 270);";
const badge = document.createElement("span");
badge.style.cssText =
"display:grid;place-items:center;width:38px;height:38px;border-radius:10px;" +
`font-family:ui-monospace,monospace;font-size:1rem;background:oklch(0.32 0.07 ${item.hue});color:oklch(0.88 0.06 ${item.hue});`;
badge.textContent = item.glyph;
const text = document.createElement("span");
text.style.minWidth = "0";
const title = document.createElement("span");
title.style.cssText =
"display:block;font-size:0.92rem;font-weight:600;color:oklch(0.92 0.01 265);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
title.textContent = item.title;
const sub = document.createElement("span");
sub.style.cssText = "display:block;font-size:0.75rem;color:oklch(0.7 0.02 265);";
sub.textContent = item.sub;
text.append(title, sub);
el.append(badge, text);
return el;
};
let counter = 0;
const push = (animateIt) => {
const item = list[counter % list.length];
counter++;
// FLIP: позиции ДО перестройки
const before = new Map();
for (const child of element.children) before.set(child, child.getBoundingClientRect().top);
const card = makeCard(item);
element.prepend(card);
const excess = element.children.length > max ? element.lastElementChild : null;
if (!animateIt) {
if (excess) excess.remove();
return;
}
card.animate(
[
{ opacity: 0, transform: "translateY(-22px) scale(0.94)" },
{ opacity: 1, transform: "translateY(0) scale(1)" },
],
{ duration: 420, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
);
for (const [child, top] of before) {
if (child === excess) continue;
const dy = top - child.getBoundingClientRect().top;
if (dy) child.animate([{ transform: `translateY(${dy}px)` }, { transform: "translateY(0)" }], {
duration: 380,
easing: "cubic-bezier(0.22, 1, 0.36, 1)",
});
}
if (excess) {
// лишняя карточка тает на месте: вынимаем из потока, чтобы не мешала сдвигу
const r = excess.getBoundingClientRect();
const host = element.getBoundingClientRect();
excess.style.cssText += `position:absolute;top:${r.top - host.top}px;left:0;right:0;pointer-events:none;`;
element.style.position = "relative";
excess
.animate([{ opacity: 1 }, { opacity: 0, transform: "translateY(18px) scale(0.96)" }], {
duration: 320,
easing: "ease-out",
})
.finished.then(() => excess.remove())
.catch(() => excess.remove());
}
};
for (let i = 0; i < max; i++) push(false);
if (frozen) return () => element.replaceChildren();
let timer = 0;
let inView = true;
const start = () => {
if (!timer && inView && !document.hidden) timer = window.setInterval(() => push(true), interval * 1000);
};
const stop = () => {
if (timer) clearInterval(timer);
timer = 0;
};
const io = new IntersectionObserver(([e]) => {
inView = !!(e && e.isIntersecting);
inView ? start() : stop();
});
io.observe(element);
const onVis = () => (document.hidden ? stop() : start());
document.addEventListener("visibilitychange", onVis);
start();
return () => {
stop();
io.disconnect();
document.removeEventListener("visibilitychange", onVis);
element.replaceChildren();
};
}
Похожие эффекты
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Magic UI Animated List (MIT)v1.0.0