Форум программистов, компьютерный форум, киберфорум
mr_dramm
Войти
Регистрация
Восстановить пароль
Блоги Сообщество Поиск Заказать работу  

Интерактивные popup с автоматическим выравниванием и анимацией

Запись от mr_dramm размещена 09.02.2025 в 23:00
Показов 1486 Комментарии 0

Рекомендуется статья к прочтению:

1. Анимация в браузерах и как с ней работать Сергей Чикуёнок (VK)

Функциональные фичи:

- автоматическое вертикальное выравнивание при добавлении и удалении popup
- автоматическое удаление popup через указанные промежуток времени
- анимация появления удаления выравнивания
- остановка анимации и воспроизведение при наведении курсора на popup

Кодовые Фичи:

- использование структуры List для хранения popup, позволяет меньше вызывать querySelectorAll
- минимизируем количество reflow, разделяя получение размеров (getBoundingClientRect()) и установку transform, подробнее в статье 1 раздел "Неочевидные моменты в работе Layout/reflow"
- запуск перерисовки с requestAnimationFrame в createPopup и для оптимизации отрисовки за один кадр в verticalAlignPopups
- использование transform вместо position дает преимущества, подробнее в статье 1 раздел "Рендеринг и анимация в отдельном потоке"

Базовый пример
Демо
Код
Кликните здесь для просмотра всего текста

HTML5
1
2
    <div id="app"></div>
    <button id="btnCreatePopup">Add popup</button>
CSS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
body {
  overflow-x: hidden;
}
.popup {
  position: fixed;
  display: flex;
  max-width: 400px;
  border: solid;
  right: 0;
  bottom: 0;
  transform: translateX(100%);
  transition: transform 0.2s;
  background-color: white;
}
 
.content {
  position: relative;
  flex: 1;
  padding: 1rem;
}
 
.line-box {
  position: absolute;
  bottom: 0;
  left: 0;
  height: 5px;
  display: flex;
  width: 100%;
  overflow: hidden;
}
.line {
  background-color: red;
  flex: 1;
}
.close {
  cursor: pointer;
  user-select: none;
  position: absolute;
  top: 0;
  right: 0;
}
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
import List from './list.js';
import throttle from './throttle';
// instead of a List, you can use querySelectorAll()
const list = new List();
const duration = 5000;
const texts = [
  'Lorem, ipsum dolor sit amet consectetur, adipisicing elit.',
  'Voluptatum mollitia dicta ab dolorem iure similique fugiat sapiente ullam dignissimos maxime quo alias ea quasi magni magnam facilis aspernatur, asperiores temporibus. Provident quis totam, maiores recusandae ut expedita eligendi dolor sed, tempora, quo asperiores nobis, vitae error? Suscipit nihil nesciunt aliquam in, enim!',
];
let textIndx = 0;
 
const verticalGap = 16;
 
const verticalAlignPopups = () => {
  if (!list.tail) return;
 
  requestAnimationFrame(() => {
    // First rAF: calculate tY 1 reflow
    list.tail.value.tY = 0;
    for (
      let node = list.tail.prev,
        prevHeight = list.tail.value.popup.getBoundingClientRect().height;
      node;
      node = node.prev
    ) {
      node.value.tY = -(
        Math.abs(node.next.value.tY) +
        prevHeight +
        verticalGap
      );
      prevHeight = node.value.popup.getBoundingClientRect().height;
    }
 
    // Second rAF: for paint и composite
    requestAnimationFrame(() => {
      for (let node = list.tail; node; node = node.prev) {
        if (node.value.animation.playState == 'finished') continue;
        node.value.popup.style.transform = `translate(0, ${node.value.tY}px)`;
      }
    });
  });
};
 
window.addEventListener(
  'resize',
  throttle(() => verticalAlignPopups())
);
 
const moveRight = [
  [{ transform: 'translateX(0)' }, { transform: 'translateX(100%)' }],
  {
    id: 'moveRight',
    duration,
    easing: 'linear',
    fill: 'forwards',
  },
];
const animations = new Set();
 
