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

Card Stack

Колода карточек: верхняя по кругу уходит под низ — сама по таймеру или свайпом. Слот каждой карточки — функция её номера (сдвиг + масштаб + прозрачность), перестановку в слот везёт spring. Полёт двухфазный: дуга вбок поверх колоды, на вершине смена слоя, нырок в задний слот — z-index дробно не анимируется, однофазно полёта либо не видно, либо карточка садится поверх.

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

Пропсы

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

ПропТипПо умолчаниюЧто это
gap?number17Сдвиг карточек в глубину, px.

Установка

npx shadcn@latest add https://motiva.pages.dev/r/card-stack.json

зависимости: motion

Что нужно в проекте до установки
  1. Файл components.json в корне

    Из него CLI узнаёт, куда класть файлы. Алиасы — в такой форме:

    "aliases": { "components": "@/components", "lib": "@/lib" }

    Нет файла — установка не начнётся вовсе.

  2. Алиас @ объявлен дважды

    В корневом tsconfig.json и в конфиге сборщика. В Vite корневой tsconfig часто содержит только references — этого мало.

    "paths": { "@/*": ["./src/*"] }        // tsconfig.json
    resolve.alias = { "@": "/src" }        // vite.config.ts

    Без paths CLI молча создаёт папку с именем @.

  3. Токены подключены один раз на приложение

    Они приезжают вместе с записью — достаточно импортировать их в точке входа:

    import "@/styles/motiva-tokens.css";

    Без них блок потеряет цвета, ритм и тему.

  4. Своя типографика не спорит с блоком

    Наследуемые свойства протекают внутрь: text-align: center на обёртке отцентрует содержимое секции. То же касается font-familyи line-height.

  5. Проект без TypeScript? всё равно нужен tsconfig

    CLI грузит tsconfig.json безусловно, а при "tsx": false идёт за jsconfig.json и падает так же. Минимум — файл с pathsи "tsx": true в components.json.

    Приехавший рядом .tsx можно не трогать — вам нужен .vanilla.js.

Экспорт

↓ Скачать .zip

Код

"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>
  );
}
лицензия: MITавтор: Motivaисточник: оригинал (Motiva); идея-референс — Aceternity Card Stack (код не заимствован: публичного репозитория нет, Terms запрещают)v1.0.0