Countdown Timer
Days, hours, minutes and seconds to a deadline; each digit rolls down independently as it changes.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/countdown-timer.json'use client'
import { useEffect, useState } from 'react'
import './countdown-timer.css'
export interface CountdownTimerProps {
to: Date | number
labels?: 'short' | 'long'
showDays?: boolean
caption?: string
endedText?: string
fill?: 'dark' | 'light'
className?: string
}
function Digit({ c }: { c: string }) {
return <span className="sd-cdt__d"><span key={c} className="in">{c}</span></span>
}
/** Rolling-digit countdown. */
export function CountdownTimer({ to, labels = 'short', showDays = true, caption = 'Launches in', endedText = 'It’s live', fill = 'dark', className }: CountdownTimerProps) {
const target = typeof to === 'number' ? to : to.getTime()
const [now, setNow] = useState<number | null>(null)
useEffect(() => { setNow(Date.now()); const t = setInterval(() => setNow(Date.now()), 1000); return () => clearInterval(t) }, [])
const left = Math.max(0, Math.floor((target - (now ?? target - 1)) / 1000))
const d = Math.floor(left / 86400), h = Math.floor(left / 3600) % 24, m = Math.floor(left / 60) % 60, s = left % 60
const units: [number, string, string][] = [[showDays ? d : d * 24 + h, 'd', 'days'], [h, 'h', 'hours'], [m, 'm', 'min'], [s, 's', 'sec']]
const shown = showDays ? units : units.slice(1).map((u, i) => (i === 0 ? [d * 24 + h, u[1], u[2]] as [number, string, string] : u))
const cls = ['sd-cdt', className].filter(Boolean).join(' ')
if (now !== null && left === 0) return <div className={cls} data-fill={fill}><div className="sd-cdt__end" role="status">{endedText}</div></div>
return (
<div className={cls} data-fill={fill}>
<div className="sd-cdt__cap">{caption}</div>
<div className="sd-cdt__row" aria-hidden>
{shown.map(([n, sh, lg], i) => (
<div key={sh} style={{ display: 'contents' }}>
{i > 0 && <span className="sd-cdt__sep">:</span>}
<div className="sd-cdt__unit">
<div className="sd-cdt__digits">{String(n).padStart(2, '0').split('').map((c, j) => <Digit key={j} c={c} />)}</div>
<span className="sd-cdt__lab">{labels === 'long' ? lg : sh}</span>
</div>
</div>
))}
</div>
<span className="sr" aria-live="polite">{`${caption} ${d} days ${h} hours ${m} minutes`}</span>
</div>
)
}.sd-cdt { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-cdt[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-cdt button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-cdt :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-cdt .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-cdt { display: grid; justify-items: center; gap: 14px; }
.sd-cdt__cap { font-size: 12.5px; color: var(--mute); }
.sd-cdt__row { display: flex; align-items: flex-start; gap: 6px; }
.sd-cdt__unit { display: grid; justify-items: center; gap: 7px; }
.sd-cdt__digits { display: flex; gap: 3px; }
.sd-cdt__d { position: relative; width: 30px; height: 46px; border-radius: 9px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); overflow: hidden; font: 600 26px/46px ui-sans-serif, system-ui; text-align: center; font-variant-numeric: tabular-nums; }
.sd-cdt__d span { position: absolute; inset: 0; }
.sd-cdt__d .in { animation: sd-cdt-in 420ms cubic-bezier(0.3, 1.2, 0.4, 1); }
@keyframes sd-cdt-in { from { transform: translateY(-100%); } }
.sd-cdt__lab { font-size: 11px; color: var(--mute); text-transform: uppercase; letter-spacing: 0.08em; }
.sd-cdt__sep { font: 600 24px/46px ui-sans-serif, system-ui; color: var(--mute); }
.sd-cdt__end { font-size: 22px; font-weight: 650; animation: sd-cdt-pop 400ms cubic-bezier(0.3, 1.6, 0.4, 1); }
@keyframes sd-cdt-pop { from { transform: scale(0.8); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .sd-cdt *, .sd-cdt *::before, .sd-cdt *::after { animation: none !important; transition: none !important; } }Overview
Only digits that change move, each rolling down into the next value, so the seconds column ticks while the rest stay still.
At zero it swaps to an ended message. Use it for launches, sales and event starts.
When to use
- Launch pages, sales banners, event tickets, auctions.
When not to use
- Fake urgency: a countdown that resets on reload erodes trust.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/countdown-timer.json$
npx shadcn@latest add https://sundaeui.dev/r/countdown-timer.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/countdown-timer.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/countdown-timer.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy countdown-timer.tsx and countdown-timer.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { CountdownTimer } from '@/components/sundae/countdown-timer'
<CountdownTimer />Props
| Prop | Type | Default |
|---|---|---|
to*Target time. | Date | number | — |
labelsUnit labels. | 'short' | 'long' | 'short' |
endedTextShown at zero. | string | 'It’s live' |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The visible clock is aria-hidden; a visually hidden summary updates once a minute so screen readers are not flooded.
- Reduced motion swaps digits without rolling.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-cdt; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Countdown Timer free?
- Yes. Countdown Timer is free for personal and commercial projects, with full source on this page.
- How do I install Countdown Timer?
- Run npx shadcn@latest add https://sundaeui.dev/r/countdown-timer.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Countdown Timer support keyboard and screen readers?
- The visible clock is aria-hidden; a visually hidden summary updates once a minute so screen readers are not flooded. Reduced motion swaps digits without rolling.
- Does Countdown Timer respect reduced motion?
- Reduced motion swaps digits without rolling.
More like this
All data →Transit Departures
Data · RefinedStacked Bars
Data · Refined- Pro
Scrub Sparkline
Data · Refined - Pro
Tick Gauge
Data · Refined - Pro
Focus Timer
Components · Refined - Pro
Donut Breakdown
Data · Refined