A WordPress countdown timer for a launch, a sale or an event usually comes from a heavy plugin. This shortcode prints days, hours, minutes and seconds until any date, counts down live in the browser and swaps in your own message when the time is up.
The older version of this post hard-coded the date in JavaScript, used fixed element IDs so only one timer could work per page, and threw errors on every page without the timer. Now the date is a shortcode attribute in your site’s timezone, and you can place as many timers on a page as you like:
[dpc_countdown date="2026-12-31 18:00"][dpc_countdown date="2026-11-27 09:00" expired="Black Friday is over."]
The snippet
<?php
/**
* [dpc_countdown date="2026-12-31 18:00" expired="The offer has ended."]
* Days, hours, minutes and seconds until a date in the site's timezone
* (Settings → General). Styled with Tailwind classes; readable without them.
*/
add_shortcode( 'dpc_countdown', 'dpc_countdown_shortcode' );
function dpc_countdown_shortcode( $atts ): string {
$atts = shortcode_atts(
array(
'date' => '',
'expired' => __( 'This countdown has ended.', 'dpc' ),
),
$atts,
'dpc_countdown'
);
$end = '' !== trim( $atts['date'] ) ? date_create_immutable( $atts['date'], wp_timezone() ) : false;
if ( ! $end ) {
// Only people who can edit content see why nothing rendered.
return current_user_can( 'edit_posts' )
? '<p class="dpc-countdown-error">' . esc_html__( 'Countdown: set date="YYYY-MM-DD HH:MM".', 'dpc' ) . '</p>'
: '';
}
$left = $end->getTimestamp() - time();
if ( $left <= 0 ) {
return '<p class="dpc-countdown dpc-countdown--expired">' . esc_html( $atts['expired'] ) . '</p>';
}
dpc_countdown_script();
// Start values from the server, so the timer is correct before the script runs.
$units = array(
'days' => array( __( 'Days', 'dpc' ), intdiv( $left, DAY_IN_SECONDS ) ),
'hours' => array( __( 'Hours', 'dpc' ), intdiv( $left % DAY_IN_SECONDS, HOUR_IN_SECONDS ) ),
'minutes' => array( __( 'Min.', 'dpc' ), intdiv( $left % HOUR_IN_SECONDS, MINUTE_IN_SECONDS ) ),
'seconds' => array( __( 'Sec.', 'dpc' ), $left % MINUTE_IN_SECONDS ),
);
$boxes = '';
foreach ( $units as $unit => list( $label, $value ) ) {
$boxes .= sprintf(
'<div class="dpc-countdown__unit flex flex-col items-center bg-amber-200 p-4"><span class="dpc-countdown__value text-5xl" data-unit="%1$s">%2$s</span> <span class="dpc-countdown__label">%3$s</span></div>',
esc_attr( $unit ),
esc_html( 'days' === $unit ? (string) $value : sprintf( '%02d', $value ) ),
esc_html( $label )
);
}
return sprintf(
'<div class="dpc-countdown flex flex-wrap gap-4 justify-center items-center" role="timer" aria-label="%1$s" data-dpc-countdown="%2$d" data-dpc-expired="%3$s">%4$s</div>',
/* translators: %s: end date and time. */
esc_attr( sprintf( __( 'Countdown to %s', 'dpc' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $end->getTimestamp() ) ) ),
$end->getTimestamp(),
esc_attr( $atts['expired'] ),
$boxes
);
}
/** One footer script per page, and only on pages that show a countdown. */
function dpc_countdown_script(): void {
if ( wp_script_is( 'dpc-countdown', 'enqueued' ) ) {
return;
}
$js = <<<'JS'
(function () {
function pad(n) {
return n < 10 ? '0' + n : String(n);
}
function start(box) {
var end = parseInt(box.getAttribute('data-dpc-countdown'), 10) * 1000;
var fields = {};
box.querySelectorAll('[data-unit]').forEach(function (el) {
fields[el.getAttribute('data-unit')] = el;
});
var timer;
function tick() {
var left = Math.floor((end - Date.now()) / 1000);
if (isNaN(left) || left <= 0) {
clearInterval(timer);
var done = document.createElement('p');
done.className = 'dpc-countdown dpc-countdown--expired';
done.textContent = box.getAttribute('data-dpc-expired') || '';
box.replaceWith(done);
return;
}
fields.days.textContent = Math.floor(left / 86400);
fields.hours.textContent = pad(Math.floor((left % 86400) / 3600));
fields.minutes.textContent = pad(Math.floor((left % 3600) / 60));
fields.seconds.textContent = pad(left % 60);
}
tick();
timer = setInterval(tick, 1000);
}
function init() {
document.querySelectorAll('[data-dpc-countdown]').forEach(start);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
JS;
wp_register_script( 'dpc-countdown', false, array(), null, array( 'in_footer' => true ) );
wp_add_inline_script( 'dpc-countdown', $js );
wp_enqueue_script( 'dpc-countdown' );
}
Add it with Scripts Organizer
In Scripts Organizer go to Code Blocks → Add New and give the block a name. Set Trigger location to Everywhere, tick PHP under Script location, paste the code into the PHP editor and publish. Leave Action/Hook empty. Then put [dpc_countdown date="…"] in a Shortcode block, or in any page builder’s shortcode element.
It is a normal add_shortcode() rather than a block with the Shortcode script location because every timer needs its own date and message, and a Shortcode-location block cannot read attributes. The script is printed by the shortcode itself, once per page and only where a timer is shown, so there is no separate Footer part to set conditions for. The same code works unchanged in a small plugin or an mu-plugin.
The look comes from Tailwind CSS classes (flex, gap-4, bg-amber-200, p-4, text-5xl), compiled for example by Winden. Tailwind is only needed for that styling: without it the timer still counts down and reads as four plain lines such as “2 Days”, “03 Hours”.
How it works
date_create_immutable()reads thedateattribute in the timezone from Settings → General, so18:00means 18:00 on your site, not on the visitor’s computer. An invalid date renders nothing for visitors and a short notice for editors.- The shortcode calculates the remaining time on the server and prints it into the markup. The timer is right on first paint, and still shows sensible numbers without JavaScript.
- The end time travels as a Unix timestamp in
data-dpc-countdown. The script starts one interval per timer, writes the numbers withtextContent, and replaces the timer with theexpiredtext when it reaches zero. - A date in the past prints only the escaped
expiredmessage. All attributes are escaped, androle="timer"with anaria-labeltells screen readers what the numbers are without announcing every second. - The script is registered as a handle without a file and added once in the footer, however many timers the page has.
Customise it
- Colours and sizes: change the Tailwind classes in the two
sprintf()templates, for examplebg-amber-200tobg-violet-100ortext-5xltotext-4xl md:text-6xl. - Labels: edit
Days,Hours,Min.andSec.in$units; they are translatable with thedpctext domain. - Hide the days box for short timers: remove the
daysrow and add its value to the hours.
Gotchas
- Full-page caching stores the start values. The script corrects them as soon as it runs, so a cached page may show a jump of a few seconds or minutes.
- After the end date a cached page keeps showing the timer until the cache is cleared; the script still swaps in the expired message.
- Tailwind compilers only generate classes they can find. If the boxes come out unstyled, add the classes to your Tailwind safelist.
In the Scripts Organizer library Import it with one click from Scripts Organizer.