(() => {
const format = seconds => {const s=Math.max(0,Math.ceil(seconds));return [Math.floor(s/86400),Math.floor(s/3600)%24,Math.floor(s/60)%60,s%60];};
const valid = value => typeof value==='string' && /(?:Z|[+-]\d\d:\d\d)$/.test(value) && Number.isFinite(Date.parse(value));
document.querySelectorAll('[data-countdown]').forEach(root => {
let ended=false; const status=root.querySelector('[data-status]');
function draw(){const raw=root.dataset.deadline;if(!valid(raw)){root.dataset.phase='invalid';const f=root.querySelector('[data-registration] fieldset');if(f)f.disabled=true;root.querySelectorAll('[data-value]').forEach(e=>e.textContent='—');status.textContent='Set a valid ISO deadline with Z or an explicit UTC offset.';return;}
const target=Date.parse(raw),left=Math.max(0,target-Date.now()),values=format(left/1000);root.querySelectorAll('[data-value]').forEach(el=>el.textContent=String(values[Number(el.dataset.value)]).padStart(2,'0'));
const timer=root.querySelector('[role=timer]');if(timer)timer.setAttribute('aria-label',values[0]+' days, '+values[1]+' hours, '+values[2]+' minutes, '+values[3]+' seconds remaining');
const date=root.querySelector('[data-date]');if(date){let zone=root.dataset.zone||'UTC';try{date.textContent=new Intl.DateTimeFormat('en-GB',{dateStyle:'long',timeStyle:'short',timeZone:zone}).format(new Date(target))+' · '+zone;}catch{root.dataset.zone='UTC';date.textContent=new Date(target).toISOString()+' · UTC';}}
const progress=root.querySelector('progress');if(progress){const start=Date.parse(root.dataset.start||'');if(!Number.isFinite(start)||start>=target){progress.value=0;root.querySelector('[data-percent]').textContent='Check the start and end dates.';}else{const percent=Math.max(0,Math.min(100,(Date.now()-start)/(target-start)*100));progress.value=percent;root.querySelector('[data-percent]').textContent=Math.round(percent)+'% of the example window elapsed';}}
const form=root.querySelector('[data-registration]');if(form)form.querySelector('fieldset').disabled=left===0;
root.dataset.phase=left===0?'ended':'upcoming';if(left===0&&!ended){status.textContent=root.dataset.endMessage||'The example deadline has passed.';ended=true;}else if(left>0&&ended){status.textContent='Countdown updated.';ended=false;}
}
root.querySelectorAll('[data-event-target]').forEach(button=>button.addEventListener('click',()=>{root.dataset.deadline=button.dataset.eventTarget;root.querySelector('[data-event-title]').textContent=button.textContent.trim();root.querySelectorAll('[data-event-target]').forEach(e=>e.setAttribute('aria-pressed',String(e===button)));status.textContent='Selected '+button.textContent.trim()+'.';ended=false;draw();}));
const configure=root.querySelector('[data-configure]');if(configure)configure.addEventListener('submit',event=>{event.preventDefault();if(!configure.reportValidity())return;const value=configure.elements.deadline.value;if(!/^\d{4}-\d\d-\d\dT\d\d:\d\d$/.test(value)){status.textContent='Enter a complete UTC date and time.';return;}const iso=value+':00Z';if(!valid(iso)){status.textContent='Enter a valid UTC date and time.';return;}root.dataset.deadline=iso;root.dataset.zone=configure.elements.zone.value;status.textContent='Deadline updated. Only the display time zone changes.';ended=false;draw();});
const close=root.querySelector('[data-dismiss]'),banner=root.querySelector('[data-banner]'),show=root.querySelector('[data-show-banner]');if(close&&banner&&show){close.addEventListener('click',()=>{banner.hidden=true;show.hidden=false;show.focus();});show.addEventListener('click',()=>{banner.hidden=false;show.hidden=true;close.focus();});}
const registration=root.querySelector('[data-registration]');if(registration)registration.addEventListener('submit',event=>{event.preventDefault();draw();if(root.dataset.phase==='ended'||!registration.reportValidity())return;status.textContent='Demo validation passed. No email was sent or registration stored.';});
root.querySelectorAll('[data-preview-state]').forEach(button=>button.addEventListener('click',()=>{root.dataset.deadline=new Date(Date.now()+(button.dataset.previewState==='ended'?-1000:3600000)).toISOString();status.textContent='Previewing the '+button.dataset.previewState+' state with a sample deadline.';ended=false;draw();}));
draw();setInterval(draw,1000);document.addEventListener('visibilitychange',()=>{if(!document.hidden)draw();});
});
document.querySelectorAll('[data-duration-timer]').forEach(root=>{
const input=root.querySelector('[data-minutes]'),readout=root.querySelector('[data-duration]'),start=root.querySelector('[data-start]'),pause=root.querySelector('[data-pause]'),reset=root.querySelector('[data-reset]'),status=root.querySelector('[data-duration-status]');let remaining=Number(input.value)*60000,deadline=0,running=false,finished=false;
function draw(){if(running)remaining=Math.max(0,deadline-Date.now());const sec=Math.ceil(remaining/1000);readout.textContent=[Math.floor(sec/3600),Math.floor(sec/60)%60,sec%60].map(n=>String(n).padStart(2,'0')).join(':');if(running&&remaining===0){running=false;finished=true;status.textContent='Timer finished.';}start.disabled=running||finished;pause.disabled=!running;input.disabled=running;start.textContent=remaining<Number(input.value)*60000&&!finished?'Resume':'Start';}
start.addEventListener('click',()=>{if(!input.reportValidity())return;if(!running&&!finished){deadline=Date.now()+remaining;running=true;status.textContent='Timer running.';draw();}});pause.addEventListener('click',()=>{if(running){remaining=Math.max(0,deadline-Date.now());running=false;status.textContent='Timer paused.';draw();}});function clear(){running=false;finished=false;remaining=Number(input.value)*60000;status.textContent='Timer reset.';draw();}reset.addEventListener('click',clear);input.addEventListener('change',()=>{if(input.checkValidity())clear();});draw();setInterval(draw,250);document.addEventListener('visibilitychange',()=>{if(!document.hidden)draw();});
});
})();
Bootstrap 5 code
Requires: Bootstrap 5.3.8 CSS
HTML
<main class="ct ct-agenda-theme mx-auto"><div class="ct-panel" data-countdown data-deadline="2030-06-21T17:00:00Z"><p class="ct-kicker text-uppercase">City culture weekend / Example programme</p><h1>Three dates. One place to keep track.</h1><div class="ct-events" role="group" aria-label="Choose an example event"><button type="button" data-event-target="2030-06-21T17:00:00Z" aria-pressed="true">Opening evening</button><button type="button" data-event-target="2030-06-22T09:00:00Z" aria-pressed="false">Studio day</button><button type="button" data-event-target="2030-06-23T15:00:00Z" aria-pressed="false">Closing conversation</button></div><h2 data-event-title>Opening evening</h2><div class="ct-units" role="timer" aria-live="off" aria-label="Time remaining"><div class="ct-unit"><b data-value="0">00</b><span>Days</span></div><div class="ct-unit"><b data-value="1">00</b><span>Hours</span></div><div class="ct-unit"><b data-value="2">00</b><span>Minutes</span></div><div class="ct-unit"><b data-value="3">00</b><span>Seconds</span></div></div><p class="ct-date" data-date></p><p class="ct-status" data-status role="status"></p><p class="ct-note">Illustrative dates and content. Set your own deadline before using this example.</p></div></main>
(() => {
const format = seconds => {const s=Math.max(0,Math.ceil(seconds));return [Math.floor(s/86400),Math.floor(s/3600)%24,Math.floor(s/60)%60,s%60];};
const valid = value => typeof value==='string' && /(?:Z|[+-]\d\d:\d\d)$/.test(value) && Number.isFinite(Date.parse(value));
document.querySelectorAll('[data-countdown]').forEach(root => {
let ended=false; const status=root.querySelector('[data-status]');
function draw(){const raw=root.dataset.deadline;if(!valid(raw)){root.dataset.phase='invalid';const f=root.querySelector('[data-registration] fieldset');if(f)f.disabled=true;root.querySelectorAll('[data-value]').forEach(e=>e.textContent='—');status.textContent='Set a valid ISO deadline with Z or an explicit UTC offset.';return;}
const target=Date.parse(raw),left=Math.max(0,target-Date.now()),values=format(left/1000);root.querySelectorAll('[data-value]').forEach(el=>el.textContent=String(values[Number(el.dataset.value)]).padStart(2,'0'));
const timer=root.querySelector('[role=timer]');if(timer)timer.setAttribute('aria-label',values[0]+' days, '+values[1]+' hours, '+values[2]+' minutes, '+values[3]+' seconds remaining');
const date=root.querySelector('[data-date]');if(date){let zone=root.dataset.zone||'UTC';try{date.textContent=new Intl.DateTimeFormat('en-GB',{dateStyle:'long',timeStyle:'short',timeZone:zone}).format(new Date(target))+' · '+zone;}catch{root.dataset.zone='UTC';date.textContent=new Date(target).toISOString()+' · UTC';}}
const progress=root.querySelector('progress');if(progress){const start=Date.parse(root.dataset.start||'');if(!Number.isFinite(start)||start>=target){progress.value=0;root.querySelector('[data-percent]').textContent='Check the start and end dates.';}else{const percent=Math.max(0,Math.min(100,(Date.now()-start)/(target-start)*100));progress.value=percent;root.querySelector('[data-percent]').textContent=Math.round(percent)+'% of the example window elapsed';}}
const form=root.querySelector('[data-registration]');if(form)form.querySelector('fieldset').disabled=left===0;
root.dataset.phase=left===0?'ended':'upcoming';if(left===0&&!ended){status.textContent=root.dataset.endMessage||'The example deadline has passed.';ended=true;}else if(left>0&&ended){status.textContent='Countdown updated.';ended=false;}
}
root.querySelectorAll('[data-event-target]').forEach(button=>button.addEventListener('click',()=>{root.dataset.deadline=button.dataset.eventTarget;root.querySelector('[data-event-title]').textContent=button.textContent.trim();root.querySelectorAll('[data-event-target]').forEach(e=>e.setAttribute('aria-pressed',String(e===button)));status.textContent='Selected '+button.textContent.trim()+'.';ended=false;draw();}));
const configure=root.querySelector('[data-configure]');if(configure)configure.addEventListener('submit',event=>{event.preventDefault();if(!configure.reportValidity())return;const value=configure.elements.deadline.value;if(!/^\d{4}-\d\d-\d\dT\d\d:\d\d$/.test(value)){status.textContent='Enter a complete UTC date and time.';return;}const iso=value+':00Z';if(!valid(iso)){status.textContent='Enter a valid UTC date and time.';return;}root.dataset.deadline=iso;root.dataset.zone=configure.elements.zone.value;status.textContent='Deadline updated. Only the display time zone changes.';ended=false;draw();});
const close=root.querySelector('[data-dismiss]'),banner=root.querySelector('[data-banner]'),show=root.querySelector('[data-show-banner]');if(close&&banner&&show){close.addEventListener('click',()=>{banner.hidden=true;show.hidden=false;show.focus();});show.addEventListener('click',()=>{banner.hidden=false;show.hidden=true;close.focus();});}
const registration=root.querySelector('[data-registration]');if(registration)registration.addEventListener('submit',event=>{event.preventDefault();draw();if(root.dataset.phase==='ended'||!registration.reportValidity())return;status.textContent='Demo validation passed. No email was sent or registration stored.';});
root.querySelectorAll('[data-preview-state]').forEach(button=>button.addEventListener('click',()=>{root.dataset.deadline=new Date(Date.now()+(button.dataset.previewState==='ended'?-1000:3600000)).toISOString();status.textContent='Previewing the '+button.dataset.previewState+' state with a sample deadline.';ended=false;draw();}));
draw();setInterval(draw,1000);document.addEventListener('visibilitychange',()=>{if(!document.hidden)draw();});
});
document.querySelectorAll('[data-duration-timer]').forEach(root=>{
const input=root.querySelector('[data-minutes]'),readout=root.querySelector('[data-duration]'),start=root.querySelector('[data-start]'),pause=root.querySelector('[data-pause]'),reset=root.querySelector('[data-reset]'),status=root.querySelector('[data-duration-status]');let remaining=Number(input.value)*60000,deadline=0,running=false,finished=false;
function draw(){if(running)remaining=Math.max(0,deadline-Date.now());const sec=Math.ceil(remaining/1000);readout.textContent=[Math.floor(sec/3600),Math.floor(sec/60)%60,sec%60].map(n=>String(n).padStart(2,'0')).join(':');if(running&&remaining===0){running=false;finished=true;status.textContent='Timer finished.';}start.disabled=running||finished;pause.disabled=!running;input.disabled=running;start.textContent=remaining<Number(input.value)*60000&&!finished?'Resume':'Start';}
start.addEventListener('click',()=>{if(!input.reportValidity())return;if(!running&&!finished){deadline=Date.now()+remaining;running=true;status.textContent='Timer running.';draw();}});pause.addEventListener('click',()=>{if(running){remaining=Math.max(0,deadline-Date.now());running=false;status.textContent='Timer paused.';draw();}});function clear(){running=false;finished=false;remaining=Number(input.value)*60000;status.textContent='Timer reset.';draw();}reset.addEventListener('click',clear);input.addEventListener('change',()=>{if(input.checkValidity())clear();});draw();setInterval(draw,250);document.addEventListener('visibilitychange',()=>{if(!document.hidden)draw();});
});
})();
Tailwind 4 code
Requires: Tailwind CSS 4
HTML
<main class="ct ct-agenda-theme mx-auto w-full font-sans"><div class="ct-panel" data-countdown data-deadline="2030-06-21T17:00:00Z"><p class="ct-kicker uppercase tracking-widest">City culture weekend / Example programme</p><h1>Three dates. One place to keep track.</h1><div class="ct-events" role="group" aria-label="Choose an example event"><button type="button" data-event-target="2030-06-21T17:00:00Z" aria-pressed="true">Opening evening</button><button type="button" data-event-target="2030-06-22T09:00:00Z" aria-pressed="false">Studio day</button><button type="button" data-event-target="2030-06-23T15:00:00Z" aria-pressed="false">Closing conversation</button></div><h2 data-event-title>Opening evening</h2><div class="ct-units" role="timer" aria-live="off" aria-label="Time remaining"><div class="ct-unit"><b data-value="0">00</b><span>Days</span></div><div class="ct-unit"><b data-value="1">00</b><span>Hours</span></div><div class="ct-unit"><b data-value="2">00</b><span>Minutes</span></div><div class="ct-unit"><b data-value="3">00</b><span>Seconds</span></div></div><p class="ct-date" data-date></p><p class="ct-status" data-status role="status"></p><p class="ct-note">Illustrative dates and content. Set your own deadline before using this example.</p></div></main>
(() => {
const format = seconds => {const s=Math.max(0,Math.ceil(seconds));return [Math.floor(s/86400),Math.floor(s/3600)%24,Math.floor(s/60)%60,s%60];};
const valid = value => typeof value==='string' && /(?:Z|[+-]\d\d:\d\d)$/.test(value) && Number.isFinite(Date.parse(value));
document.querySelectorAll('[data-countdown]').forEach(root => {
let ended=false; const status=root.querySelector('[data-status]');
function draw(){const raw=root.dataset.deadline;if(!valid(raw)){root.dataset.phase='invalid';const f=root.querySelector('[data-registration] fieldset');if(f)f.disabled=true;root.querySelectorAll('[data-value]').forEach(e=>e.textContent='—');status.textContent='Set a valid ISO deadline with Z or an explicit UTC offset.';return;}
const target=Date.parse(raw),left=Math.max(0,target-Date.now()),values=format(left/1000);root.querySelectorAll('[data-value]').forEach(el=>el.textContent=String(values[Number(el.dataset.value)]).padStart(2,'0'));
const timer=root.querySelector('[role=timer]');if(timer)timer.setAttribute('aria-label',values[0]+' days, '+values[1]+' hours, '+values[2]+' minutes, '+values[3]+' seconds remaining');
const date=root.querySelector('[data-date]');if(date){let zone=root.dataset.zone||'UTC';try{date.textContent=new Intl.DateTimeFormat('en-GB',{dateStyle:'long',timeStyle:'short',timeZone:zone}).format(new Date(target))+' · '+zone;}catch{root.dataset.zone='UTC';date.textContent=new Date(target).toISOString()+' · UTC';}}
const progress=root.querySelector('progress');if(progress){const start=Date.parse(root.dataset.start||'');if(!Number.isFinite(start)||start>=target){progress.value=0;root.querySelector('[data-percent]').textContent='Check the start and end dates.';}else{const percent=Math.max(0,Math.min(100,(Date.now()-start)/(target-start)*100));progress.value=percent;root.querySelector('[data-percent]').textContent=Math.round(percent)+'% of the example window elapsed';}}
const form=root.querySelector('[data-registration]');if(form)form.querySelector('fieldset').disabled=left===0;
root.dataset.phase=left===0?'ended':'upcoming';if(left===0&&!ended){status.textContent=root.dataset.endMessage||'The example deadline has passed.';ended=true;}else if(left>0&&ended){status.textContent='Countdown updated.';ended=false;}
}
root.querySelectorAll('[data-event-target]').forEach(button=>button.addEventListener('click',()=>{root.dataset.deadline=button.dataset.eventTarget;root.querySelector('[data-event-title]').textContent=button.textContent.trim();root.querySelectorAll('[data-event-target]').forEach(e=>e.setAttribute('aria-pressed',String(e===button)));status.textContent='Selected '+button.textContent.trim()+'.';ended=false;draw();}));
const configure=root.querySelector('[data-configure]');if(configure)configure.addEventListener('submit',event=>{event.preventDefault();if(!configure.reportValidity())return;const value=configure.elements.deadline.value;if(!/^\d{4}-\d\d-\d\dT\d\d:\d\d$/.test(value)){status.textContent='Enter a complete UTC date and time.';return;}const iso=value+':00Z';if(!valid(iso)){status.textContent='Enter a valid UTC date and time.';return;}root.dataset.deadline=iso;root.dataset.zone=configure.elements.zone.value;status.textContent='Deadline updated. Only the display time zone changes.';ended=false;draw();});
const close=root.querySelector('[data-dismiss]'),banner=root.querySelector('[data-banner]'),show=root.querySelector('[data-show-banner]');if(close&&banner&&show){close.addEventListener('click',()=>{banner.hidden=true;show.hidden=false;show.focus();});show.addEventListener('click',()=>{banner.hidden=false;show.hidden=true;close.focus();});}
const registration=root.querySelector('[data-registration]');if(registration)registration.addEventListener('submit',event=>{event.preventDefault();draw();if(root.dataset.phase==='ended'||!registration.reportValidity())return;status.textContent='Demo validation passed. No email was sent or registration stored.';});
root.querySelectorAll('[data-preview-state]').forEach(button=>button.addEventListener('click',()=>{root.dataset.deadline=new Date(Date.now()+(button.dataset.previewState==='ended'?-1000:3600000)).toISOString();status.textContent='Previewing the '+button.dataset.previewState+' state with a sample deadline.';ended=false;draw();}));
draw();setInterval(draw,1000);document.addEventListener('visibilitychange',()=>{if(!document.hidden)draw();});
});
document.querySelectorAll('[data-duration-timer]').forEach(root=>{
const input=root.querySelector('[data-minutes]'),readout=root.querySelector('[data-duration]'),start=root.querySelector('[data-start]'),pause=root.querySelector('[data-pause]'),reset=root.querySelector('[data-reset]'),status=root.querySelector('[data-duration-status]');let remaining=Number(input.value)*60000,deadline=0,running=false,finished=false;
function draw(){if(running)remaining=Math.max(0,deadline-Date.now());const sec=Math.ceil(remaining/1000);readout.textContent=[Math.floor(sec/3600),Math.floor(sec/60)%60,sec%60].map(n=>String(n).padStart(2,'0')).join(':');if(running&&remaining===0){running=false;finished=true;status.textContent='Timer finished.';}start.disabled=running||finished;pause.disabled=!running;input.disabled=running;start.textContent=remaining<Number(input.value)*60000&&!finished?'Resume':'Start';}
start.addEventListener('click',()=>{if(!input.reportValidity())return;if(!running&&!finished){deadline=Date.now()+remaining;running=true;status.textContent='Timer running.';draw();}});pause.addEventListener('click',()=>{if(running){remaining=Math.max(0,deadline-Date.now());running=false;status.textContent='Timer paused.';draw();}});function clear(){running=false;finished=false;remaining=Number(input.value)*60000;status.textContent='Timer reset.';draw();}reset.addEventListener('click',clear);input.addEventListener('change',()=>{if(input.checkValidity())clear();});draw();setInterval(draw,250);document.addEventListener('visibilitychange',()=>{if(!document.hidden)draw();});
});
})();
About this set
An event selector updates one shared countdown, date and accessible selected state. All three dates are explicit UTC instants; selecting a later event does not restart a fictional rolling deadline.
What’s included
Explicit deadline or duration configuration
Clock-derived remaining time; no interval drift accumulation
Responsive controls and keyboard focus
Expiry and reset behavior covered by tests
Accessibility
Ticking values use role="timer" with aria-live="off". Status messages announce user actions and completion, not every second. Controls have visible labels and focus outlines. No motion is required.
Customise it
Edit the data-deadline/data-start ISO values, or the default duration input. Change the scoped .ct theme variables. The Bootstrap and Tailwind variants share scoped component CSS and vanilla logic with framework utility markup.