Таймер обратного отсчёта нужен для акций, запусков, вебинаров и ограниченных предложений. Сделать его можно на чистом JavaScript без библиотек. Главное правило: не уменьшать счётчик на секунду каждую секунду, а каждый раз вычислять оставшееся время от текущих часов. Тогда таймер не отстаёт, даже если вкладка была в фоне или браузер притормозил.

Разметка

html
<div class="countdown" data-deadline="2099-12-31T23:59:59+06:00">
    <span data-unit="days">0</span> дн.
    <span data-unit="hours">00</span> ч
    <span data-unit="minutes">00</span> мин
    <span data-unit="seconds">00</span> с
</div>

Дата окончания задаётся в атрибуте data-deadline в формате ISO: ГГГГ-ММ-ДДTчч:мм:сс и смещение часового пояса. В примере стоит условная дата - подставьте свою. Смещение важно: без него дата будет считаться по часовому поясу посетителя, и у пользователей в разных городах акция закончится в разное время. Со смещением (в примере +06:00, то есть UTC+6) отсчёт идёт до одного и того же момента для всех посетителей.

Скрипт

js
function startCountdown(element, deadline, onFinish) {
    const parts = {
        days: element.querySelector('[data-unit="days"]'),
        hours: element.querySelector('[data-unit="hours"]'),
        minutes: element.querySelector('[data-unit="minutes"]'),
        seconds: element.querySelector('[data-unit="seconds"]'),
    };

    function render() {
        const left = Math.max(0, deadline - Date.now());
        const total = Math.floor(left / 1000);

        parts.days.textContent = Math.floor(total / 86400);
        parts.hours.textContent = String(Math.floor(total % 86400 / 3600)).padStart(2, '0');
        parts.minutes.textContent = String(Math.floor(total % 3600 / 60)).padStart(2, '0');
        parts.seconds.textContent = String(total % 60).padStart(2, '0');

        if (left === 0) {
            clearInterval(timer);
            if (onFinish) onFinish(element);
        }
    }

    const timer = setInterval(render, 1000);
    render();
    return timer;
}

document.querySelectorAll('.countdown').forEach(element => {
    const deadline = new Date(element.dataset.deadline).getTime();
    startCountdown(element, deadline, el => {
        el.textContent = 'Акция завершена';
    });
});

Как это работает:

  • deadline - Date.now() - сколько миллисекунд осталось. Значение вычисляется заново при каждом обновлении, поэтому ошибка не накапливается;
  • Math.max(0, ...) не даёт уйти в отрицательные числа, если страница открыта уже после окончания;
  • padStart(2, '0') добавляет ведущий ноль: 05 вместо 5;
  • render() вызывается сразу, чтобы цифры появились без задержки в секунду;
  • по окончании интервал останавливается, и вызывается функция onFinish, где можно скрыть кнопку, показать сообщение или перезагрузить блок.

Код обрабатывает все элементы с классом countdown, поэтому на странице может быть сколько угодно таймеров с разными датами.

Таймер на N минут

Если таймер должен отсчитывать фиксированный интервал от открытия страницы, например 15 минут на оформление заказа:

js
const element = document.querySelector('.countdown');
const deadline = Date.now() + 15 * 60 * 1000;
startCountdown(element, deadline);

Если блок дней не нужен, просто уберите его из разметки и соответствующую строку из render().

Сохранение после перезагрузки

Стрелка обновления вокруг окна и хранилище с часами

В варианте выше таймер начинается заново при каждом обновлении страницы. Чтобы отсчёт продолжался, сохраните время окончания в localStorage:

js
function getPersistentDeadline(key, minutes) {
    const saved = Number(localStorage.getItem(key));

    if (saved && saved > Date.now()) {
        return saved;
    }

    const deadline = Date.now() + minutes * 60 * 1000;
    localStorage.setItem(key, deadline);
    return deadline;
}

const element = document.querySelector('.countdown');
startCountdown(element, getPersistentDeadline('order-timer', 15));

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

Учтите, что localStorage хранится в браузере посетителя и легко очищается. Если срок действительно важен (например, срок действия скидки), его нужно проверять на сервере, а таймер на странице - только для наглядности.

Время сервера вместо часов посетителя

Date.now() берёт время с компьютера посетителя, а часы на нём могут спешить или отставать. Для точного отсчёта получите время с сервера и вычислите поправку:

js
const response = await fetch('/api/time');           // сервер возвращает { now: <миллисекунды> }
const { now } = await response.json();
const offset = now - Date.now();

и в render() используйте Date.now() + offset вместо Date.now().

Склонение слов

Если нужно писать «2 дня», «5 дней» вместо сокращений, понадобится функция склонения:

js
function plural(n, one, few, many) {
    const mod10 = n % 10, mod100 = n % 100;
    if (mod10 === 1 && mod100 !== 11) return one;
    if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return few;
    return many;
}

plural(21, 'день', 'дня', 'дней');   // 'день'

Для этого можно использовать и встроенный Intl.PluralRules('ru'): он возвращает категорию one, few или many, по которой удобно выбрать нужную форму.

Доступность

Чтобы программы экранного доступа не зачитывали каждую секунду, не ставьте на таймер aria-live. Если важно сообщить о завершении, выведите сообщение в отдельный элемент с aria-live="polite".