Implementing Countdown Timer on Website
Countdown timer is a marketing tool for promotions, launches, registrations. Technically simple, but has nuances with time zones, SEO, and client-side manipulations.
Basic JavaScript Implementation
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">Promotion 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">days</span>
</div>` : ''}
<div class="countdown__unit">
<span class="countdown__value">${String(hours).padStart(2, '0')}</span>
<span class="countdown__label">hours</span>
</div>
<div class="countdown__unit">
<span class="countdown__value">${String(minutes).padStart(2, '0')}</span>
<span class="countdown__label">minutes</span>
</div>
<div class="countdown__unit">
<span class="countdown__value">${String(seconds).padStart(2, '0')}</span>
<span class="countdown__label">seconds</span>
</div>
</div>
`
}
update()
const interval = setInterval(update, 1000)
return () => clearInterval(interval) // return cleanup function
}
// Initialization: date from data-attribute
document.querySelectorAll('[data-countdown]').forEach(el => {
const target = new Date(el.getAttribute('data-countdown')!)
createCountdown(target, el as HTMLElement)
})
React Component with Flip Animation
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">Time's up</div>
return (
<div className="countdown-timer" role="timer" aria-label="Countdown">
{days > 0 && <FlipUnit value={days} label="days" />}
<FlipUnit value={hours} label="hours" />
<FlipUnit value={minutes} label="minutes" />
<FlipUnit value={seconds} label="seconds" />
</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); }
}
Time Zones: Proper Handling
Common mistake — store event date without timezone. User from one timezone sees promo 7 hours later than user from another.
// On server — always UTC
// In Laravel:
$event->ends_at = Carbon::parse('2025-06-01 23:59:59', 'Europe/London')->utc();
// On client — get UTC ISO string
const targetUTC = '2025-06-01T20:59:59Z' // already UTC
const target = new Date(targetUTC) // JS automatically converts to local time
If need to show same time for everyone (e.g., "sale ends at midnight in London"):
import { zonedTimeToUtc } from 'date-fns-tz'
const londonMidnight = zonedTimeToUtc('2025-06-01 00:00:00', 'Europe/London')
// All users count down to this UTC moment
Protection from DevTools Manipulation
Opening DevTools and changing system time or tweaking JS variable is trivial. If timer is tied to business logic (discount, access), validation must be on server:
// Middleware for checking promo activity
class PromoActive
{
public function handle(Request $request, Closure $next): Response
{
$promo = Promo::findOrFail($request->route('promo'));
if (!$promo->isActive()) {
return response()->json(['error' => 'Promotion ended'], 410);
}
return $next($request);
}
}
Timer with Server Time (Protection from Desync)
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()
// Compensate half of round-trip time
const delta = serverTime - ((t0 + t1) / 2)
return delta
}
// Use at start
const delta = await getServerTimeDelta()
const getAdjustedNow = () => Date.now() + delta
Evergreen Timer (Restarts for Each User)
Pattern: promotion lasts 24 hours from first visit. Stored in 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 hours
SEO: Completion Time for Search Engines
<!-- Schema.org for timed events -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SaleEvent",
"name": "Summer Sale",
"startDate": "2025-06-01T00:00:00+01:00",
"endDate": "2025-06-07T23:59:59+01:00",
"offers": {
"@type": "Offer",
"discount": "50%"
}
}
</script>
Timeline
Static timer with basic layout — 2–3 hours. With flip animation, proper time zones, and responsive design — 1 day. With server sync, evergreen logic, and Schema.org markup — 1.5 days.