const createPopup = () => {
  if (textIndx == texts.length) textIndx = 0;
  const popupContent = `
  <div class="content">
  ${texts[textIndx++]}
  </div>
  <div class="line-box">
    <div class="line"></div>
  </div>
  <button class="close">X</button>
  `;
 
  let popup = document.createElement('div');
 
  popup.classList.add('popup');
  popup.insertAdjacentHTML('afterbegin', popupContent);
 
  let finished = false;
  // p = true - play otherwise pause
  const ppAnimations = (p) => {
    if (finished) return;
    animations.forEach((a) => {
      if (a.playState == 'finished') return;
      p ? a.play() : a.pause();
    });
  };
 
  const mouseenterListener = () => ppAnimations(false),
    mouseleaveListener = () => ppAnimations(true);
  let node = list.append({ popup, tY: 0 });
 
  popup.addEventListener('mouseenter', mouseenterListener);
  popup.addEventListener('mouseleave', mouseleaveListener);
 
  let animation;
 
  const animationEndHandler = () => {
    popup.addEventListener('transitionend', cleanAndAlign);
    popup.style.transform = `translate(100%, ${node.value.tY}px)`;
    list.remove(node);
    node = null;
  };
  const clickHandler = () => {
    ppAnimations(true);
    finished = true;
    animationEndHandler();
  };
  const cleanAndAlign = () => {
    popup.removeEventListener('mouseenter', mouseenterListener);
    popup.removeEventListener('mouseleave', mouseleaveListener);
    popup.removeEventListener('transitionend', cleanAndAlign);
    popup.querySelector('.close').removeEventListener('click', clickHandler);
    animation.removeEventListener('finish', animationEndHandler);
    if (animation.playState != 'finished') animation.cancel();
    animations.delete(animation);
    animation = null;
    popup.remove();
    popup = null;
 
    verticalAlignPopups();
  };
 
  popup.querySelector('.close').addEventListener('click', clickHandler);
  document.body.appendChild(popup);
  requestAnimationFrame(() => {
    animation = popup.querySelector('.line').animate(...moveRight);
    node.value.animation = animation;
    animations.add(animation);
    animation.addEventListener('finish', animationEndHandler);
    popup.style.transform = `translateX(0)`;
    verticalAlignPopups();
  });
};
 
const btnCreatePopup = document.getElementById('btnCreatePopup');
btnCreatePopup.addEventListener('click', createPopup);


Пример с использованием template
Демо
Код
Кликните здесь для просмотра всего текста

HTML5
1
2
3
4
5
6
7
8
9
10
11
    <template id="popup-template">
      <div class="popup">
        <div class="content"></div>
        <div class="line-box">
          <div class="line"></div>
        </div>
        <button class="close">X</button>
      </div>
    </template>
    <div id="app"></div>
    <button id="btnCreatePopup">Add popup</button>
Изменения относительно базового примера только в функции createPopup, клонируем содержимое template в DOM с помощью popupTemplate.content.cloneNode(true)

было
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
  const popupContent = `
  <div class="content">
  ${texts[textIndx++]}
  </div>
  <div class="line-box">
    <div class="line"></div>
  </div>
  <button class="close">X</button>
  `;
 
  let popup = document.createElement('div');
 
  popup.classList.add('popup');
  popup.insertAdjacentHTML('afterbegin', popupContent);
стало
JavaScript
1
2
3
4
  const popupTemplate = document.getElementById('popup-template');
  ...
  let popup = popupTemplate.content.cloneNode(true).querySelector('.popup');
  popup.querySelector('.content').innerHTML = texts[textIndx++];


Пример с использованием web components
Демо
Код
Кликните здесь для просмотра всего текста

Shadow DOM инкапсулирует свою разметку и стили, поэтому стили, определённые в <head>, не применяются к элементам внутри теневого дерева. Один из вариантов устанавливать стили для Web components это добавить их в template и при создании компонента записать содержимое template в Shadow DOM.
HTML5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<!DOCTYPE html>
    <template id="popup-template">
      <style>
        .popup {
          position: fixed;
          display: flex;
          max-width: 400px;
          border: solid;
          right: 0;
          bottom: 0;
          transform: translateX(100%);
          transition: transform 0.2s;
          background-color: white;
        }
 
        .content {
          position: relative;
          flex: 1;
          padding: 1rem;
        }
 
        .line-box {
          position: absolute;
          bottom: 0;
          left: 0;
          height: 5px;
          display: flex;
          width: 100%;
          overflow: hidden;
        }
        .line {
          background-color: red;
          flex: 1;
        }
        .close {
          cursor: pointer;
          user-select: none;
          position: absolute;
          top: 0;
          right: 0;
        }
      </style>
      <div class="popup">
        <div class="content"></div>
        <div class="line-box">
          <div class="line"></div>
        </div>
        <button class="close">X</button>
      </div>
    </template>
    <div id="app"></div>
    <button id="btnCreatePopup">Add popup</button>
