Card Stack
Колода карточек: верхняя по кругу уходит под низ — сама по таймеру или свайпом. Слот каждой карточки — функция её номера (сдвиг + масштаб + прозрачность), перестановку в слот везёт spring. Полёт двухфазный: дуга вбок поверх колоды, на вершине смена слоя, нырок в задний слот — z-index дробно не анимируется, однофазно полёта либо не видно, либо карточка садится поверх.
Пропсы
Разобрано из кода записи: обязательные — без «?», у остальных показано значение по умолчанию.
| Проп | Тип | По умолчанию | Что это |
|---|---|---|---|
| gap? | number | 17 | Сдвиг карточек в глубину, px. |
Установка
npx shadcn@latest add https://motiva.pages.dev/r/card-stack.jsonзависимости: motion
Что нужно в проекте до установки
Файл
components.jsonв корнеИз него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:
"aliases": { "components": "@/components", "lib": "@/lib" }Нет файла — установка не начнётся вовсе.
Алиас
@объявлен дваждыВ корневом
tsconfig.jsonи в конфиге сборщика. В Vite корневой tsconfig часто содержит толькоreferences— этого мало."paths": { "@/*": ["./src/*"] } // tsconfig.json resolve.alias = { "@": "/src" } // vite.config.tsБез
pathsCLI молча создаёт папку с именем@.Токены подключены один раз на приложение
Они приезжают вместе с записью — достаточно импортировать их в точке входа:
import "@/styles/motiva-tokens.css";Без них блок потеряет цвета, ритм и тему.
Своя типографика не спорит с блоком
Наследуемые свойства протекают внутрь:
text-align: centerна обёртке отцентрует содержимое секции. То же касаетсяfont-familyиline-height.Проект без TypeScript? всё равно нужен tsconfig
CLI грузит
tsconfig.jsonбезусловно, а при"tsx": falseидёт заjsconfig.jsonи падает так же. Минимум — файл сpathsи"tsx": trueвcomponents.json.Приехавший рядом
.tsxможно не трогать — вам нужен.vanilla.js.
Экспорт
Код
"use client";
import { useEffect, useRef, useState } from "react";
import { motion } from "motion/react";
export interface CardStackItem {
/** Метка в углу (моноширинная). */
tag: string;
/** Заголовок карточки. */
title: string;
/** Текст. */
text: string;
/** Тон акцента, градусы oklch. */
hue: number;
}
export interface CardStackProps {
/** Колода; верхняя уходит под низ. */
items?: CardStackItem[];
/** Секунд между сменами; 0 — только вручную. */
interval?: number;
/** Сдвиг карточек в глубину, px. */
gap?: number;
}
const DEMO: CardStackItem[] = [
{ tag: "01", title: "Свайпните меня", text: "Верхняя карточка тянется за пальцем и уходит под низ колоды.", hue: 300 },
{ tag: "02", title: "Или подождите", text: "Колода крутится сама и замирает, пока курсор над ней.", hue: 230 },
{ tag: "03", title: "Полёт двухфазный", text: "Дуга вправо поверх колоды, на вершине смена слоя, нырок в слот.", hue: 150 },
{ tag: "04", title: "Хвост колоды", text: "Задние живут в тех же слотах: сдвиг и масштаб от номера.", hue: 60 },
];
/**
* Колода карточек: верхняя по кругу уходит ПОД НИЗ — сама по таймеру или свайпом.
*
* Слот каждой карточки — функция её номера в колоде (сдвиг вниз + масштаб + прозрачность),
* любая перестановка — просто новый номер: в слот карточку везёт spring. Уходящая летит
* ДВУМЯ фазами: сначала вбок-вверх ПОВЕРХ колоды, затем — уже под нижним z — нырок
* в задний слот. Однофазный полёт не работает: z-index не анимируется дробно, и карточка
* либо ныряет первым же кадром (полёта не видно), либо садится ПОВЕРХ колоды.
*
* Свайп поверх идущего полёта не глотается: полёт обрывается, карточки едут в слоты
* из тех мест, где их застали. Автопрокрутка замирает под курсором, на неактивной
* вкладке, вне зоны видимости и под reduced-motion / data-shot; свайп при reduce
* работает, но перестановка мгновенная.
*/
export function CardStack({ items = DEMO, interval = 3.2, gap = 17 }: CardStackProps = {}) {
const [order, setOrder] = useState(() => items.map((_, i) => i));
/** Кто в первой фазе полёта — ему цель «вершина дуги» и верхний z. */
const [flying, setFlying] = useState<number | null>(null);
const host = useRef<HTMLDivElement>(null);
const paused = useRef(false);
const frozen = useRef(false);
const phase = useRef<ReturnType<typeof setTimeout> | null>(null);
/** В какую сторону летит уходящая: свайп влево — дуга влево; автопрокрутка — вправо. */
const flyDir = useRef(1);
const cycle = (dir = 1) => {
flyDir.current = dir;
setOrder((o) => {
const top = o[0]!;
if (!frozen.current) {
// прошлый полёт, если он ещё идёт, обрывается прямо здесь — новым состоянием
if (phase.current) clearTimeout(phase.current);
setFlying(top);
phase.current = setTimeout(() => setFlying(null), 240);
}
return [...o.slice(1), top];
});
};
useEffect(() => {
frozen.current =
document.documentElement.hasAttribute("data-shot") ||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (frozen.current || !interval) return;
const el = host.current;
let visible = true;
const io = new IntersectionObserver(([e]) => (visible = e!.isIntersecting));
if (el) io.observe(el);
const id = setInterval(() => {
if (paused.current || document.hidden || !visible) return;
cycle();
}, Math.max(1.2, interval) * 1000);
return () => {
clearInterval(id);
io.disconnect();
if (phase.current) clearTimeout(phase.current);
};
}, [interval]);
const n = items.length;
return (
<div
ref={host}
className="cst"
onPointerEnter={() => (paused.current = true)}
onPointerLeave={() => (paused.current = false)}
style={{
position: "relative",
width: "min(340px, 100%)",
// запас под сдвиг хвоста колоды: карточки не выходят за рамку записи
height: 190 + gap * (n - 1),
}}
>
{items.map((item, id) => {
const slot = order.indexOf(id);
const isFlying = flying === id;
const isTop = slot === 0 && !isFlying;
return (
<motion.div
key={id}
drag={isTop ? "x" : false}
dragSnapToOrigin
onDragEnd={(_, info) => {
if (Math.abs(info.offset.x) > 80 || Math.abs(info.velocity.x) > 500)
// дуга уходит В СТОРОНУ свайпа: знак берём из смещения (или скорости,
// если бросили щелчком почти без пути)
cycle(Math.sign(info.offset.x || info.velocity.x) || 1);
}}
animate={
isFlying
? { x: 200 * flyDir.current, y: -22, scale: 0.98, rotate: 7 * flyDir.current, opacity: 1 }
: { x: 0, y: gap * slot, scale: 1 - slot * 0.05, rotate: 0, opacity: 1 - slot * 0.16 }
}
transition={
frozen.current
? { duration: 0 }
: isFlying
? { type: "spring", stiffness: 300, damping: 26 }
: { type: "spring", stiffness: 340, damping: 30 }
}
style={{
position: "absolute",
inset: "0 0 auto 0",
height: 190,
// первая фаза — поверх всей колоды; после смены фазы карточка уже сзади
zIndex: isFlying ? n + 1 : n - slot,
display: "grid",
gridTemplateRows: "auto 1fr",
gap: 10,
padding: "16px 18px",
borderRadius: 16,
background: "var(--ds-surface, oklch(0.2 0.02 270))",
border: "1px solid var(--ds-border, oklch(0.3 0.02 270))",
boxShadow: "0 10px 24px oklch(0 0 0 / 0.18)",
cursor: isTop ? "grab" : "default",
touchAction: "pan-y",
userSelect: "none",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span
style={{
font: "500 0.72rem/1 ui-monospace, monospace",
padding: "5px 8px",
borderRadius: 7,
background: `oklch(0.32 0.07 ${item.hue})`,
color: `oklch(0.88 0.06 ${item.hue})`,
}}
>
{item.tag}
</span>
<strong
style={{
font: "600 0.98rem/1.2 system-ui, sans-serif",
color: "var(--ds-fg, oklch(0.92 0.01 265))",
}}
>
{item.title}
</strong>
</div>
<p
style={{
margin: 0,
font: "400 0.85rem/1.55 system-ui, sans-serif",
color: "var(--ds-muted, oklch(0.7 0.02 265))",
}}
>
{item.text}
</p>
</motion.div>
);
})}
</div>
);
}