Реализация таймера обратного отсчёта на сайте
Таймер обратного отсчёта — маркетинговый инструмент для акций, запусков, регистраций. Технически прост, но есть нюансы с часовыми поясами, SEO и манипуляциями на клиенте.
Базовая реализация на JavaScript
function createCountdown(targetDate: Date, container: HTMLElement) {
function update() {
const now = Date.now()
const diff = targetDate.getTime() - now
if (diff <= 0) {
container.innerHTML = '<span class="countdown__ended">Акция завершена</span>'
return
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
container.innerHTML = `
<div class="countdown">
${days > 0 ? `<div class="countdown__unit">
<span class="countdown__value">${String(days).padStart(2, '0')}</span>
<span class="countdown__label">${pluralize(days, ['день', 'дня', 'дней'])}</span>
</div>` : ''}
<div class="countdown__unit">
<span class="countdown__value">${String(hours).padStart(2, '0')}</span>
<span class="countdown__label">часов</span>
</div>
<div class="countdown__unit">
<span class="countdown__value">${String(minutes).padStart(2, '0')}</span>
<span class="countdown__label">минут</span>
</div>
<div class="countdown__unit">
<span class="countdown__value">${String(seconds).padStart(2, '0')}</span>
<span class="countdown__label">секунд</span>
</div>
</div>
`
}
update()
const interval = setInterval(update, 1000)
return () => clearInterval(interval) // возвращаем функцию очистки
}
function pluralize(n: number, forms: [string, string, string]): string {
const mod10 = n % 10
const mod100 = n % 100
if (mod10 === 1 && mod100 !== 11) return forms[0]
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return forms[1]
return forms[2]
}
// Инициализация: дата из data-атрибута
document.querySelectorAll('[data-countdown]').forEach(el => {
const target = new Date(el.getAttribute('data-countdown')!)
createCountdown(target, el as HTMLElement)
})
React-компонент с flip-анимацией
import { useState, useEffect, useRef } from 'react'
function useCountdown(targetDate: Date) {
const [timeLeft, setTimeLeft] = useState(() => getTimeLeft(targetDate))
useEffect(() => {
const tick = () => setTimeLeft(getTimeLeft(targetDate))
tick()
const id = setInterval(tick, 1000)
return () => clearInterval(id)
}, [targetDate])
return timeLeft
}
function getTimeLeft(target: Date) {
const diff = Math.max(0, target.getTime() - Date.now())
return {
days: Math.floor(diff / 86400000),
hours: Math.floor((diff % 86400000) / 3600000),
minutes: Math.floor((diff % 3600000) / 60000),
seconds: Math.floor((diff % 60000) / 1000),
expired: diff === 0,
}
}
function FlipUnit({ value, label }: { value: number; label: string }) {
const [flip, setFlip] = useState(false)
const prevValue = useRef(value)
useEffect(() => {
if (prevValue.current !== value) {
setFlip(true)
prevValue.current = value
const t = setTimeout(() => setFlip(false), 300)
return () => clearTimeout(t)
}
}, [value])
return (
<div className="flip-unit">
<div className={`flip-unit__card ${flip ? 'flip-unit__card--flip' : ''}`}>
<span className="flip-unit__value">{String(value).padStart(2, '0')}</span>
</div>
<span className="flip-unit__label">{label}</span>
</div>
)
}
export function CountdownTimer({ target, onExpire }: { target: Date; onExpire?: () => void }) {
const { days, hours, minutes, seconds, expired } = useCountdown(target)
useEffect(() => {
if (expired) onExpire?.()
}, [expired, onExpire])
if (expired) return <div className="countdown--expired">Время вышло</div>
return (
<div className="countdown-timer" role="timer" aria-label="Обратный отсчёт">
{days > 0 && <FlipUnit value={days} label="дней" />}
<FlipUnit value={hours} label="часов" />
<FlipUnit value={minutes} label="минут" />
<FlipUnit value={seconds} label="секунд" />
</div>
)
}
.flip-unit__card {
position: relative;
display: inline-block;
transition: transform 0.3s ease;
transform-style: preserve-3d;
}
.flip-unit__card--flip {
animation: flip 0.3s ease;
}
@keyframes flip {
0% { transform: rotateX(0deg); }
50% { transform: rotateX(-90deg); }
100% { transform: rotateX(0deg); }
}
Часовые пояса: правильная обработка
Распространённая ошибка — хранить дату акции без таймзоны. Пользователь из Владивостока видит акцию на 7 часов позже, чем из Москвы.
// На сервере — всегда UTC
// В Laravel:
$event->ends_at = Carbon::parse('2025-06-01 23:59:59', 'Europe/Moscow')->utc();
// На клиенте — получаем UTC ISO строку
const targetUTC = '2025-06-01T20:59:59Z' // уже UTC
const target = new Date(targetUTC) // JS автоматически переводит в локальное время
Если нужно показывать одно и то же время для всех (например, «распродажа кончается в полночь по Москве»):
import { zonedTimeToUtc } from 'date-fns-tz'
const moscowMidnight = zonedTimeToUtc('2025-06-01 00:00:00', 'Europe/Moscow')
// Все пользователи считают время до этого UTC-момента
Защита от манипуляций через DevTools
Открыть DevTools и поменять системное время или подправить JS-переменную — тривиально. Если таймер связан с бизнес-логикой (скидка, доступ), проверка должна быть на сервере:
// Middleware для проверки активности акции
class PromoActive
{
public function handle(Request $request, Closure $next): Response
{
$promo = Promo::findOrFail($request->route('promo'));
if (!$promo->isActive()) {
return response()->json(['error' => 'Акция завершена'], 410);
}
return $next($request);
}
}
Таймер с серверным временем (защита от рассинхрона)
async function getServerTimeDelta(): Promise<number> {
const t0 = Date.now()
const response = await fetch('/api/time')
const t1 = Date.now()
const serverTime: number = await response.json()
// Компенсируем половину round-trip времени
const delta = serverTime - ((t0 + t1) / 2)
return delta
}
// Используем при старте
const delta = await getServerTimeDelta()
const getAdjustedNow = () => Date.now() + delta
Evergreen-таймер (перезапускается для каждого пользователя)
Паттерн: акция длится 24 часа с момента первого визита. Хранится в localStorage/cookie:
function getOrCreateDeadline(durationMs: number): Date {
const key = 'promo_deadline'
const stored = localStorage.getItem(key)
if (stored) {
const deadline = new Date(stored)
if (deadline > new Date()) return deadline
}
const deadline = new Date(Date.now() + durationMs)
localStorage.setItem(key, deadline.toISOString())
return deadline
}
const deadline = getOrCreateDeadline(24 * 60 * 60 * 1000) // 24 часа
SEO: время завершения для поисковиков
<!-- Schema.org для событий с таймером -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SaleEvent",
"name": "Летняя распродажа",
"startDate": "2025-06-01T00:00:00+03:00",
"endDate": "2025-06-07T23:59:59+03:00",
"offers": {
"@type": "Offer",
"discount": "50%"
}
}
</script>
Сроки
Статичный таймер с базовой вёрсткой — 2–3 часа. С flip-анимацией, правильными часовыми поясами и адаптивом — 1 день. С серверной синхронизацией, evergreen-логикой и Schema.org разметкой — 1.5 дня.







