Countdown

A data-attribute-driven countdown timer with configurable timezone offset, per-unit display, four label formats (plain, short, long, abbr), and automatic status states (active, finished, error). Seconds trigger per-second ticking; omitting them saves to a once-per-minute refresh cycle.

countdowntimerdatejavascriptdata-attributes

Code

index.html
html
<div data-countdown-timezone-offset="2" data-countdown-date="2030-08-29 13:36" data-countdown-status="active" data-countdown-format="long">
  <p data-countdown-update="days">Days</p>
  <p data-countdown-update="hours">Hours</p>
  <p data-countdown-update="minutes">Minutes</p>
  <p data-countdown-update="seconds">Seconds</p>
</div>
script.js
javascript
function initCountdown(){
  var reg={items:[],timer:null};

  function parseIso(root){
    var s=root.getAttribute('data-countdown-date')||'';
    var m=s.trim().match(/^(\d{4})-(\d{2})-(\d{2})\s(\d{1,2}):(\d{2})$/);
    if(!m) return null;
    var y=+m[1],mo=+m[2]-1,d=+m[3],h=+m[4],mi=+m[5];
    var t=Date.UTC(y,mo,d,h,mi,0,0);
    var off=+(root.getAttribute('data-countdown-timezone-offset')||0);
    if(off) t-=off*3600000;
    var dt=new Date(t);
    if(dt.getUTCFullYear()!==y||dt.getUTCMonth()!==mo||dt.getUTCDate()!==d) return null;
    return t;
  }

  function splitByUnits(ms,u){
    var secs=Math.max(0,Math.floor(ms/1000));
    var out={years:0,months:0,weeks:0,days:0,hours:0,minutes:0,seconds:0,done:ms<=0};
    var seq=[['years',31536000],['months',2592000],['weeks',604800],['days',86400],['hours',3600],['minutes',60],['seconds',1]];
    for(var i=0;i<seq.length;i++){
      var k=seq[i][0],len=seq[i][1];
      if(u[k]){ out[k]=Math.floor(secs/len); secs%=len; }
    }
    return out;
  }

  var sing={years:'year',months:'month',weeks:'week',days:'day',hours:'hour',minutes:'minute',seconds:'second'};
  var abbr={years:['yr.','yrs.'],months:['mo.','mos.'],weeks:['wk.','wks.'],days:['day','days'],hours:['hr.','hrs.'],minutes:['min.','mins.'],seconds:['sec.','secs.']};
  function lab(k,v,f){
    if(f==='plain') return ''+v;
    if(f==='short') return v+(k==='months'?'mo':k[0]);
    if(f==='abbr'){ var a=abbr[k]; return v+' '+a[v===1?0:1]; }
    return v+' '+(v===1?sing[k]:k);
  }

  function make(root){
    var u={}, order=['years','months','weeks','days','hours','minutes','seconds'];
    root.querySelectorAll('[data-countdown-update]').forEach(function(n){
      var k=(n.getAttribute('data-countdown-update')||'').toLowerCase();
      if(order.indexOf(k)>-1) u[k]=n;
    });
    var tgt=parseIso(root);
    if(tgt==null){
      root.setAttribute('data-countdown-status','error');
      var first=null; for(var i=0;i<order.length;i++){ if(u[order[i]]){ first=u[order[i]]; break; } }
      if(first) first.textContent='Invalid Date, use: 2026-03-21 11:36';
      order.forEach(function(k){ var n=u[k]; if(n&&n!==first) n.textContent=''; });
      return null;
    }
    var f=(root.getAttribute('data-countdown-format')||'plain').toLowerCase();

    var inst={
      root:root,tgt:tgt,f:f,u:u,st:null,done:false,
      render:function(ms){
        var d=splitByUnits(ms,this.u);
        this.done=d.done;
        this.root.setAttribute('data-countdown-status', d.done?'finished':'active');
        if(this.u.years)   this.u.years.textContent   = lab('years',d.years,this.f);
        if(this.u.months)  this.u.months.textContent  = lab('months',d.months,this.f);
        if(this.u.weeks)   this.u.weeks.textContent   = lab('weeks',d.weeks,this.f);
        if(this.u.days)    this.u.days.textContent    = lab('days',d.days,this.f);
        if(this.u.hours)   this.u.hours.textContent   = lab('hours',d.hours,this.f);
        if(this.u.minutes) this.u.minutes.textContent = lab('minutes',d.minutes,this.f);
        if(this.u.seconds) this.u.seconds.textContent = lab('seconds',d.seconds,this.f);
      },
      tickMin:function(nowMs){
        if(this.done) return;
        this.render(this.tgt-nowMs);
        if(this.u.seconds && !this.done && !this.st) this.startSec();
        if(this.done) this.stopSec();
      },
      startSec:function(){
        var self=this;
        function t(){
          if(self.done) return self.stopSec();
          var ms=self.tgt-Date.now();
          if(ms<=0){ self.render(0); return self.stopSec(); }
          self.render(ms);
        }
        t(); self.st=setInterval(t,1000);
      },
      stopSec:function(){ if(this.st){ clearInterval(this.st); this.st=null; } }
    };
    root.__cd=inst;
    return inst;
  }

  function startMinTimer(){
    if(reg.timer) return;
    reg.timer=setInterval(function(){
      var now=Date.now();
      for(var i=0;i<reg.items.length;i++) reg.items[i].tickMin(now);
    },60000);
    var now=Date.now();
    for(var j=0;j<reg.items.length;j++) reg.items[j].tickMin(now);
  }

  document.querySelectorAll('[data-countdown-date]').forEach(function(root){
    var inst=make(root);
    if(inst) reg.items.push(inst);
  });
  if(reg.items.length) startMinTimer();
}

// Initialize Countdown
document.addEventListener('DOMContentLoaded', () => {
  initCountdown();
});

Guide

Container

Wrap the countdown in a parent element with data-countdown-date="YYYY-MM-DD H:mm" to set the target end date. The script attaches data-countdown-status to this container to indicate its state (active, finished, or error).

Date

Provide the target moment using data-countdown-date="YYYY-MM-DD H:mm" in 24-hour time, interpreted as UTC unless combined with a timezone offset.

Timezone Offset

Add data-countdown-timezone-offset="2" to interpret the input date as local wall time in a specific timezone, shifting the stored UTC instant by the given number of hours. Use positive values for UTC+ zones (e.g. 2 for Amsterdam CEST) and negative for UTC- zones (e.g. -5 for New York).

Status

The container receives data-countdown-status which automatically updates to active while counting, finished once expired, and error if the input date is invalid. Use it to drive CSS states — for example showing hidden content with [data-countdown-status="finished"].

Units

Attach data-countdown-update to child elements to output each unit individually. Supported values: years, months, weeks, days, hours, minutes, seconds. Only the units you include are displayed, and higher-order values roll down into the next available unit. Including seconds triggers per-second updates; omitting it limits refreshes to once per minute.

Format

Control the label style on the container with data-countdown-format. Use plain for numbers only (5, 7). Use short for compact labels (5h, 7mo). Use long for full labels with pluralization (1 hour, 5 hours). Use abbr for newsroom-style dotted abbreviations (9 yrs., 4 mos., 1 hr., 30 mins.).

Error Handling

When the date string cannot be parsed, data-countdown-status="error" is applied and the first available data-countdown-update element shows "Invalid Date, use: 2026-03-21 11:36", while all other units are cleared.