Реалізація таймера зворотного відліку на вебсайті
Таймер зворотного відліку — маркетинговий інструмент для акцій, запусків, реєстрацій. Технічно простий, але є нюанси з часовими поясами, 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">днів</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) // повертаємо функцію очистки
}
// Ініціалізація: дата з 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); }
}
Часові пояси: правильна обробка
Поширена помилка — зберігати дату акції без часового поясу. Користувач з Києва бачить акцію на 3 години пізніше, ніж користувач з іншого часового поясу.
// На сервері — завжди UTC
// У Laravel:
$event->ends_at = Carbon::parse('2025-06-01 23:59:59', 'Europe/Kyiv')->utc();
// На клієнті — отримуємо UTC ISO рядок
const targetUTC = '2025-06-01T20:59:59Z' // вже UTC
const target = new Date(targetUTC) // JS автоматично переводить в локальний час
Якщо потрібно показувати один і той же час для всіх (наприклад, "акція закінчується в полуніч за київським часом"):
import { zonedTimeToUtc } from 'date-fns-tz'
const kyivMidnight = zonedTimeToUtc('2025-06-01 00:00:00', 'Europe/Kyiv')
// Усі користувачі рахують час до цього 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 дня.







