Эффект печатной машинки — это цифровой эквивалент наблюдения за тем, как кто-то думает вслух. Текст появляется по одному символу за раз, как будто настоящая рука нажимает на настоящие клавиши. Вы видите это в hero-секциях портфолио, в терминальных эмуляторах в браузере и в окнах чата ИИ-ассистентов, которые хотят доказать, что они «печатают» ответ, а не просто выдают готовый блок текста. При грамотном исполнении это создает предвкушение. При плохом — ощущение зажеванного принтера из 1987 года.

Почему этот паттерн сохраняется

Компьютеры передают информацию мгновенно. Люди — нет. Разрыв между этими двумя скоростями полезен. Эффект печатной машинки сокращает его, имитируя человеческий темп. На лендинге он может привлекать внимание к заголовку, слово за словом, чтобы посетители действительно прочитали ценностное предложение, а не просто пробежали глазами. В эмуляторе терминала он создает иллюзию того, что команды выполняются в реальном времени. В интерфейсе чат-бота ритм сигнализирует о том, что ответ генерируется на лету, а не извлекается из базы данных.

Но эффект работает только в том случае, если механика уважает пользователя. Монотонное, метрономичное «тик-тик-тик» с одинаковыми интервалами кажется роботизированным. Хуже того, реализация, игнорирующая ассистивные технологии, может превратить приятный визуальный эффект в раздражающий барьер. Цель не в том, чтобы замедлить пользователя, а в том, чтобы добавить ровно столько «трения», сколько нужно, чтобы интерфейс казался живым.

Реализуйте это с помощью рекурсивного setTimeout

Начните с setTimeout и даже не думайте о setInterval. Разница важнее, чем кажется.

setInterval упрям. Он срабатывает каждые n миллисекунд независимо от того, что происходит в вашем скрипте или в основном потоке браузера. Если вашей логике нужна пауза в 50 миллисекунд между обычными нажатиями клавиш, но пауза в 150 миллисекунд после знака препинания, setInterval не сможет адаптироваться. В итоге вы будете оборачивать его в дополнительную условную логику, бороться с состоянием гонки (race conditions) и в конечном счете так часто очищать и сбрасывать интервал, что код превратится в кошмар управления состоянием. Фиксированные интервалы пасуют в тот момент, когда вам требуется переменная скорость.

Рекурсивный setTimeout решает эту проблему, позволяя каждому шагу самому определять правила для следующего. Думайте об этом как о крошечной стейт-машине. Вы поддерживаете несколько переменных: текущую строку, индекс текущего символа, булев флаг того, печатаете вы или удаляете текст, и счетчик textIndex, чтобы можно было перебирать несколько строк. Функция добавляет один символ, проверяет свое положение в предложении, а затем планирует свой следующий вызов с задержкой, соответствующей контексту.

Например, вы можете печатать большинство символов со скоростью 50 миллисекунд, замедляться до 150 миллисекунд после запятой и делать паузу на 800 миллисекунд в конце предложения перед переходом в режим удаления. С setInterval это сделать чисто невозможно. С рекурсивным setTimeout логика предельно проста:

if typing:
  append next character
  if at end of string:
    switch to pause mode
    schedule next call after 1000ms
if deleting:
  remove last character
  if string empty:
    increment textIndex
    load next string
    switch to typing mode

Такая структура также упрощает очистку. Сохраните ID тайм-аута. Когда компонент размонтируется или пользователь уйдет со страницы, один раз вызовите clearTimeout. Никаких «осиротевших» интервалов, тикающих в фоновом режиме.

Курсор должен мигать сам по себе

Мигающий курсор — это визуальная деталь, а не вопрос данных. Не включайте его в свой движок состояния JavaScript. Используйте отдельную CSS-анимацию, привязанную к псевдоэлементу ::after или выделенному тегу <span>, расположенному в конце вашего текстового контейнера.

Простая анимация @keyframes blink, переключающая opacity или border-color с использованием тайминга step-end, обеспечит четкую, оптимизированную для аппаратного ускорения пульсацию, которая выполняется на композиторе. JavaScript не должен заниматься микроменеджментом видимости курсора. Если вы будете переключать свойства отображения внутри рекурсии setTimeout, вы заставите браузер выполнять ненужные пересчеты стилей при каждом символе. Пусть CSS отвечает за эстетику, а JavaScript — за последовательность.

Циклическое переключение между несколькими строками легко реализовать с помощью счетчика textIndex. Сохраните свои строки в массиве. Когда анимация завершит фазу удаления и контейнер станет пустым, увеличьте textIndex по модулю длины массива, сбросьте указатель символа на ноль и начните печатать снова. Именно так сайты-портфолио переключают роли — ["Developer", "Designer", "Writer"] — без перезагрузки страницы.

Ошибки, разрушающие иллюзию

Три ошибки постоянно встречаются в любительских реализациях.

