(() => {
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-kitchen-theme mx-auto"><div class="ct-panel"><p class="ct-kicker text-uppercase">At the worktop / Three separate timers</p><h1>A timer for each task.</h1><div class="ct-timers"><section class="ct-timer" data-duration-timer><h2>Steep</h2><label>Minutes<input data-minutes type="number" min="1" max="180" step="1" value="3"></label><p class="ct-duration" data-duration role="timer" aria-live="off">00:00:00</p><div class="ct-actions d-flex flex-wrap"><button type="button" data-start>Start</button><button type="button" class="secondary" data-pause disabled>Pause</button><button type="button" class="secondary" data-reset>Reset</button></div><p class="ct-status" data-duration-status role="status"></p></section><section class="ct-timer" data-duration-timer><h2>Rest</h2><label>Minutes<input data-minutes type="number" min="1" max="180" step="1" value="10"></label><p class="ct-duration" data-duration role="timer" aria-live="off">00:00:00</p><div class="ct-actions d-flex flex-wrap"><button type="button" data-start>Start</button><button type="button" class="secondary" data-pause disabled>Pause</button><button type="button" class="secondary" data-reset>Reset</button></div><p class="ct-status" data-duration-status role="status"></p></section><section class="ct-timer" data-duration-timer><h2>Set</h2><label>Minutes<input data-minutes type="number" min="1" max="180" step="1" value="5"></label><p class="ct-duration" data-duration role="timer" aria-live="off">00:00:00</p><div class="ct-actions d-flex flex-wrap"><button type="button" data-start>Start</button><button type="button" class="secondary" data-pause disabled>Pause</button><button type="button" class="secondary" data-reset>Reset</button></div><p class="ct-status" data-duration-status role="status"></p></section></div><p class="ct-note">The timers run independently and announce completion once. They make no sound and do not provide food-safety guidance.</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-kitchen-theme mx-auto w-full font-sans"><div class="ct-panel"><p class="ct-kicker uppercase tracking-widest">At the worktop / Three separate timers</p><h1>A timer for each task.</h1><div class="ct-timers"><section class="ct-timer" data-duration-timer><h2>Steep</h2><label>Minutes<input data-minutes type="number" min="1" max="180" step="1" value="3"></label><p class="ct-duration" data-duration role="timer" aria-live="off">00:00:00</p><div class="ct-actions flex flex-wrap"><button type="button" data-start>Start</button><button type="button" class="secondary" data-pause disabled>Pause</button><button type="button" class="secondary" data-reset>Reset</button></div><p class="ct-status" data-duration-status role="status"></p></section><section class="ct-timer" data-duration-timer><h2>Rest</h2><label>Minutes<input data-minutes type="number" min="1" max="180" step="1" value="10"></label><p class="ct-duration" data-duration role="timer" aria-live="off">00:00:00</p><div class="ct-actions flex flex-wrap"><button type="button" data-start>Start</button><button type="button" class="secondary" data-pause disabled>Pause</button><button type="button" class="secondary" data-reset>Reset</button></div><p class="ct-status" data-duration-status role="status"></p></section><section class="ct-timer" data-duration-timer><h2>Set</h2><label>Minutes<input data-minutes type="number" min="1" max="180" step="1" value="5"></label><p class="ct-duration" data-duration role="timer" aria-live="off">00:00:00</p><div class="ct-actions flex flex-wrap"><button type="button" data-start>Start</button><button type="button" class="secondary" data-pause disabled>Pause</button><button type="button" class="secondary" data-reset>Reset</button></div><p class="ct-status" data-duration-status role="status"></p></section></div><p class="ct-note">The timers run independently and announce completion once. They make no sound and do not provide food-safety guidance.</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
Three independent duration timers share a small reusable controller. Each has its own remaining time, controls and completion message; pausing or resetting one does not change the others.
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.