[{"data":1,"prerenderedAt":26},["ShallowReactive",2],{"journal-all":3,"journal-post-begushhaya-stroka-za-chetyre-dnya-pervaya-stabilnaya-versiya-marquee-content":23},[4,14],{"id":5,"documentId":6,"title":7,"content":8,"slug":9,"author":10,"displayDate":11,"publishedAt":12,"tags":13},6,"kei6wjtw7x6zzx0sd6481d9z","marquee-content 3.0: анимация бесконечная, слушатели — тоже","Бесконечная анимация — нормальное поведение для бегущей строки. Бесконечные обработчики и наблюдатели — нет.\n\nПри ручных проверках заметил лишнюю нагрузку. В коде нашлись три возможных источника: повторная инициализация, новые scroll-обработчики и неполная очистка. Точный сценарий воспроизведения и вклад каждого механизма не сохранились.\n\n`marquee-content@3.0.0` опубликован. Публичный API почти не изменился, зато жизненный цикл компонента пришлось пересобрать.\n\n## Разметка без изменений\n\nМежду `1.9.1` и `3.0.0` компонент по-прежнему подключался как Custom Element:\n\n```html\n\u003Cmarquee-content\n  data-mc-duration=\"20\"\n  data-mc-direction=\"auto\"\n>\n  \u003Cul>\n    \u003Cli>Primary\u003C\u002Fli>\n    \u003Cli>Secondary\u003C\u002Fli>\n    \u003Cli>Tertiary\u003C\u002Fli>\n  \u003C\u002Ful>\n\u003C\u002Fmarquee-content>\n```\n\nСохранились те же основные атрибуты:\n\n- `data-mc-duration`;\n- `data-mc-direction`;\n- `data-mc-skew`;\n- `data-mc-min`;\n- `data-mc-max`.\n\nGSAP всё так же можно было передать через `MarqueeContent.registerGSAP(gsap)`. В `1.9.1` исходный класс уже содержал этот метод, но собранный ESM ещё не экспортировал класс. Экспорт исправил в `2.0.0`.\n\nПри переходе с `2.4.1` на `3.0.0` пользователю не требовалось менять код: переделка находилась внутри компонента.\n\n## Как накапливалась лишняя работа\n\nВ версии `2.4.1` после подключения элемента `connectedCallback()` вызывал `init()`. Компонент создавал обёртки, считал клоны, применял skew и запускал GSAP-анимацию.\n\nШирину отслеживал `ResizeObserver`. При каждом обновлении размеров компонент заново рассчитывал клоны и пересоздавал Tween. Само по себе это ожидаемо: после изменения ширины старая геометрия уже не гарантирует непрерывную ленту.\n\nВ версии `2.4.1` resize проходил через `setTimeout` с задержкой `150ms`, а затем через `requestAnimationFrame`:\n\n```js\ndebounce(fn, delay) {\n  this.timer = null;\n\n  return (...args) => {\n    if (this.timer) clearTimeout(this.timer);\n    this.timer = setTimeout(() => fn(...args), delay);\n  };\n}\n```\n\nПосле таймера `update()` планировал ещё один кадр, в котором выполнялись клонирование и пересоздание анимации. Старый debounce отменял предыдущий timeout, поэтому ожидающий callback был только один. Обработка при этом оставалась привязана и к фиксированной задержке, и к циклу отрисовки браузера.\n\nДля направления `auto` каждая новая анимация добавляла собственный обработчик:\n\n```js\nwindow.addEventListener('scroll', handleScroll, {\n  capture: true,\n  passive: true,\n});\n```\n\nПри resize метод `animation()` убивал предыдущий Tween, но созданный внутри `autoDirection()` scroll-listener не удалялся. Несколько пересборок компонента могли оставить несколько обработчиков одного события.\n\n`disconnectedCallback()` убивал текущий Tween и отменял второй этап rAF, если тот уже был запланирован. Ожидающий timeout при этом не отменялся, а `ResizeObserver` продолжал наблюдение после удаления элемента.\n\n## Вместо таймера — один кадр\n\nВ версии 3.0 debounce больше не использует `setTimeout`:\n\n```js\ndebounce = () => {\n  let timer;\n\n  return () => {\n    cancelAnimationFrame(timer);\n    timer = requestAnimationFrame(this.update);\n  };\n};\n```\n\nКаждый новый вызов отменяет ранее запланированный callback. Частые сигналы объединяются в один `update` без фиксированной задержки `150ms`.\n\nСам `update()` по-прежнему планирует фактическую пересборку через `requestAnimationFrame`:\n\n```js\nupdate() {\n  cancelAnimationFrame(this.af);\n\n  if (this.firstElementChild) {\n    this.af = requestAnimationFrame(() => {\n      this.cloning();\n      this.animation();\n    });\n  }\n}\n```\n\nПолучилась двухступенчатая rAF-схема. Первый кадр объединяет сигналы `ResizeObserver`, поступившие до ближайшей отрисовки, второй выполняет работу с DOM и анимацией. Длительная серия уведомлений всё ещё может запускать обновление в нескольких кадрах.\n\n## ScrollTrigger уже знает направление\n\nСобственный `window.addEventListener('scroll', ...)` оказался лишним. ScrollTrigger и так обновляется при прокрутке и передаёт в `onUpdate` текущий экземпляр со свойством `direction`.\n\nВ версии 3.0 направление меняется внутри уже существующего ScrollTrigger:\n\n```js\nonUpdate: (self) => {\n  if (this.dataset.mcDirection === 'ltr') {\n    this.timeline.timeScale(-1);\n  } else if (this.dataset.mcDirection === 'auto') {\n    this.timeline.timeScale(self.direction);\n  }\n},\n```\n\n`self.direction` возвращает `1` при движении вперёд и `-1` при движении назад. Отдельно хранить предыдущий `scrollY`, сравнивать значения и обслуживать глобальный listener больше не нужно.\n\nЗдесь оптимизация свелась к удалению кода: ScrollTrigger уже обрабатывал событие. Параллельный механизм добавлял состояние, которое приходилось синхронизировать и очищать.\n\n## Очистка в одном месте\n\nНесмотря на имя свойства, `this.timeline` содержит результат `gsap.to()`, то есть Tween. Работа с ним переехала в отдельный метод:\n\n```js\nclearTimeline() {\n  if (this.timeline) {\n    this.timeline.kill();\n    this.timeline = null;\n  }\n\n  this.gsap.set(this.children, { clearProps: true });\n}\n```\n\nТеперь один и тот же cleanup используется перед пересозданием анимации, при выходе из media query и при отключении компонента.\n\nПри этом каждый `update()` снова вызывает `cloning()` и `animation()`. Оба метода регистрируют новый callback через `MM.add()`, но общий `MM.revert()` не вызывается. Нативные scroll-listeners больше не накапливаются, а matchMedia-контексты всё ещё могут.\n\n`disconnectedCallback()` тоже стал полнее:\n\n```js\ndisconnectedCallback() {\n  cancelAnimationFrame(this.af);\n  this.clearTimeline();\n\n  if (this.resizeObserver) {\n    this.resizeObserver.disconnect();\n  }\n}\n```\n\n`ResizeObserver.disconnect()` прекращает наблюдение за всеми связанными элементами. Для Custom Element это обычная часть lifecycle: созданные при подключении наблюдатели нужно отключать при удалении.\n\nОсталась ещё одна проблема. ID первого rAF хранится в локальной переменной внутри `debounce()`, поэтому `disconnectedCallback()` отменяет только `this.af` второго этапа. Уже запланированный первый callback всё ещё может вызвать `update()` после удаления элемента.\n\n## Клоны добавляются одним вызовом\n\nФормула количества клонов не изменилась:\n\n```js\nconst requiredQuantity = Math.ceil(\n  this.scrollWidth \u002F this.firstElementChild.clientWidth + 2,\n);\n```\n\nИзменился способ добавления. Раньше каждый клон сразу вставлялся в DOM внутри цикла. Теперь сначала создаётся массив, а затем все элементы передаются в один `append()`:\n\n```js\nconst clones = Array.from(\n  { length: requiredQuantity - 1 },\n  () => this.firstElementChild.cloneNode(true),\n);\n\nthis.append(...clones);\n```\n\nЗаметного прироста это не гарантирует: клоны всё равно нужно создать, а браузер может объединить последовательные изменения DOM. Практическая польза в другом — подготовка узлов отделена от их добавления.\n\n## Что действительно стало меньше\n\nИсходный код стал короче, но npm-тарбол немного распух из-за добавленных `src\u002Fdemo.js` и `src\u002Findex.html`.\n\nДля пользователя важнее, что из runtime исчез собственный scroll-listener, а `ResizeObserver` получил явный `disconnect()`.\n\n## Что осталось незакрытым\n\nВерсия 3.0 закрыла часть долга, но lifecycle остался неполным.\n\n`ResizeObserver` создаётся и начинает наблюдение в конструкторе. После `disconnect()` повторное подключение того же DOM-элемента не запускает `observe()` заново. `init()` повторно оборачивает сохранившуюся структуру, а старые matchMedia-контексты и клоны не получают общего `revert()`.\n\nСценарий `remove → append` всё ещё работает неправильно.\n\n## Итог версии 3.0\n\n1. Пересмотр кода выявил несколько возможных источников лишней работы: повторную инициализацию, scroll-обработчики и незавершённый `ResizeObserver`.\n2. Удаление собственного scroll-listener оказалось полезнее попытки сделать его быстрее. ScrollTrigger уже предоставлял нужное направление.\n3. `requestAnimationFrame` заменил произвольную задержку и объединил частые resize-сигналы, но сама работа с DOM никуда не исчезла.\n4. `disconnectedCallback()` должен останавливать Tween, наблюдатели, слушатели и запланированные кадры. Остановка только анимации решает не всю задачу.\n5. В версии стало меньше обработчиков и короче runtime-код, но измерений прироста производительности у меня пока нет.\n\n## Источники\n\n- [Документация GSAP ScrollTrigger](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002F)\n- [Свойство `ScrollTrigger.direction`](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002Fdirection\u002F)\n- [MDN: `ResizeObserver`](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FResizeObserver)\n- [MDN: `ResizeObserver.disconnect()`](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FResizeObserver\u002Fdisconnect)\n- [MDN: `requestAnimationFrame()`](https:\u002F\u002Fdeveloper.mozilla.org\u002Fen-US\u002Fdocs\u002FWeb\u002FAPI\u002FWindow\u002FrequestAnimationFrame)","marquee-content-3-0-animacziya-beskonechnaya-slushateli-tozhe","Maxim","2023-10-31","2026-08-26T21:36:27.007Z",[],{"id":15,"documentId":16,"title":17,"content":18,"slug":19,"author":10,"displayDate":20,"publishedAt":21,"tags":22},4,"iw9bln325br894kn79zuz125","Бегущая строка за четыре дня: первая стабильная версия marquee-content","Бегущая строка выглядит максимально простой задачей — сложить элементы в ряд, сдвигать их в сторону и начинать сначала. Примерно так я думал, начиная эксперимент с GSAP, ScrollTrigger и Custom Elements.\n\nЗа четыре дня к первоначальной анимации добавились клонирование содержимого, три направления движения, адаптивные границы, пауза за пределами экрана и отдельная логика для мобильного resize.\n\n`marquee-content@1.0.0` опубликован. Разберу, что вошло в первую стабильную версию, какие решения сработали и где маленький эксперимент с `repeat: -1` успел обзавестись взрослым техническим долгом.\n\n## Минимальная оболочка: Custom Element\n\nЯ хотел, чтобы компонент подключался без отдельной разметки из служебных обёрток. Пользователь описывает содержимое, а библиотека отвечает за движение:\n\n```html\n\u003Cmarquee-content\n  data-mc-duration=\"20\"\n  data-mc-direction=\"auto\"\n  role=\"marquee\"\n>\n  \u003Cul>\n    \u003Cli>Primary\u003C\u002Fli>\n    \u003Cli>Secondary\u003C\u002Fli>\n    \u003Cli>Tertiary\u003C\u002Fli>\n  \u003C\u002Ful>\n\u003C\u002Fmarquee-content>\n```\n\nКомпонент — автономный Custom Element:\n\n```js\nexport class MarqueeContent extends HTMLElement {\n  constructor() {\n    super();\n\n    this.mm = gsap.matchMedia();\n    this.tl = gsap.timeline();\n    \u002F\u002F Инициализация параметров и анимации\n  }\n}\n\ncustomElements.get('marquee-content')\n  || customElements.define('marquee-content', MarqueeContent);\n```\n\nПроверка через `customElements.get()` не позволяет зарегистрировать элемент заново при следующем подключении скрипта. Сам компонент остаётся в обычном DOM, без Shadow DOM: стили страницы видят содержимое и могут управлять им напрямую.\n\nУ этого решения есть шероховатость. Либа читает атрибуты и дочерние узлы прямо в конструкторе, хотя требования к Custom Elements предписывают отложить такую работу до `connectedCallback()`. Демо работает, потому что скрипт регистрирует элемент после разбора разметки, но при более раннем подключении инициализация может получить пустой элемент.\n\nИнтеграционный долг: демо загружает GSAP 3.11.5 и ScrollTrigger отдельными CDN-скриптами. Пакет уже объявляет `gsap` зависимостью, но исходный модуль всё равно ждёт глобальные `gsap` и `ScrollTrigger`. Версию можно назвать стабильной, а вот способ подключения — не вполне.\n\n## Откуда берётся бесконечность\n\nОдной копии содержимого недостаточно. Когда она уедет за границу экрана, слева или справа (в зависимости от направления движения) появится пустое место. Поэтому компонент сначала вычисляет, сколько копий нужно для заполнения контейнера:\n\n```js\nlet requiredQuantity = (\n  this.clientWidth \u002F this.firstElementChild.clientWidth + 3\n).toFixed(0);\n\nfor (let i = 1; i \u003C requiredQuantity; i++) {\n  const item = this.firstElementChild;\n  const clone = item.cloneNode(true);\n  item.parentNode.append(clone);\n}\n```\n\nВ более компактной записи:\n\n```text\nN = round(W \u002F w + 3)\n```\n\nгде `W` — ширина контейнера, `w` — ширина исходного блока, а `N` — итоговое количество блоков вместе с оригиналом.\n\nНапример, для контейнера шириной `960px` и блока шириной `320px` получается:\n\n```text\nN = round(960 \u002F 320 + 3) = 6\n```\n\nТри блока закрывают видимую ширину, ещё три дают избыточное покрытие во время циклического сдвига. Формула не ищет минимум, а сознательно создаёт лишние копии.\n\n`toFixed(0)` возвращает строку, которую цикл затем неявно преобразует обратно в число. Код работает, но эта маленькая прогулка числа через строку не даёт ничего, кроме будущего вопроса «а зачем?». Здесь достаточно `Math.round()`.\n\nПосле клонирования GSAP создаёт timeline — временную шкалу анимации — для всех дочерних элементов:\n\n```js\nthis.tl.to(this.children, {\n  duration: this.duration,\n  x: '-100%',\n  ease: 'none',\n  repeat: -1,\n});\n```\n\nКаждая копия смещается на собственную ширину. Линейная функция плавности (`ease: 'none'`) и бесконечное повторение (`repeat: -1`) создают непрерывный цикл, а одинаковые копии скрывают переход.\n\n## Направление без второй анимации\n\nДля `rtl` и `ltr` не нужны два timeline. Достаточно менять знак `timeScale`:\n\n```js\nthis.tl\n  .to(this.children, {\n    duration: this.duration,\n    x: '-100%',\n    ease: 'none',\n    repeat: -1,\n  })\n  .timeScale(this.dir === 'ltr' ? -1 : 1)\n  .totalProgress(0.5);\n```\n\nПоложительный масштаб времени проигрывает timeline вперёд, отрицательный — назад. `totalProgress(0.5)` помещает позицию воспроизведения в середину условной общей длительности бесконечно повторяющейся анимации, чтобы отрицательный `timeScale` не остановился сразу на абсолютном начале.\n\nТретье значение, `auto`, связывает направление с прокруткой страницы. Обработчик сравнивает текущий `pageYOffset` с предыдущим и плавно переводит `timeScale` в `1` или `-1`:\n\n```js\nconst orientation = window.pageYOffset > currentScroll ? 1 : -1;\n\nif (orientation !== scrollDirection) {\n  gsap.to(this.tl, {\n    timeScale: orientation,\n    overwrite: true,\n  });\n}\n```\n\nScrollTrigger решает другую задачу: ставит анимацию на паузу, когда компонент покидает область просмотра (`viewport`), и возобновляет при возвращении. Невидимая анимация продолжала бы расходовать ресурсы без пользы.\n\n## Адаптивность через `gsap.matchMedia()`\n\nКомпонент поддерживает `data-mc-min` и `data-mc-max` по отдельности. Каждый из них превращается в media query, внутри которого создаются клоны и timeline. Если указать оба, `min` перезапишет запрос для `max`, поэтому полноценного диапазона из двух границ пока не получилось.\n\nДля этого используется `gsap.matchMedia()` из GSAP 3.11. Метод запускает переданную функцию при совпадении запроса, а при выходе откатывает созданные GSAP-анимации и вызывает функцию очистки:\n\n```js\nthis.mm.add(this.breakpoint, () => {\n  \u002F\u002F Создание клонов и анимации\n\n  return () => {\n    removingClones();\n  };\n});\n```\n\nЭто оказалось удобнее отдельного набора `matchMedia().addEventListener()` и ручного согласования состояния. При выходе из запроса нужно убрать клоны и встроенные стили, иначе отключённый компонент продолжит влиять на раскладку страницы.\n\nGSAP автоматически откатывает созданные внутри callback анимации и ScrollTrigger, а возвращённая функция удаляет клоны. Нативные обработчики `scroll`, `resize` и `change` не снимаются, поэтому очистка жизненного цикла остаётся неполной.\n\n## «Мобильные» мучения\n\nПервый неприятный сюрприз пришёл от iOS. Изменение видимой области браузера во время прокрутки генерировало `resize`. Обработчик безусловно пересобирал timeline и заново считал клоны, даже когда ширина не менялась. Повторная инициализация во время прокрутки могла нарушить непрерывность бегущей строки.\n\nДальше были пробы, ошибки и тесты на реальных мобильных устройствах. Окончательная логика обработки указателей появилась позже. Некоторые ошибки очень убедительно говорят «починил», пока ещё раз не откроешь страницу на телефоне.\n\nПроверял несколько подходов:\n\n- сравнивал новую ширину окна с предыдущей;\n- пробовал отделять мобильные устройства через `userAgent`;\n- слушал изменение ориентации;\n- менял задержку debounce;\n- полностью убивал timeline перед повторным клонированием.\n\nПроверка `userAgent` вроде бы отделяла известные мобильные платформы, но не определяла причину конкретного `resize`, а увеличение debounce лишь откладывало лишнюю пересборку.\n\nВ текущей версии обработчики подключаются через два media query:\n\n```js\nthis.mm.add('(any-pointer: coarse)', () => {\n  const portrait = window.matchMedia('(orientation: portrait)');\n\n  portrait.addEventListener('change', (event) => {\n    if (!event.matches) {\n      resetAmin();\n    }\n  });\n});\n\nthis.mm.add('(any-pointer: fine)', () => {\n  window.addEventListener(\n    'resize',\n    this.debounce(resetAmin, 250),\n  );\n});\n```\n\n`(any-pointer: coarse)` включает обработку смены ориентации, а `(any-pointer: fine)` — обычный `resize` с debounce в `250 ms`. Запросы не взаимоисключающие: на гибридном устройстве могут сработать оба.\n\nВетка `coarse` тоже получилась узкой: она пересобирает компонент только при выходе из портретной ориентации. Но это устраняет конкретный сбой, не заставляя тяжёлую пересборку срабатывать вслед за изменением высоты вьюпорта из-за движения адрес-бара на touch-only устройстве.\n\n## API версии 1.0\n\nВ первый стабильный API вошли пять атрибутов:\n\n- `data-mc-duration` — длительность одного цикла, то есть сдвига на ширину блока, в секундах, по умолчанию `20`;\n- `data-mc-direction` — `rtl`, `ltr` или `auto`;\n- `data-mc-skew` — наклон по оси Y;\n- `data-mc-min` — минимальная ширина для запуска;\n- `data-mc-max` — максимальная ширина для запуска.\n\n`data-mc-min` и `data-mc-max` работают как альтернативы, а не как совместный диапазон.\n\n## Итог первой версии\n\n1. Бесконечное движение — это прежде всего управление геометрией. Timeline занимает несколько строк, правильное число копий и пересборка после изменения размеров занимают всё остальное.\n2. `resize` сообщает о событии браузера, а не о намерении пользователя. На компьютере эти вещи часто совпадают. На мобильном устройстве `resize` может также возникать при движении адрес-бара или при повороте экрана.\n3. Custom Element — не только красивый тег. Он приносит жизненный цикл, повторное подключение к DOM и обязанность корректно освобождать ресурсы. В текущем варианте эта часть ещё слишком тесно связана с конструктором.\n\nЖелание посмотреть, насколько далеко можно уехать на одном `repeat: -1`, постепенно превращается в небольшой npm-пакет.\n\n## Источники\n\n- [GSAP 3.11: `gsap.matchMedia()`](https:\u002F\u002Fgsap.com\u002Fblog\u002F3-11\u002F)\n- [Документация GSAP ScrollTrigger](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002F)\n- [Документация GSAP `totalProgress()`](https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FGSAP\u002FTimeline\u002FtotalProgress()\u002F)\n- [HTML Standard: Custom Elements](https:\u002F\u002Fhtml.spec.whatwg.org\u002Fmultipage\u002Fcustom-elements.html)\n- [Media Queries Level 4: `any-pointer`](https:\u002F\u002Fwww.w3.org\u002FTR\u002Fmediaqueries-4\u002F#any-input)\n","begushhaya-stroka-za-chetyre-dnya-pervaya-stabilnaya-versiya-marquee-content","2023-03-24","2026-08-26T21:01:08.447Z",[],{"id":15,"documentId":16,"title":17,"content":18,"slug":19,"author":10,"displayDate":20,"publishedAt":21,"tags":24,"html":25},[],"\u003Cp class=\"body-large\">Бегущая строка выглядит максимально простой задачей — сложить элементы в ряд, сдвигать их в сторону и начинать сначала. Примерно так я думал, начиная эксперимент с GSAP, ScrollTrigger и Custom Elements.\u003C\u002Fp>\n\u003Cp class=\"body-large\">За четыре дня к первоначальной анимации добавились клонирование содержимого, три направления движения, адаптивные границы, пауза за пределами экрана и отдельная логика для мобильного resize.\u003C\u002Fp>\n\u003Cp class=\"body-large\">\u003Ccode>marquee-content@1.0.0\u003C\u002Fcode> опубликован. Разберу, что вошло в первую стабильную версию, какие решения сработали и где маленький эксперимент с \u003Ccode>repeat: -1\u003C\u002Fcode> успел обзавестись взрослым техническим долгом.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Минимальная оболочка: Custom Element\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Я хотел, чтобы компонент подключался без отдельной разметки из служебных обёрток. Пользователь описывает содержимое, а библиотека отвечает за движение:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">marquee-content\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  data-mc-duration\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">=\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"20\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  data-mc-direction\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">=\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"auto\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">  role\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">=\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">\"marquee\"\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">ul\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>Primary&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>Secondary&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    &#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>Tertiary&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">li\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  &#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">ul\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">&#x3C;\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#85E89D\">marquee-content\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">>\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Компонент — автономный Custom Element:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">export\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> class\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> MarqueeContent\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> extends\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> HTMLElement\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  constructor\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">() {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    super\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.mm \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> gsap.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">matchMedia\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.tl \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> gsap.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">timeline\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">    \u002F\u002F Инициализация параметров и анимации\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  }\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">customElements.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">get\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'marquee-content'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">)\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  ||\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> customElements.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">define\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'marquee-content'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">, MarqueeContent);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Проверка через \u003Ccode>customElements.get()\u003C\u002Fcode> не позволяет зарегистрировать элемент заново при следующем подключении скрипта. Сам компонент остаётся в обычном DOM, без Shadow DOM: стили страницы видят содержимое и могут управлять им напрямую.\u003C\u002Fp>\n\u003Cp class=\"body-large\">У этого решения есть шероховатость. Либа читает атрибуты и дочерние узлы прямо в конструкторе, хотя требования к Custom Elements предписывают отложить такую работу до \u003Ccode>connectedCallback()\u003C\u002Fcode>. Демо работает, потому что скрипт регистрирует элемент после разбора разметки, но при более раннем подключении инициализация может получить пустой элемент.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Интеграционный долг: демо загружает GSAP 3.11.5 и ScrollTrigger отдельными CDN-скриптами. Пакет уже объявляет \u003Ccode>gsap\u003C\u002Fcode> зависимостью, но исходный модуль всё равно ждёт глобальные \u003Ccode>gsap\u003C\u002Fcode> и \u003Ccode>ScrollTrigger\u003C\u002Fcode>. Версию можно назвать стабильной, а вот способ подключения — не вполне.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Откуда берётся бесконечность\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Одной копии содержимого недостаточно. Когда она уедет за границу экрана, слева или справа (в зависимости от направления движения) появится пустое место. Поэтому компонент сначала вычисляет, сколько копий нужно для заполнения контейнера:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">let\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\"> requiredQuantity\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">  this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.clientWidth \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">\u002F\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.firstElementChild.clientWidth \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">+\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 3\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">).\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">toFixed\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">0\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">for\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">let\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> i \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 1\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">; i \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">&#x3C;\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> requiredQuantity; i\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">++\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> item\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.firstElementChild;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> clone\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> item.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">cloneNode\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">true\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  item.parentNode.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">append\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(clone);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">В более компактной записи:\u003C\u002Fp>\n\u003Cpre class=\"shiki\">\u003Ccode>N = round(W \u002F w + 3)\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">где \u003Ccode>W\u003C\u002Fcode> — ширина контейнера, \u003Ccode>w\u003C\u002Fcode> — ширина исходного блока, а \u003Ccode>N\u003C\u002Fcode> — итоговое количество блоков вместе с оригиналом.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Например, для контейнера шириной \u003Ccode>960px\u003C\u002Fcode> и блока шириной \u003Ccode>320px\u003C\u002Fcode> получается:\u003C\u002Fp>\n\u003Cpre class=\"shiki\">\u003Ccode>N = round(960 \u002F 320 + 3) = 6\n\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Три блока закрывают видимую ширину, ещё три дают избыточное покрытие во время циклического сдвига. Формула не ищет минимум, а сознательно создаёт лишние копии.\u003C\u002Fp>\n\u003Cp class=\"body-large\">\u003Ccode>toFixed(0)\u003C\u002Fcode> возвращает строку, которую цикл затем неявно преобразует обратно в число. Код работает, но эта маленькая прогулка числа через строку не даёт ничего, кроме будущего вопроса «а зачем?». Здесь достаточно \u003Ccode>Math.round()\u003C\u002Fcode>.\u003C\u002Fp>\n\u003Cp class=\"body-large\">После клонирования GSAP создаёт timeline — временную шкалу анимации — для всех дочерних элементов:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.tl.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">to\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.children, {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  duration: \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.duration,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  x: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'-100%'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  ease: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'none'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  repeat: \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">-\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">1\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">});\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Каждая копия смещается на собственную ширину. Линейная функция плавности (\u003Ccode>ease: 'none'\u003C\u002Fcode>) и бесконечное повторение (\u003Ccode>repeat: -1\u003C\u002Fcode>) создают непрерывный цикл, а одинаковые копии скрывают переход.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Направление без второй анимации\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Для \u003Ccode>rtl\u003C\u002Fcode> и \u003Ccode>ltr\u003C\u002Fcode> не нужны два timeline. Достаточно менять знак \u003Ccode>timeScale\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.tl\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  .\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">to\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.children, {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    duration: \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.duration,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    x: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'-100%'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    ease: \u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'none'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    repeat: \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">-\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">1\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  })\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  .\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">timeScale\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.dir \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">===\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\"> 'ltr'\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> ?\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> -\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">1\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> :\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 1\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">)\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  .\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">totalProgress\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">0.5\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Положительный масштаб времени проигрывает timeline вперёд, отрицательный — назад. \u003Ccode>totalProgress(0.5)\u003C\u002Fcode> помещает позицию воспроизведения в середину условной общей длительности бесконечно повторяющейся анимации, чтобы отрицательный \u003Ccode>timeScale\u003C\u002Fcode> не остановился сразу на абсолютном начале.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Третье значение, \u003Ccode>auto\u003C\u002Fcode>, связывает направление с прокруткой страницы. Обработчик сравнивает текущий \u003Ccode>pageYOffset\u003C\u002Fcode> с предыдущим и плавно переводит \u003Ccode>timeScale\u003C\u002Fcode> в \u003Ccode>1\u003C\u002Fcode> или \u003Ccode>-1\u003C\u002Fcode>:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> orientation\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> window.pageYOffset \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> currentScroll \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">?\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> 1\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> :\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> -\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">1\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">;\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (orientation \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">!==\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> scrollDirection) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  gsap.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">to\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.tl, {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    timeScale: orientation,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    overwrite: \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">true\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  });\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">}\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">ScrollTrigger решает другую задачу: ставит анимацию на паузу, когда компонент покидает область просмотра (\u003Ccode>viewport\u003C\u002Fcode>), и возобновляет при возвращении. Невидимая анимация продолжала бы расходовать ресурсы без пользы.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Адаптивность через \u003Ccode>gsap.matchMedia()\u003C\u002Fcode>\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Компонент поддерживает \u003Ccode>data-mc-min\u003C\u002Fcode> и \u003Ccode>data-mc-max\u003C\u002Fcode> по отдельности. Каждый из них превращается в media query, внутри которого создаются клоны и timeline. Если указать оба, \u003Ccode>min\u003C\u002Fcode> перезапишет запрос для \u003Ccode>max\u003C\u002Fcode>, поэтому полноценного диапазона из двух границ пока не получилось.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Для этого используется \u003Ccode>gsap.matchMedia()\u003C\u002Fcode> из GSAP 3.11. Метод запускает переданную функцию при совпадении запроса, а при выходе откатывает созданные GSAP-анимации и вызывает функцию очистки:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.mm.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">add\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.breakpoint, () \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#6A737D\">  \u002F\u002F Создание клонов и анимации\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  return\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> () \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">    removingClones\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  };\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">});\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">Это оказалось удобнее отдельного набора \u003Ccode>matchMedia().addEventListener()\u003C\u002Fcode> и ручного согласования состояния. При выходе из запроса нужно убрать клоны и встроенные стили, иначе отключённый компонент продолжит влиять на раскладку страницы.\u003C\u002Fp>\n\u003Cp class=\"body-large\">GSAP автоматически откатывает созданные внутри callback анимации и ScrollTrigger, а возвращённая функция удаляет клоны. Нативные обработчики \u003Ccode>scroll\u003C\u002Fcode>, \u003Ccode>resize\u003C\u002Fcode> и \u003Ccode>change\u003C\u002Fcode> не снимаются, поэтому очистка жизненного цикла остаётся неполной.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">«Мобильные» мучения\u003C\u002Fh2>\n\u003Cp class=\"body-large\">Первый неприятный сюрприз пришёл от iOS. Изменение видимой области браузера во время прокрутки генерировало \u003Ccode>resize\u003C\u002Fcode>. Обработчик безусловно пересобирал timeline и заново считал клоны, даже когда ширина не менялась. Повторная инициализация во время прокрутки могла нарушить непрерывность бегущей строки.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Дальше были пробы, ошибки и тесты на реальных мобильных устройствах. Окончательная логика обработки указателей появилась позже. Некоторые ошибки очень убедительно говорят «починил», пока ещё раз не откроешь страницу на телефоне.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Проверял несколько подходов:\u003C\u002Fp>\n\u003Cul class=\"list-large\">\n\u003Cli>сравнивал новую ширину окна с предыдущей;\u003C\u002Fli>\n\u003Cli>пробовал отделять мобильные устройства через \u003Ccode>userAgent\u003C\u002Fcode>;\u003C\u002Fli>\n\u003Cli>слушал изменение ориентации;\u003C\u002Fli>\n\u003Cli>менял задержку debounce;\u003C\u002Fli>\n\u003Cli>полностью убивал timeline перед повторным клонированием.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp class=\"body-large\">Проверка \u003Ccode>userAgent\u003C\u002Fcode> вроде бы отделяла известные мобильные платформы, но не определяла причину конкретного \u003Ccode>resize\u003C\u002Fcode>, а увеличение debounce лишь откладывало лишнюю пересборку.\u003C\u002Fp>\n\u003Cp class=\"body-large\">В текущей версии обработчики подключаются через два media query:\u003C\u002Fp>\n\u003Cpre class=\"shiki github-dark\" style=\"background-color:#24292e;color:#e1e4e8\" tabindex=\"0\">\u003Ccode>\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.mm.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">add\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'(any-pointer: coarse)'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">, () \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">  const\u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\"> portrait\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\"> =\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> window.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">matchMedia\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'(orientation: portrait)'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">);\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  portrait.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">addEventListener\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'change'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">, (\u003C\u002Fspan>\u003Cspan style=\"color:#FFAB70\">event\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">) \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#F97583\">    if\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> (\u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">!\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">event.matches) {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#B392F0\">      resetAmin\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">();\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">    }\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  });\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">});\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.mm.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">add\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003Cspan style=\"color:#9ECBFF\">'(any-pointer: fine)'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">, () \u003C\u002Fspan>\u003Cspan style=\"color:#F97583\">=>\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\"> {\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  window.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">addEventListener\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#9ECBFF\">    'resize'\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">,\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#79B8FF\">    this\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">.\u003C\u002Fspan>\u003Cspan style=\"color:#B392F0\">debounce\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">(resetAmin, \u003C\u002Fspan>\u003Cspan style=\"color:#79B8FF\">250\u003C\u002Fspan>\u003Cspan style=\"color:#E1E4E8\">),\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">  );\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003Cspan style=\"color:#E1E4E8\">});\u003C\u002Fspan>\u003C\u002Fspan>\n\u003Cspan class=\"line\">\u003C\u002Fspan>\u003C\u002Fcode>\u003C\u002Fpre>\u003Cp class=\"body-large\">\u003Ccode>(any-pointer: coarse)\u003C\u002Fcode> включает обработку смены ориентации, а \u003Ccode>(any-pointer: fine)\u003C\u002Fcode> — обычный \u003Ccode>resize\u003C\u002Fcode> с debounce в \u003Ccode>250 ms\u003C\u002Fcode>. Запросы не взаимоисключающие: на гибридном устройстве могут сработать оба.\u003C\u002Fp>\n\u003Cp class=\"body-large\">Ветка \u003Ccode>coarse\u003C\u002Fcode> тоже получилась узкой: она пересобирает компонент только при выходе из портретной ориентации. Но это устраняет конкретный сбой, не заставляя тяжёлую пересборку срабатывать вслед за изменением высоты вьюпорта из-за движения адрес-бара на touch-only устройстве.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">API версии 1.0\u003C\u002Fh2>\n\u003Cp class=\"body-large\">В первый стабильный API вошли пять атрибутов:\u003C\u002Fp>\n\u003Cul class=\"list-large\">\n\u003Cli>\u003Ccode>data-mc-duration\u003C\u002Fcode> — длительность одного цикла, то есть сдвига на ширину блока, в секундах, по умолчанию \u003Ccode>20\u003C\u002Fcode>;\u003C\u002Fli>\n\u003Cli>\u003Ccode>data-mc-direction\u003C\u002Fcode> — \u003Ccode>rtl\u003C\u002Fcode>, \u003Ccode>ltr\u003C\u002Fcode> или \u003Ccode>auto\u003C\u002Fcode>;\u003C\u002Fli>\n\u003Cli>\u003Ccode>data-mc-skew\u003C\u002Fcode> — наклон по оси Y;\u003C\u002Fli>\n\u003Cli>\u003Ccode>data-mc-min\u003C\u002Fcode> — минимальная ширина для запуска;\u003C\u002Fli>\n\u003Cli>\u003Ccode>data-mc-max\u003C\u002Fcode> — максимальная ширина для запуска.\u003C\u002Fli>\n\u003C\u002Ful>\n\u003Cp class=\"body-large\">\u003Ccode>data-mc-min\u003C\u002Fcode> и \u003Ccode>data-mc-max\u003C\u002Fcode> работают как альтернативы, а не как совместный диапазон.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Итог первой версии\u003C\u002Fh2>\n\u003Col>\n\u003Cli>Бесконечное движение — это прежде всего управление геометрией. Timeline занимает несколько строк, правильное число копий и пересборка после изменения размеров занимают всё остальное.\u003C\u002Fli>\n\u003Cli>\u003Ccode>resize\u003C\u002Fcode> сообщает о событии браузера, а не о намерении пользователя. На компьютере эти вещи часто совпадают. На мобильном устройстве \u003Ccode>resize\u003C\u002Fcode> может также возникать при движении адрес-бара или при повороте экрана.\u003C\u002Fli>\n\u003Cli>Custom Element — не только красивый тег. Он приносит жизненный цикл, повторное подключение к DOM и обязанность корректно освобождать ресурсы. В текущем варианте эта часть ещё слишком тесно связана с конструктором.\u003C\u002Fli>\n\u003C\u002Fol>\n\u003Cp class=\"body-large\">Желание посмотреть, насколько далеко можно уехать на одном \u003Ccode>repeat: -1\u003C\u002Fcode>, постепенно превращается в небольшой npm-пакет.\u003C\u002Fp>\n\u003Ch2 class=\"headline-medium\">Источники\u003C\u002Fh2>\n\u003Cul class=\"list-large\">\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fgsap.com\u002Fblog\u002F3-11\u002F\">GSAP 3.11: \u003Ccode>gsap.matchMedia()\u003C\u002Fcode>\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FPlugins\u002FScrollTrigger\u002F\">Документация GSAP ScrollTrigger\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fgsap.com\u002Fdocs\u002Fv3\u002FGSAP\u002FTimeline\u002FtotalProgress()\u002F\">Документация GSAP \u003Ccode>totalProgress()\u003C\u002Fcode>\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fhtml.spec.whatwg.org\u002Fmultipage\u002Fcustom-elements.html\">HTML Standard: Custom Elements\u003C\u002Fa>\u003C\u002Fli>\n\u003Cli>\u003Ca href=\"https:\u002F\u002Fwww.w3.org\u002FTR\u002Fmediaqueries-4\u002F#any-input\">Media Queries Level 4: \u003Ccode>any-pointer\u003C\u002Fcode>\u003C\u002Fa>\u003C\u002Fli>\n\u003C\u002Ful>\n",1787812879740]