Использование setInterval. Мы уже разбирали проблему переменной скорости, но существует и более тонкая проблема. Если обновление DOM когда-либо задерживается — скажем, из-за того, что браузер отрисовывает сдвиг макета (layout shift), — setInterval продолжает срабатывать. Это может привести к наложению записей, дублированию символов или к тому, что запись будет происходить быстрее, чем браузер успеет её отрисовать. Рекурсивный setTimeout ждет завершения текущего шага, прежде чем планировать следующий.

Забытая обработка HTML-сущностей. Если ваши исходные строки содержат угловые скобки, а вы внедряете контент через innerHTML по одному символу за раз, вы будете разрывать теги пополам. Браузер видит <, затем <s, затем <st. Это мешает правильному парсингу тегов и может привести к поврежденным узлам DOM или неожиданным каскадам стилей. Если вы хотите, чтобы отображались сами символы, сначала экранируйте их или, что еще лучше, используйте textContent вместо innerHTML. Если вам действительно нужны стилизованные <span> внутри эффекта печати, предварительно обработайте строку, чтобы точно знать, где начинаются и заканчиваются теги, прежде чем запускать цикл по символам.

Игнорирование доступности. Скринридеры не любят, когда им читают текст по одной букве за раз. По мере того как ваш скрипт добавляет каждый новый символ в DOM, некоторые вспомогательные технологии заново объявляют весь узел, создавая прерывистый поток обрывков слов. Это кошмар для всех, кто полагается на аудиальную навигацию. Решение несложно: добавьте aria-label контейнеру, который содержит полный итоговый текст. Вы также можете полностью скрыть анимированный элемент от вспомогательных технологий с помощью aria-hidden="true" и предоставить визуально скрытую статичную копию для скринридеров. В любом случае, предоставьте пользователям полное предложение сразу, вместо того чтобы заставлять их ждать завершения вашего «перформанса».

Ways to Polish Your Version

Как только основной цикл начнет работать стабильно, можно добавлять дополнительные функции. Но сдерживайте желание внедрять их, пока не будут отлажены основы.

Звуковые эффекты нажатия клавиш. Едва уловимый щелчок при появлении каждого символа может приносить удовлетворение, но аудио на веб-страницах — это минное поле. Используйте Web Audio API или легкий элемент Audio с коротким буфером. Немного варьируйте скорость воспроизведения — от 0.95 до 1.05, — чтобы одинаковые щелчки не звучали синтетически. Всегда соблюдайте политики автовоспроизведения браузера и добавьте переключатель беззвучного режима. Ничто не отпугивает пользователей быстрее, чем портфолио с включенным звуком печати, который начинает играть сам по себе в 9 утра.

Многострочный ввод. В настоящих терминалах текст переносится. Если ваш текст переходит на новую строку, простой курсор в виде border-right будет нелепо прыгать, если только ваш макет не предсказуем. Разбивайте строки по символам переноса строки и отрисовывайте каждую строку в собственном <span> или используйте позиционированный псевдоэлемент, который отслеживает конец контента. Будьте осторожны с переносом текста; курсор, реализованный как строковый (inline) бордер, может «отрываться» от текста, если ширина контейнера изменится. Рассмотрите возможность использования white-space: pre-wrap и моноширинного шрифта для стилизации под терминал, так как символы фиксированной ширины делают расчеты положения курсора гораздо более предсказуемыми.

Рендеринг Markdown в реальном времени. Здесь всё становится сложно. Если вы печатаете **bold**, у вас есть выбор: отрисовывать звездочки буквально так, как они появляются, или на лету преобразовывать их в жирный шрифт. Если вы выберете второй вариант, переход с textContent на innerHTML в процессе печати приведет к изменению границ текстовых узлов. Положение курсора превращается в головную боль при расчетах, потому что HTML-теги смещают дерево DOM под вами. Один из более безопасных подходов — печатать необработанную строку Markdown как обычно, а затем запускать проход рендеринга, когда вся строка уже будет на экране. Если вам действительно нужно форматирование в реальном времени, поддерживайте два слоя: скрытый буфер набора и распарсенный визуальный слой.

Эффекты удаления. Удаление текста не обязательно должно означать нажатие Backspace по одному символу за раз. Вы можете имитировать сброс по принципу «выделить всё, затем удалить», который мгновенно очищает поле перед набором следующей строки. Это выглядит слишком стерильно. В качестве альтернативы, медленное удаление (по 30 миллисекунд на символ) создает напряжение. Смешивайте оба подхода: быстро удалите опечатку, сделайте паузу, а затем возобновите удаление с обычной скоростью. Такая вариативность придает эффекту «человечности».

The Real Takeaway

Эффект печатной машинки — это один из тех элементов UI-декора, которые кажутся тривиальными на первый взгляд, но раскрывают свою сложность только после того, как вы начнете их реализовывать. Начните с