Ведь если звёзды зажигают, значит это кому-нибудь нужно?

Сделаем в этом уроке вместе анимированную звёздочку с помощью CSS и JavaScript, а потом адаптируем код под React. Время зажигать звёзды! 💫

D:\dtsiki\demo\star_final 💫

Подготовительные работы

Прежде чем приступать к созданию анимации нужно подготовить саму звезду. Это можно сделать разными способами, наприме:

  • Добавить звезду как фон у элемента
  • Добавить звезду как изображение через HTML-тег img
  • Добавить звезду как векторное изображение svg

Я остановила свой выбор на первом варианте: расположить фигуру звездочки без глаз фоном в элементе-контейнере и затем добавить глаза и зрачки с помошью CSS.

Для этого я обрисовала с исходника в Figma векторную звезду и экспортировала её в формате .svg. За основу я взяла иллюстрацию, найденную на просторах Pinterest*.

Figma
Превью приложения Figma с созданием звезды

* Если вы являетесь правообладателем этой прекрасной звёздочки, пожалуйста, не ругайтесь, что я использовала её без указания авторских прав. Свяжитесь со мной и я укажу ссылку на вас.

HTML-разметка и CSS-стили на данном этапе будут совсем простыми. Нам нужен будет контейнер-обёртка с классом .container, в котором будут лежать непосредственно глаза - два дива с одинаковым классом .eye. В дальнейшем эта HTML-разметка останется практически без изменений. В CSS на данном шаге мы пропишем только стили для контейнера-обёртки - добавим на фон звезду в формате .svg и отцентрируем внутреннее содержимое, это пригодится нам позже. Здесь 350px и 323px не магические числа, взятые из потолка, а размеры .svg файла звезды, который у меня получился в Figma.

index.html
1<div class='container'>
2  <div class='eye'></div>
3  <div class='eye'></div>
4</div>
style.css
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.

D:\dtsiki\demo\star_0 💫

Сам глаз застилизуем с помощью класса .eye, а зрачок добавим псевдоэлементом :before. HTML-разметка остаётся без изменений т.к. элементы с классом .eye мы добавили на прошлом шаге.

index.html
1<div class='container'>
2  <div class='eye'></div>
3  <div class='eye'></div>
4</div>
style.css
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}

Теперь звёздочка выглядит отлично!

D:\dtsiki\demo\star_1 💫

Все размеры в коде выше я подбирала индивидуально. Не стесняйтесь экспериментировать на этом этапе: можно менять размер глаз, зрачков, расстояние между ними и т.д. Саму звёздочку можно поменять на другой рисунок - сердце, облако, яблоко, etc.

А теперь анимируем!

Сперва реализуем анимацию на чистом JavaScript, а затем я подскажу как перенести её на React.

По задумке глаза должны следить за курсором, поэтому нам нужно подписаться на движение мышкой (курсором). Делается это просто с помощью метода подписки на событие addEventListener:

index.js
1document.addEventListener('mousemove', moveEyesToCursor);

В этом коде mousemove - событие, которое срабатывает при каждом движении мыши, moveEyesToCursor - функция-обработчик, которая будет вызываться каждый раз при движении мыши.

Напишем теперь эту самую функцию-обработчик moveEyesToCursor, которая и будет делать всю магию движения глаз. Для начала нужно найти все глаза, которые мы хотим анимировать. Сделаем это так:

index.js
1function moveEyesToCursor() {
2  const eyes = document.querySelectorAll('.eye');
3}

Метод querySelectorAll в JavaScript используется для поиска и возврата списка всех элементов документа, соответствующих заданному CSS-селектору. В нашем случае это класс .eye. Таким образом в переменной eyes будет храниться список объектов NodeList с нашими глазами-дивами и с ними можно будет что-то сделать.

А сделаем мы вот что - пройдёмся по всем элементам полученного списка с помощью метода перебора массивов forEach:

index.js
1function moveEyesToCursor() {
2  const eyes = document.querySelectorAll('.eye');
3
4  eyes.forEach((eye) => {
5    // Здесь сделаем магию
6  });
7}

Время освежить курс математики. Для каждого глаза вычисляем его центр x, y:

index.js
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}

Теперь вычислим угол между глазом и курсором:

index.js
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}

Преобразуем и корректируем угол:

index.js
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(). Глаз будет поворачиваться так, чтобы смотреть на курсор:

index.js
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}

Собираем код вместе:

index.js
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}
index.html
1<div class='container'>
2  <div class='eye'></div>
3  <div class='eye'></div>
4</div>
style.css
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 будет выглядеть следующим образом:

AnimatedStar.tsx
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);
app.module.scss
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. Сперва проинициализируем ссылку:

AnimatedStar.tsx
1const eyesRef = useRef<(HTMLDivElement | null)[]>([]);

Не пугайтесь, я использую React в связке с TypeScript. Если вы не знакомы с TypeScript, то небольшое пояснение: (HTMLDivElement | null)[] в коде выше означает лишь типизацию элементов, которые будут храниться в нашей созданной только что ссылке, т.е. это будет массив элементов, где элемент может быть либо null либо специального типа DOM-дерева HTMLDivElement.

Чуть попозже назначим ссылки на элементы, но пока что займёмся другим - напишем функцию добавления ссылки в созданный массив eyesRef:

AnimatedStar.tsx
1const addToRefs = (el: HTMLDivElement | null, index: number) => {
2  if (el && !eyesRef.current.includes(el)) {
3    eyesRef.current[index] = el;
4  }
5};

Вот теперь можно назначать ссылки. Созданная только что выше функция addToRefs будет вызываться для каждого глаза через свойство ref следующим образом:

AnimatedStar.tsx
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:

AnimatedStar.tsx
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:

AnimatedStar.tsx
1useEffect(() => {
2  document.addEventListener('mousemove', moveEyesToCursor);
3
4  return () => {
5    document.removeEventListener('mousemove', moveEyesToCursor);
6  };
7}, []);

Return-функция здесь выполняет роль очистки: удаляет обработчик при размонтировании компонента, предотвращая таким образом утечки памяти.

Ну вот и всё, собираем всё вместе:

AnimatedStar.tsx
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};
styles.scss
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}