Ведь если звёзды зажигают, значит это кому-нибудь нужно?
Сделаем в этом уроке вместе анимированную звёздочку с помощью CSS и JavaScript, а потом адаптируем код под React. Время зажигать звёзды! 💫
Подготовительные работы
Прежде чем приступать к созданию анимации нужно подготовить саму звезду. Это можно сделать разными способами, наприме:
- Добавить звезду как фон у элемента
- Добавить звезду как изображение через HTML-тег
img - Добавить звезду как векторное изображение
svg
Я остановила свой выбор на первом варианте: расположить фигуру звездочки без глаз фоном в элементе-контейнере и затем добавить глаза и зрачки с помошью CSS.
Для этого я обрисовала с исходника в Figma векторную звезду и экспортировала её в формате .svg. За основу я взяла иллюстрацию, найденную на просторах Pinterest*.
* Если вы являетесь правообладателем этой прекрасной звёздочки, пожалуйста, не ругайтесь, что я использовала её без указания авторских прав. Свяжитесь со мной и я укажу ссылку на вас.
HTML-разметка и CSS-стили на данном этапе будут совсем простыми. Нам нужен будет контейнер-обёртка с классом .container, в котором будут лежать непосредственно глаза - два дива с одинаковым классом .eye. В дальнейшем эта HTML-разметка останется практически без изменений. В CSS на данном шаге мы пропишем только стили для контейнера-обёртки - добавим на фон звезду в формате .svg и отцентрируем внутреннее содержимое, это пригодится нам позже. Здесь 350px и 323px не магические числа, взятые из потолка, а размеры .svg файла звезды, который у меня получился в Figma.
1<div class='container'>
2 <div class='eye'></div>
3 <div class='eye'></div>
4</div>1.container {
2 width: 350px;
3 height: 323px;
4 margin: 0 auto;
5 display: flex;
6 align-items: center;
7 justify-content: center;
8 background-repeat: no-repeat;
9 background-position: center;
10 background-size: 350px 323px;
11 background-image: url('./../star.svg');
12}На этом этапе звёздочка без глаз выглядит пока что совсем грустно. Поэтому на следующем этапе добавим глаза с помощью чистого CSS.
Сам глаз застилизуем с помощью класса .eye, а зрачок добавим псевдоэлементом :before. HTML-разметка остаётся без изменений т.к. элементы с классом .eye мы добавили на прошлом шаге.
1<div class='container'>
2 <div class='eye'></div>
3 <div class='eye'></div>
4</div>1.eye {
2 position: relative;
3 width: 40px;
4 height: 40px;
5 background: white;
6 border-radius: 50%;
7 overflow: hidden;
8 margin: -50px 10px 0;
9}
10
11.eye:before {
12 content: '';
13 position: absolute;
14 top: 50%;
15 left: 10px;
16 transform: translate(-50%, -50%);
17 width: 20px;
18 height: 20px;
19 border-radius: 50%;
20 background: black;
21 box-sizing: border-box;
22}Теперь звёздочка выглядит отлично!
Все размеры в коде выше я подбирала индивидуально. Не стесняйтесь экспериментировать на этом этапе: можно менять размер глаз, зрачков, расстояние между ними и т.д. Саму звёздочку можно поменять на другой рисунок - сердце, облако, яблоко, etc.
А теперь анимируем!
Сперва реализуем анимацию на чистом JavaScript, а затем я подскажу как перенести её на React.
По задумке глаза должны следить за курсором, поэтому нам нужно подписаться на движение мышкой (курсором). Делается это просто с помощью метода подписки на событие addEventListener:
1document.addEventListener('mousemove', moveEyesToCursor);В этом коде mousemove - событие, которое срабатывает при каждом движении мыши, moveEyesToCursor - функция-обработчик, которая будет вызываться каждый раз при движении мыши.
Напишем теперь эту самую функцию-обработчик moveEyesToCursor, которая и будет делать всю магию движения глаз. Для начала нужно найти все глаза, которые мы хотим анимировать. Сделаем это так:
1function moveEyesToCursor() {
2 const eyes = document.querySelectorAll('.eye');
3}Метод querySelectorAll в JavaScript используется для поиска и возврата списка всех элементов документа, соответствующих заданному CSS-селектору. В нашем случае это класс .eye. Таким образом в переменной eyes будет храниться список объектов NodeList с нашими глазами-дивами и с ними можно будет что-то сделать.
А сделаем мы вот что - пройдёмся по всем элементам полученного списка с помощью метода перебора массивов forEach:
1function moveEyesToCursor() {
2 const eyes = document.querySelectorAll('.eye');
3
4 eyes.forEach((eye) => {
5 // Здесь сделаем магию
6 });
7}Время освежить курс математики. Для каждого глаза вычисляем его центр x, y:
1function moveEyesToCursor() {
2 const eyes = document.querySelectorAll('.eye');
3
4 eyes.forEach((eye) => {
5 let x = (eye.getBoundingClientRect().left) + (eye.clientWidth / 2);
6 let y = (eye.getBoundingClientRect().top) + (eye.clientHeight / 2);
7 });
8}Теперь вычислим угол между глазом и курсором:
1function moveEyesToCursor() {
2 const eyes = document.querySelectorAll('.eye');
3
4 eyes.forEach((eye) => {
5 let x = (eye.getBoundingClientRect().left) + (eye.clientWidth / 2);
6 let y = (eye.getBoundingClientRect().top) + (eye.clientHeight / 2);
7
8 let radian = Math.atan2(event.pageX - x, event.pageY - y);
9 });
10}Преобразуем и корректируем угол:
1function moveEyesToCursor() {
2 const eyes = document.querySelectorAll('.eye');
3
4 eyes.forEach((eye) => {
5 let x = (eye.getBoundingClientRect().left) + (eye.clientWidth / 2);
6 let y = (eye.getBoundingClientRect().top) + (eye.clientHeight / 2);
7
8 let radian = Math.atan2(event.pageX - x, event.pageY - y);
9 let rotation = (radian * (180 / Math.PI) * -1) + 270;
10 });
11}Всё, с математикой закончили. Осталось с помощью CSS заставить глаза двигаться. Сделаем это с помощью CSS свойства transform: rotate(). Глаз будет поворачиваться так, чтобы смотреть на курсор:
1function moveEyesToCursor() {
2 const eyes = document.querySelectorAll('.eye');
3
4 eyes.forEach((eye) => {
5 let x = (eye.getBoundingClientRect().left) + (eye.clientWidth / 2);
6 let y = (eye.getBoundingClientRect().top) + (eye.clientHeight / 2);
7
8 let radian = Math.atan2(event.pageX - x, event.pageY - y);
9 let rotation = (radian * (180 / Math.PI) * -1) + 270;
10
11 eye.style.transform = `rotate(${rotation}deg)`;
12 });
13}Собираем код вместе:
1document.addEventListener('mousemove', moveEyesToCursor);
2
3function moveEyesToCursor() {
4 const eyes = document.querySelectorAll('.eye');
5
6 eyes.forEach((eye) => {
7 let x = (eye.getBoundingClientRect().left) + (eye.clientWidth / 2);
8 let y = (eye.getBoundingClientRect().top) + (eye.clientHeight / 2);
9
10 let radian = Math.atan2(event.pageX - x, event.pageY - y);
11 let rotation = (radian * (180 / Math.PI) * -1) + 270;
12
13 eye.style.transform = `rotate(${rotation}deg)`;
14 });
15}1<div class='container'>
2 <div class='eye'></div>
3 <div class='eye'></div>
4</div>1.container {
2 height: 323px;
3 width: 350px;
4 margin: 0 auto;
5 display: flex;
6 align-items: center;
7 justify-content: center;
8 background-repeat: no-repeat;
9 background-position: center;
10 background-size: 350px 323px;
11 background-image: url('./../star.svg');
12}
13
14.eye {
15 position: relative;
16 width: 40px;
17 height: 40px;
18 background: white;
19 border-radius: 50%;
20 overflow: hidden;
21 margin: -50px 10px 0;
22}
23
24.eye:before {
25 content: '';
26 position: absolute;
27 top: 50%;
28 left: 10px;
29 transform: translate(-50%, -50%);
30 width: 20px;
31 height: 20px;
32 border-radius: 50%;
33 background: black;
34 box-sizing: border-box;
35}Вы можете перейти по ссылке в песочницу на сайте Codepen, чтобы посмотреть и потыкать результат вживую 🔍
Переносим на React
Чтобы перенести наш код на React нужно чуть-чуть модернизировать HTML-разметку, сама разметка при этом практически не изменится. Нужно будет только поддержать тот вид стилизации, который вы используете у себя в проекте или предпочитаете сами. В моём случае это CSS Modules в связке с SASS. Поэтому моя HTML-разметка после переноса на JSX будет выглядеть следующим образом:
1import styles from './app.module.scss';
2...
3return (
4 <div className={styles.eyes_container}>
5 <div className={styles.eye}></div>
6 <div className={styles.eye}></div>
7 </div>
8);1.eyes_container {
2 height: 323px;
3 width: 350px;
4 margin: 0 auto;
5 display: flex;
6 align-items: center;
7 justify-content: center;
8 background-repeat: no-repeat;
9 background-position: center;
10 background-size: 350px 323px;
11 background-image: url('./../star.svg');
12}
13
14.eye {
15 position: relative;
16 width: 40px;
17 height: 40px;
18 background: white;
19 border-radius: 50%;
20 overflow: hidden;
21 margin: -50px 10px 0;
22
23 &:before {
24 content: '';
25 position: absolute;
26 top: 50%;
27 left: 10px;
28 transform: translate(-50%, -50%);
29 width: 20px;
30 height: 20px;
31 border-radius: 50%;
32 background: black;
33 box-sizing: border-box;
34 }
35}Псевдоэлемент :before я перенесла внутрь стиля .eye потому что SASS поддерживает такой синтаксис. Это все изменения, которые произошли в CSS-стилях.
Чтобы перенести анимацию на React нам понадобится какое-то хранилище, где мы будем хранить ссылки на DOM-элементы глаз. Делать это будем с помощью ссылок ref и хука useRef. Сперва проинициализируем ссылку:
1const eyesRef = useRef<(HTMLDivElement | null)[]>([]);Не пугайтесь, я использую React в связке с TypeScript. Если вы не знакомы с TypeScript, то небольшое пояснение: (HTMLDivElement | null)[] в коде выше означает лишь типизацию элементов, которые будут храниться в нашей созданной только что ссылке, т.е. это будет массив элементов, где элемент может быть либо null либо специального типа DOM-дерева HTMLDivElement.
Чуть попозже назначим ссылки на элементы, но пока что займёмся другим - напишем функцию добавления ссылки в созданный массив eyesRef:
1const addToRefs = (el: HTMLDivElement | null, index: number) => {
2 if (el && !eyesRef.current.includes(el)) {
3 eyesRef.current[index] = el;
4 }
5};Вот теперь можно назначать ссылки. Созданная только что выше функция addToRefs будет вызываться для каждого глаза через свойство ref следующим образом:
1return (
2 <div className={styles.eyes_container}>
3 <div ref={(el) => addToRefs(el, 0)} className={styles.eye}></div>
4 <div ref={(el) => addToRefs(el, 1)} className={styles.eye}></div>
5 </div>
6;)Функция-обработчик движения глаз moveEyesToCursor при переносе на React остаётся почти без изменений, но нужно поддержать добавленные ссылки, добавив проверку current:
1const moveEyesToCursor = (event: MouseEvent) => {
2 eyesRef.current.forEach((eye) => {
3 if (!eye) {
4 return;
5 }
6
7 const rect = eye.getBoundingClientRect();
8 const x = rect.left + eye.clientWidth / 2;
9 const y = rect.top + eye.clientHeight / 2;
10
11 const radian = Math.atan2(event.pageX - x, event.pageY - y);
12 const rotation = radian * (180 / Math.PI) * -1 + 270;
13
14 eye.style.transform = `rotate(${rotation}deg)`;
15 });
16};Чтобы использовать подписку на событие mousemove как в нативном JavaScript выше в React мы воспользуемся хуком useEffect:
1useEffect(() => {
2 document.addEventListener('mousemove', moveEyesToCursor);
3
4 return () => {
5 document.removeEventListener('mousemove', moveEyesToCursor);
6 };
7}, []);Return-функция здесь выполняет роль очистки: удаляет обработчик при размонтировании компонента, предотвращая таким образом утечки памяти.
Ну вот и всё, собираем всё вместе:
1import React, { useEffect, useRef } from 'react';
2import styles from './styles.module.scss';
3
4export const AnimatedStar = () => {
5 const eyesRef = useRef<(HTMLDivElement | null)[]>([]);
6
7 const moveEyesToCursor = (event: MouseEvent) => {
8 eyesRef.current.forEach((eye) => {
9 if (!eye) {
10 return;
11 }
12
13 const rect = eye.getBoundingClientRect();
14 const x = rect.left + eye.clientWidth / 2;
15 const y = rect.top + eye.clientHeight / 2;
16
17 const radian = Math.atan2(event.pageX - x, event.pageY - y);
18 const rotation = radian * (180 / Math.PI) * -1 + 270;
19
20 eye.style.transform = `rotate(${rotation}deg)`;
21 });
22 };
23
24 useEffect(() => {
25 document.addEventListener('mousemove', moveEyesToCursor);
26
27 return () => {
28 document.removeEventListener('mousemove', moveEyesToCursor);
29 };
30 }, []);
31
32 const addToRefs = (el: HTMLDivElement | null, index: number) => {
33 if (el && !eyesRef.current.includes(el)) {
34 eyesRef.current[index] = el;
35 }
36 };
37
38 return (
39 <div className={styles.eyes_container}>
40 <div ref={(el) => addToRefs(el, 0)} className={styles.eye}></div>
41 <div ref={(el) => addToRefs(el, 1)} className={styles.eye}></div>
42 </div>
43 );
44};1.eyes_container {
2 height: 323px;
3 width: 350px;
4 margin: 0 auto;
5 display: flex;
6 align-items: center;
7 justify-content: center;
8 background-repeat: no-repeat;
9 background-position: center;
10 background-size: 350px 323px;
11 background-image: url('./../../../../../public/assets/blog/animated-star-tutorial/star.svg');
12}
13
14.eye {
15 position: relative;
16 width: 40px;
17 height: 40px;
18 background: white;
19 border-radius: 50%;
20 overflow: hidden;
21 margin: -50px 10px 0;
22
23 &:before {
24 content: '';
25 position: absolute;
26 top: 50%;
27 left: 10px;
28 transform: translate(-50%, -50%);
29 width: 20px;
30 height: 20px;
31 border-radius: 50%;
32 background: black;
33 box-sizing: border-box;
34 }
35}