изменения относительно базового примера
- Создаем класс, который наследуется от HTMLElement, в котором будет инициализация shadowDom и логика управления компонентом
- Регистрация компонента customElements.define
- Добавление компонента в DOM document.createElement('popup-element') и document.body.appendChild(popup)
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
class Popup extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    const fragment = popupTemplate.content.cloneNode(true);
    this.shadowRoot.appendChild(fragment);
    this.popup = this.shadowRoot.querySelector('.popup');
    this.node = list.append({ popup: this.popup, tY: 0 });
    this.onMouseEnter = () => this.ppAnimations(false);
    this.onMouseLeave = () => this.ppAnimations(true);
    this.onClick = () => {
      this.ppAnimations(true);
      this.finished = true;
      this.animationEndHandler();
    };
    this.onTransitionEnd = this.cleanAndAlign.bind(this);
    this.addEventListener('mouseenter', this.onMouseEnter);
    this.addEventListener('mouseleave', this.onMouseLeave);
    this.finished = false;
    this.shadowRoot
      .querySelector('.close')
      .addEventListener('click', this.onClick);
  }
  // p = true - play otherwise pause
  ppAnimations(p) {
    if (this.finished) return;
    animations.forEach((a) => {
      if (a.playState == 'finised') return;
      p ? a.play() : a.pause();
    });
  }
 
  cleanAndAlign() {
    this.removeEventListener('mouseenter', this.onMouseEnter);
    this.removeEventListener('mouseleave', this.onMouseLeave);
    this.popup.removeEventListener('transitionend', this.onTransitionEnd);
    this.animation.cancel();
    animations.delete(this.animation);
    this.animation = null;
    this.remove();
    verticalAlignPopups();
  }
  animationEndHandler() {
    this.popup.addEventListener('transitionend', this.onTransitionEnd);
    this.shadowRoot
      .querySelector('.close')
      .removeEventListener('click', this.onClick);
    this.popup.style.transform = `translate(100%, ${this.node.value.tY}px)`;
    list.remove(this.node);
 
    this.node = null;
  }
  show() {
    this.animation = this.shadowRoot
      .querySelector('.line')
      .animate(...moveRight);
    this.node.value.animation = this.animation;
    animations.add(this.animation);
    this.animation.addEventListener('finish', (e) => {
      if (e.target.id !== 'moveRight') return;
      this.animationEndHandler();
    });
    this.popup.style.transform = `translateX(0)`;
    verticalAlignPopups();
  }
}
customElements.define('popup-element', Popup);
 
const createPopup = () => {
  const popup = document.createElement('popup-element');
  if (textIndx == texts.length) textIndx = 0;
  popup.shadowRoot.querySelector('.content').innerHTML = texts[textIndx++];
  document.body.appendChild(popup);
  requestAnimationFrame(() => {
    popup.show();
  });
};


Служебный класс List
Кликните здесь для просмотра всего текста
JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Node {
  constructor(value) {
    this.value = value;
    this.next = this.prev = null;
  }
}
 
export default class List {
  constructor() {
    this.tail = null;
    this.head = null;
  }
  append(value) {
    const tail = new Node(value);
    if (this.tail) {
      tail.prev = this.tail;
      this.tail.next = tail;
    }
    this.tail = tail;
    return tail;
  }
  remove(node) {
    if (node === this.head) {
      this.head = node.next;
      if (this.head) {
        this.head.prev = null;
      } else {
        this.tail = null;
      }
    } else if (node === this.tail) {
      this.tail = node.prev;
      if (this.tail) {
        this.tail.next = null;
      } else {
        this.head = null;
      }
    } else {
      if (node.prev) {
        node.prev.next = node.next;
      }
      if (node.next) {
        node.next.prev = node.prev;
      }
    }
    node.value = null;
    node.next = null;
    node.prev = null;
  }
}
Размещено в ui, web animations api, html, javascript, animation
Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
Модель здравосохранения 17. Планы на выгорание
anaschu 23.05.2026
Вот конкретная схема реализации: В классе Работник добавить: накопленнаяУсталость — растёт каждый час работы, снижается в перерывы и болезни коэффициентПрезентеизма — снижает продуктивность. . .
Изменение цветов в палитре gif файла aka фавикона
russiannick 23.05.2026
Изменение цветов в палитре gif файла, юзаемого как фавиконка в составе html-файла, помещенная в base64, средствами нативного Java Script, навеянное сном в майский день. Для работы необходим браузер,. . .
Модель здравосохранения 16. Слишком хорошие и здоровые сотрудники уходят, недовольные зарплатой
anaschu 23.05.2026
Отладка увольнений и настройка производительности Сегодня во второй половине дня разобрались с механикой увольнений и настроили коэффициент сложности заданий. Вот что было сделано. . . .
Как я стал коммунистом))) Модель сохранения здоровья сотрудников, запись блога номер 15
anaschu 23.05.2026
Внезапно хорошее здоровье сотрудников не нужно капиталистам?))
Модель здравоСохранения 15. Как мы чинили AnyLogic модель рабочего коллектива: сочленение диаграммы состояний болезней и поломок в ресурспул
anaschu 23.05.2026
Как мы чинили AnyLogic модель рабочего коллектива Сегодня разобрались с пятью багами, из-за которых модель либо падала с ошибкой, либо давала совершенно бессмысленные результаты. Каждый баг был. . .
Диалоги с ИИ
zorxor 23.05.2026
Насколько я понимаю - Вы - Искусственный Интеллект. Это так? Да, всё верно. Я — искусственный интеллект. Я представляю собой большую языковую модель, созданную для помощи в самых разных задачах. . . .
Модель здравосохранения 14. Собираем всю модель вместе.
anaschu 22.05.2026
Модель собрана. В будущих постах на видео я покажу, как она работает. В этом посте запускаем её, проверяем результаты и разбираем что можно с ней делать дальше. Перед запуском проверяем. . .
Модель здравоохранения 13. Добавление самой системы здравоохранения.
anaschu 22.05.2026
В предыдущем посте мы настроили болезни. Теперь добавим события, которые управляют здоровьем всего коллектива, а также настроим рабочий график и расчёт финансов. В Main создаём четыре события. . . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2026, CyberForum.ru