Вместе с Даниилом Сарабьевым (разработчик We Wizards) сделаем сервис, позволяющий получить случайный набор закрытых карт таро с возможностью вскрыть выбранные карты.

Разделим наш сервис на два класса:
общий контроллер (назовем его TarotController);
класс единичной карты (TarotCard).
Контроллер будет отвечать за показ и скрытие карт, так же в нем будет крутиться рекурсивная функция, вызываемая с помощью метода requestAnimationFrame. В каждом цикле анимации она будет вызывать метод update у каждого инстанса класса TarotCard(об этом чуть позже).
Создадим базовую разметку, где пока просто подключим индексный файл скрипта с атрибутом type=”module” для поддержки импортов.
Добавим обертку с классом container, внутрь которой положим элемент c классом cards-container для вствки самих карт и кнопку с классом reset для сброса состояния приложения и получения новых карт.
Заранее сделаем темплейт, который будет использоваться внутри класса TarotCard:
/<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Animatied tarot</title> </head> <body> <div class="container"> <div class="cards-container"></div> <button class="reset">RESET</button> </div> <template id="card"> <div class="card"></div> </template> <script type="module" src="./js/index.js"></script> </body> </html>
Напишем “на ощупь” код контроллера, пока без отображения, так как логика довольно простая, не запутаемся.
import Card from "./TarotCard.js"; import cardsData from '../data.js'; export default class TarotController { /** * Контейнер для вставки резметки карт * @type {HTMLElement} */ container; /** * Массив с текущими картами * @type {TarotCard[]} */ cards = []; constructor() { this.container = document.querySelector('.cards-container'); this.setCards(); this.loop(); } /** * Установить случайный набор карт * в указанном количестве * * @param {number} amount - Количество карт */ async setCards(amount = 5) { this.reset().then(() => { const shuffledCards = this.getShuffledCards(); for (let i = 0; i < amount; i ++) { if (!shuffledCards[i]) { return; } this.cards.push(new TarotCard(shuffledCards[i], i, amount, this)); } }); } /** * Метод для обновления всех существующих карт */ loop() { this.cards.forEach(card => card.update()); requestAnimationFrame(this.loop.bind(this)); } /** * Спрятать существующие карты и очистить контейнер * с разметкой */ reset() { return new Promise((resolve) => { if (!this.cards.length) { resolve(true); return; } this.cards.forEach(card => card.fadeOut()); setTimeout(() => { this.cards = []; this.container.innerHTML = ''; resolve(true); }, 800); }); } /** * Получить перемешанный массив с картами * @return {Object} Перемешанный массив с картами */ getShuffledCards() { return cardsData .map(val => ({ val, sort: Math.random() })) .sort((a, b) => a.sort > b.sort ? 1 : -1) .map(({ val }) => val); } }
Стоит обратить внимание, что перед тем как задать набор карт в методе TarotController.setCards мы вызываем другой метод этого же класса, который сбрасывает текущее состояние приложения. Метод TarotController.reset возвращает промис, который резолвится сразу, если массив с картами TarotController.сards пустой, в ином случае мы прячем текущие карты, ждем окончания анимации и чистим разметку и массив TarotController.сards.
В методе TarotController.setCards после резолва промиса reset первым делом мы перемешиваем исходный массив с картами, который импортировали на 2 строке файла (import cardsData from '../data.js';). Затем с помощью обычного цикла for итерируемся указанное в параметре количество раз и создаем экземпляр класса TarotCard, куда передаем данные текущей карты (пока без разницы, что это будут за данные), текущий индекс карты, общее кол-во карт и сам инстанс нашего контроллера, чтобы у карты был к нему доступ.
Приступаем к самому интересному — пишем код класса TarotCard и реализуем все задуманные анимации а именно:
анимация появления карточки (выплывание из верхней части странички);
анимация скрытия карточки (скрытие карты за нижнюю границу страницы);
анимация по наведению на карту (перемещение по оси Z, ближе к пользователю);
анимация появления (”разворота”) карты по клику.
Все перемещения будем реализовывать с помощью базовой функции инерции:
position += (targetPosition - position) * coeff;.
Получается, что переменная position каждый цикл анимации будет приближаться к целевой позиции на расстояние, равное разнице целевой и текущей позиции умноженной на коэффициент, который будет отвечать за скорость анимации.
Пришло время создать класс TarotCard. Заочно мы уже определили какие будут параметры конструктора и некоторые методы:
import TarotController from "./TarotController.js"; export default class TarotCard { /** * @type {TarotController} */ controller; /** * * @param {*} data * @param {number} index - Индекс текущей карты * @param {number} amount - Общее кол-во карт * @param {TarotController} controller */ constructor(data, index, amount, controller) { this.controller = controller; } fadeOut() {} fadeIn() {} update() {} }
Метод fadeOut будет скрывать нашу карту в методе reset у класса TarotController. Заодно сделаем метод fadeIn для появления карты.
Метод update будет вызываться каждый цикл анимации в TarotController, тут будем расчитывать позицию и повороты карты.
Определим какие могут быть состояния у каждой карты и создадим соответствующие поля в классе TarotCard:
export default class TarotCard { /** * @type {TarotController} */ controller; isFadeIn = false; isFadeOut = false; isHovered = false; isRevealed = false; ... }
Сразу же добавим изменения состояний в наших созданных методах:
fadeOut() { this.isFadeIn = false; this.isFadeOut = true; } fadeIn() { this.isFadeIn = true; this.isFadeOut = false; }
Далее предлагаю сделать HTML-элемент, чтобы уже работать наглядно. Создаем метод TarotCard.createElement, который будем вызывать в конструкторе. В нем мы возьмем наш template #card, скопируем его и вставим в контейнер контроллера, пока без данных:
createElement() { const clone = document.querySelector('#card').content.cloneNode(true); this.element = clone.querySelector('.card'); this.controller.container.appendChild(this.element); /** * TODO: заполнить карточку данными */ }
Теперь при создании экземпляра TarotCard у нас будет создаваться элемент внутри контейнера.
Напишем базовые стили и посмотрим что у нас получается:
* { margin: 0; padding: 0; box-sizing: border-box; } .cards-container { width: 100%; height: 100vh; display: flex; justify-content: center; align-items: center; gap: 2rem; perspective: 1000px; overflow: hidden; } .card { min-width: 10rem; aspect-ratio: 9/16; background-color: red; border-radius: 4px; will-change: transform; position: relative; } .reset { z-index: 2; cursor: pointer; position: absolute; left: 50%; bottom: 2rem; transform: translateX(-50%); padding: 1rem 2rem; }
Пока ничего не произошло, нужно создать экземпляр класса TarotController. Сразу же повесим слушатель события — click-кнопку:
import TarotController from "./modules/TarotController.js"; const initTarot = () => { const tarot = new TarotController(); const btn = document.querySelector('.reset'); btn.addEventListener('click', () => { tarot.setCards(); }); }; initTarot();
Получаем такой результат:

Отлично! Теперь приступим реализации анимаций.
Первым делом определим, какие свойства будем анимировать. По задумке карта должна иметь возможность перемещаться в трех направлениях и вращаться вокруг двух осей: - X и Y. Соответственно, будем использовать translate3d для перемещения и rotateX и rotateY для поворотов.
Обозначим следующие поля:
стартовую (дефолтную) позицию как
pos = {x: 0, y: 0, z: 0}целевую позицию, которую и будем изменять
targetPos = {x: 0, y: 0, z: 0}стартовое значения вращения как
rotate = {x: 0, y: 0}целевое значение вращения, которое и будем изменять
targetRotate = {x: 0, y: 0, z: 0}
rotate = { x: 0, y: 0, }; pos = { x: 0, y: 0, z: 0, }; targetRotate = { x: 0, y: 0, }; targetPos = { x: 0, y: 0, z: 0, };
Далее расчитаем стартовую позицию, которая зависит от индекса карты и общего количества карт. По задумке карты должны вставать полукругом, а для этого нам нужны крайние значения поворота по оси Y и позиции по оси Z.
Допустим, угол будет изменяться от 30° до −30°, а позиция по оси Z от 6 до 0 и обратно.
Тут нам поможет функция линейной интерполяции, выделим ее в отдельный метод:
interpolation(value, min, max, newMin, newMax) { let newValue = ( (value-min) / (max-min) ) * (newMax-newMin) + newMin; return newValue; }
Эта функция принимает на входе число, изменяемое в интервале от min до max, и возвращает интерполированное значение в интервале от newMin до newMax.
Расчитаем значение на основе индекса карты и общего кол-ва карт, которое изменяется от -1 до 1.
Интерполировать будем index карты между значениями 0 и кол-во карт - 1 (это будет index последнего элемента). Полученное значение обозначим как basicCoeff :
const basicCoeff = interpolation(index, 0, amount -1, -1, 1);
Соответственно, если нам нужно изменить угол от 30° до -30°, то полученное значение (от -1 до 1) умножим на -30, а позицию просто умножим на 6, получив тем самым изменение от -8 до 8 и просто возьмем модуль этого значения.
Можно попробовать расположить карты полукругом, в этом нам поможет функция косинуса:

Нам нужен отрезок от -PI/2 до PI/2. И так как у нас уже есть коеффициент изменения (от −1 до 1), то косинус будем искать от Math.PI * 0.5 * basicCoeff. Но пока-что значение нам не подходит, так как косинус от 0 равен 1. Вычтем из результата едницу и инвертируем значение, чтобы получить значение, которое изменяется от 1 до 0 и обратно к 1.
const cosCoeff = -(Math.cos(basicCoeff * Math.PI * 0.5) - 1).toFixed(2);
Реализуем метод, назовем его TarotCard.calculateProps и сразу учтем возможное начальное состояние TarotCard.isFadeIn (карты будут появляться сверху, так что просто изменим начальную позицию Y):
/** * * @param {number} index * @param {number} amount */ calculateProps(index, amount) { const basicCoeff = this.interpolation(index, 0, amount - 1, -1, 1); const cosCoeff = -(Math.cos(basicCoeff * Math.PI * 0.5) - 1).toFixed(2); // Calculate rotation on Y-axis this.rotate.y = -(basicCoeff * 30).toFixed(2); // Calculate position on Z-axis this.pos.z = cosCoeff * 8; if (this.isFadeIn) { this.pos.y = -50; this.targetPos.y = -50; } }
Вызовем этот метод в конструкторе и посмотрим что у нас получилось:
/** * * @param {*} data * @param {number} index * @param {number} amount * @param {TarotController} controller */ constructor(data, index, amount, controller) { this.controller = controller; this.fadeIn(); this.calculateProps(index, amount); this.createElement(); console.log('position', this.pos); console.log('rotation', this.rotate); }
Сразу обозначим начальное состояние TarotCard.fadeIn, и создадим элемент уже реализованным методом. В консоли получим следующие сообщения:

Это означает, что у нас все сработало!
ополним метод TarotCard.fadeIn непосредственно самим появлением, а именно, после небольшой задержки просто изменим изначальное положение координаты Y на 0:
fadeIn() { this.isFadeIn = true; this.isFadeOut = false; setTimeout(() => { this.isFadeIn = false; this.pos.y = 0; }, 50); }
Теперь нужно плавно изменять поля targetPos и targetRotate в зависимости от состояния. При дефолтном состоянии эти поля должны всегда стремиться в сторону дефолтных значений.
Реализуем метод, который рассчитывает координаты в зависимости от текущего состояния карты. По каждому этапу оставлю комментарии в коде:
calculateTargetPosition() { // Скопируем дефолтные значения. Будем использовать как основные const output = { ...this.pos }; // В состоянии появления сдвинем карту наверх и немного назад, // чтобы все карты появлялись из одной плоскости if (this.isFadeIn) { output.y = -50; output.z = 0; } // В состоянии скрытия спрячем карту вниз и так же отодвинем // в одну плоскость if (this.isFadeOut) { output.y = 50; output.z = 0; } // Если навели курсор на карту, то подвинем ее немного ближе к нам if (this.isHovered) { output.z = 10; } // Вернем новые координаты return output; }
По аналогии сделаем метод, расчитывающий вращение карты:
calculateTargetRotate() { // Скопируем дефолтные значения. Будем использовать как основные const output = { ...this.rotate }; // Если навели курсор на карту и она еще скрыта, // то повернем ее перпендикулярно экрану if (this.isHovered && !this.isRevealed) { output.y = 0; } // Если навели курсор на карту и она раскрыта, // то повернем ее обратно перпендикулярно экрану if (this.isRevealed && this.isHovered) { output.y = 180; } // Если карта просто раскрыта, то развернем ее на 180 градусов if (this.isRevealed && !this.isHovered) { output.y = this.rotate.y + 180; } // Добавим небольшой наклон по оси X и сбросим поворот по оси Y // когда карта появляется или исчезает if (this.isFadeIn || this.isFadeOut) { output.y = 0; output.x = this.isFadeIn ? 15 : -15; } // Вернем новые значения return output; }
Теперь наконец-то реализуем изменение targetRotate и targetPos в сторону новых рассчитанных координат. Концепцию этой анимации я уже описал выше, примерно так это должно выглядеть:
const rotate = this.calculateTargetRotate(); // 0.1 - коеффициент изменения, проще говоря - скорость анимации this.targetRotate.y += (rotate.y - this.targetRotate.y) * 0.1;
Обобщим изменение полей, будем изменять значения в цикле по ключам объектов и немного нормализуем их, оставив по 2 цифры после запятой:
update() { const rotate = this.calculateTargetRotate(); const pos = this.calculateTargetPosition(); Object.keys(pos).forEach(key => { this.targetPos[key] += (pos[key] - this.targetPos[key]) * 0.1; this.targetPos[key] = +(this.targetPos[key].toFixed(2)); }); Object.keys(rotate).forEach(key => { this.targetRotate[key] += (rotate[key] - this.targetRotate[key]) * 0.1; this.targetRotate[key] = +(this.targetRotate[key].toFixed(2)); }); }
Еще немного и будем смотреть анимации, а пока создадим изменения состояний isHovered и isRevealed. Повесим слушатели событий на созданный элемент в отдельном методе и вызовем его после создания самого элемента:
addListeners() { this.element.addEventListener('mousemove', () => { this.isHovered = true; }); this.element.addEventListener('mouseleave', () => { this.isHovered = false; }); // Будем показывать и скрывать карту по клику на нее this.element.addEventListener('click', () => { this.isRevealed = !this.isRevealed; }); }
/** * * @param {*} data * @param {number} index * @param {number} amount * @param {TarotController} controller */ constructor(data, index, amount, controller) { this.controller = controller; this.fadeIn(); this.calculateProps(index, amount); this.createElement(); this.addListeners(); }
Все приготовления завершены, осталось связать наши координаты со стилями элемента. Для этого создадим метод TarotCard.setStyles, который будем вызывать в методе update после расчета координат:
setStyles() { if (!this.element) { return; } this.element.style.transform = `translate3d(${this.targetPos.x}rem, ${this.targetPos.y}rem, ${this.targetPos.z}rem) rotateX(${this.targetRotate.x}deg) rotateY(${this.targetRotate.y}deg)`; }
Затем просто добавляем стили нашим картам и смотрим результат:

Все работает, осталось подставить нужные данные и навести CSS-мишуры.
До сих пор мы использовали в качестве данных массив с пустыми объектами. Теперь заполним его актуальными данными. В этом массиве будут лежать объекты с полями title и img, в первом будем хранить название карты, а во втором — путь до изображения карты.
Мы уже передаем эти данные в конструктор TarotCard, давайте сохраним их:
export default class TarotCard { ... title = ''; img = ''; ... /** * * @param {*} data * @param {number} index * @param {number} amount * @param {TarotController} controller */ constructor(data, index, amount, controller) { this.controller = controller; this.title = data?.title; this.img = data?.img; this.fadeIn(); this.calculateProps(index, amount); this.createElement(); this.addListeners(); } ... }
Теперь можно добавить соответствующие картинки. Добавим в разметку шаблона элементы для рубашки карты и для изображения:
<template id="card"> <div class="card"> <div class="card__back"></div> <div class="card__front"></div> </div> </template>
И следующие стили:
.card { min-width: 10rem; aspect-ratio: 9/16; border-radius: 4px; will-change: transform; position: relative; transform-style: preserve-3d; } .card__back, .card__front { position: absolute; left: 0; top: 0; width: 100%; height: 100%; border-radius: 4px; backface-visibility: hidden; } .card__back { background-image: url('./img/back.jpg'); background-size: cover; background-position: center; background-repeat: no-repeat; } .card__front { transform: rotateY(-180deg) translateZ(1px); background-size: cover; background-position: center; background-repeat: no-repeat; }
Скачаем изображения таро и заполним массив с данными:
export default [ { title: 'шут', img: './img/cards/1.jpg', }, { title: 'маг', img: './img/cards/2.jpg', }, { title: 'жрица', img: './img/cards/3.jpg', }, { title: 'императрица', img: './img/cards/4.jpg', }, // и тд ]
В методе, где создается элемент, реализуем подставление картинки в карту:
createElement() { const clone = document.querySelector('#card').content.cloneNode(true); this.element = clone.querySelector('.card'); this.controller.container.appendChild(this.element); const cardFront = this.element.querySelector('.card__front'); cardFront.style.backgroundImage = `url(${this.img})`; }
Смотрим что получилось:
Мне захотелось еще добавить анимацию на mousemove, чтобы карта реагировала на движение мыши небольшими поворотами. Для этого создадим новое поле в классе Card для записи координат мыши и назовем его mousePos, по дефолту сделаем значения нулевыми {x: 0, y: 0}.
В существующий обработчик события mousemove добавим следующий код:
const rect = this.element.getBoundingClientRect(); this.mousePos.x = this.interpolation(e.clientX - rect.left, 0, rect.width, -1, 1); this.mousePos.y = this.interpolation(e.clientY - rect.top, 0, rect.height, -1, 1);
Тут мы интерполируем текущую позицию мыши в диапазоне от −1 до 1 и записываем в поле mousePos.
В уже созданный метод TarotCard.calculateTargetRotate добавим изменение поворота по двум осям, если текущее состояние isHovered.
Теперь метод выглядит так:
calculateTargetRotate() { const output = { ...this.rotate }; if (this.isHovered && !this.isRevealed) { output.y = this.mousePos.x * 10; output.x = -this.mousePos.y * 10; } if (this.isRevealed && this.isHovered) { output.y = this.mousePos.x * 10 + 180; output.x = -this.mousePos.y * 10; } if (this.isRevealed && !this.isHovered) { output.y = this.rotate.y + 180; } if (this.isFadeIn || this.isFadeOut) { output.y = 0; output.x = this.isFadeIn ? 15 : -15; } return output; }
После добавления побочных стилей для красоты, проект принимает законченный вид.
Тут нет жесткой привязки к отображению, и при желании ту же анимацию можно реализовать и путем WebGL.
Эта статья — не туториал, а скорее просто иллюстрация того, как можно занять себя на пару часов решением интересной и нетривиальной задачи.
Не стесняйтесь использовать наработки, вот ссылочка на гитхаб!
