Feedback
Countdown
Time left until a moment, such as a launch, an offer ending or a rate limit resetting.
- Built on
<time datetime>role="status"visibilitychange@starting-style- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 2.9 kB
- Dependencies
- React
Try its options
Loading its options.
Code
"use client";
import { useEffect, useState } from "react";
import { Countdown } from "@/components/equip/countdown";
export default function Demo() {
const [launch, setLaunch] = useState<number | null>(null);
useEffect(() => {
setLaunch(Date.now() + ((2 * 24 + 4) * 60 + 12) * 60_000 + 9_000);
}, []);
return (
<div style={{ display: "grid", gap: 8, justifyItems: "center" }}>
<span aria-hidden="true" style={{ font: "var(--eq-weight-strong) var(--eq-text-s) / 1 var(--eq-font)", color: "var(--eq-ink-2)" }}>
Public beta opens in
</span>
<Countdown to={launch} label="Public beta opens in" />
</div>
);
}Your versionchange its options in Look, This part
Examples
Compact, for a rate limit
Compact is one mono line that fits inside a sentence or a table cell; leading zero units drop away as time passes.
API limit reached: 5,000 of 5,000 requests used. Resets in
Code
"use client";
import { useEffect, useState } from "react";
import { Countdown } from "@/components/equip/countdown";
export default function Example() {
const [reset, setReset] = useState<number | null>(null);
useEffect(() => {
setReset(Date.now() + 4 * 60_000 + 12_000);
}, []);
return (
<p style={{ margin: 0, font: "var(--eq-text-s) / 1.5 var(--eq-font)", color: "var(--eq-ink-2)" }}>
API limit reached: 5,000 of 5,000 requests used. Resets in{" "}
<Countdown to={reset} label="Rate limit resets in" units={["minutes", "seconds"]} compact />
</p>
);
}Hours and minutes only
With seconds left out the minutes round up, so the offer never reads 0 minutes while it is still open.
Code
"use client";
import { useEffect, useState } from "react";
import { Countdown } from "@/components/equip/countdown";
export default function Example() {
const [end, setEnd] = useState<Date | null>(null);
useEffect(() => {
const midnight = new Date();
midnight.setHours(24, 0, 0, 0);
setEnd(midnight);
}, []);
return (
<div style={{ display: "grid", gap: 8 }}>
<strong style={{ font: "var(--eq-weight-strong) var(--eq-text-m) / 1.3 var(--eq-font)" }}>
Annual plan at 20% off ends tonight
</strong>
<Countdown to={end} label="Offer ends in" units={["hours", "minutes"]} doneText="Offer ended" />
</div>
);
}Waiting to resend a code
onDone fires once at zero. Screen readers hear the time at 30 and 10 seconds rather than every tick.
Code
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/components/equip/button";
import { Countdown } from "@/components/equip/countdown";
export default function Example() {
const [until, setUntil] = useState<number | null>(null);
const [ready, setReady] = useState(false);
useEffect(() => {
setUntil(Date.now() + 45_000);
}, []);
function resend() {
setReady(false);
setUntil(Date.now() + 45_000);
}
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, font: "var(--eq-text-s) / 1.4 var(--eq-font)" }}>
<Button variant="outline" size="s" disabled={!ready} onClick={resend}>
Resend code
</Button>
{!ready && (
<span style={{ color: "var(--eq-ink-2)" }}>
available in{" "}
<Countdown to={until} label="Resend available in" units={["seconds"]} compact onDone={() => setReady(true)} />
</span>
)}
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
to | number | string | Date | null | none | The moment to count to: epoch ms, a date string or a Date. null or an invalid date shows the placeholder. |
label | string | none | What is being counted down to, such as "Public beta opens in". Read before the time. |
units | ("days" | "hours" | "minutes" | "seconds")[] | all four | Units to show. The largest absorbs overflow, the smallest rounds up, and leading zero units drop away. |
onDone | () => void | none | Called once per target at zero, including a target that is already past when it mounts. |
doneText | string | "Ended" | Shown in place of the time at zero. |
compact | boolean | false | One mono line, such as 2d 04h 12m, instead of boxes with captions. |
...rest | ComponentProps<"span"> | none | Native attributes for the root, a span so it can sit inside a sentence. |
Anatomy
| root | .eq-countdown | data-done is set at zero. |
|---|---|---|
| time | .eq-countdown-time | A <time> whose datetime is the remaining ISO 8601 duration. |
| text | .eq-countdown-text | Visually hidden label and full time in words, read on demand, never live. |
| units | .eq-countdown-units | |
| unit | .eq-countdown-unit | One box: value and caption. |
| value | .eq-countdown-value | |
| digits | .eq-countdown-digits | Keyed on its value, so each change rolls in with the spring. |
| caption | .eq-countdown-caption | |
| compact | .eq-countdown-compact | |
| done | .eq-countdown-done | |
| live | .eq-countdown-live | Visually hidden polite status for the rare announcements. |
Notes
- The server render is a stable placeholder of -- in each unit; the clock starts in an effect, so there is no hydration mismatch.
- It ticks on each second boundary, stops while the tab is hidden and recomputes from the clock when it comes back.
- The digits and boxes are hidden from screen readers; the hidden text gives the full time in words whenever someone reads it.
- Announcements are polite: once a minute in the last ten minutes, then at 30 and 10 seconds, and once at zero; nothing is said on mount.
- It counts against the device clock, so a wrong system time shows a wrong countdown; send the target from the server.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/countdown.tsx184 lines
"use client";
import { useEffect, useRef, useState, type ComponentProps } from "react";
import "./countdown.css";
type Unit = "days" | "hours" | "minutes" | "seconds";
const ORDER: Unit[] = ["days", "hours", "minutes", "seconds"];
const SIZE: Record<Unit, number> = { days: 86400, hours: 3600, minutes: 60, seconds: 1 };
const CAPTION: Record<Unit, string> = { days: "days", hours: "hours", minutes: "mins", seconds: "secs" };
const SHORT: Record<Unit, string> = { days: "d", hours: "h", minutes: "m", seconds: "s" };
const WORD: Record<Unit, string> = { days: "day", hours: "hour", minutes: "minute", seconds: "second" };
const MAX_DELAY = 2 ** 31 - 1;
export type CountdownProps = Omit<ComponentProps<"span">, "children"> & {
/** The moment to count to: epoch ms, a date string or a Date. null shows the placeholder until you know it. */
to: number | string | Date | null;
/** What is being counted down to, e.g. "Public beta opens in". Read before the time. */
label: string;
/** Units to show, largest first. The largest absorbs overflow and the smallest rounds up. */
units?: Unit[];
/** Called once per target when the time reaches zero, including a target already past. */
onDone?: () => void;
/** Shown in place of the time at zero. */
doneText?: string;
/** One mono line, "2d 4h 12m", instead of boxes with captions. */
compact?: boolean;
};
function toMs(to: CountdownProps["to"]) {
if (to == null) return Number.NaN;
if (to instanceof Date) return to.getTime();
return typeof to === "number" ? to : Date.parse(to);
}
function split(total: number, units: Unit[]) {
const list = ORDER.filter((u) => units.includes(u));
if (list.length === 0) return [];
let rest = Math.ceil(total / SIZE[list[list.length - 1]]) * SIZE[list[list.length - 1]];
const parts = list.map((u) => {
const value = Math.floor(rest / SIZE[u]);
rest -= value * SIZE[u];
return { unit: u, value };
});
let first = 0;
while (first < parts.length - 1 && parts[first].value === 0) first++;
return parts.slice(first);
}
function spell(parts: { unit: Unit; value: number }[]) {
return parts
.filter((p) => p.value > 0)
.map((p) => `${p.value} ${WORD[p.unit]}${p.value === 1 ? "" : "s"}`)
.join(" ");
}
function duration(total: number) {
const d = Math.floor(total / 86400);
const h = Math.floor((total % 86400) / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return `P${d ? `${d}D` : ""}T${h}H${m}M${s}S`;
}
export function Countdown({
to,
label,
units = ORDER,
onDone,
doneText = "Ended",
compact = false,
className,
...rest
}: CountdownProps) {
const target = toMs(to);
const [now, setNow] = useState<number | null>(null);
const [announcement, setAnnouncement] = useState("");
const onDoneRef = useRef(onDone);
const doneFor = useRef<number | null>(null);
const previous = useRef<number | null>(null);
useEffect(() => {
onDoneRef.current = onDone;
});
useEffect(() => {
if (!Number.isFinite(target)) {
setNow(null);
return;
}
let timer = 0;
function tick() {
window.clearTimeout(timer);
const t = Date.now();
setNow(t);
const ms = target - t;
if (ms <= 0 || document.visibilityState === "hidden") return;
timer = window.setTimeout(tick, Math.min(ms % 1000 || 1000, MAX_DELAY));
}
function onVisibility() {
if (document.visibilityState === "visible") tick();
else window.clearTimeout(timer);
}
tick();
document.addEventListener("visibilitychange", onVisibility);
return () => {
window.clearTimeout(timer);
document.removeEventListener("visibilitychange", onVisibility);
};
}, [target]);
const remaining =
now != null && Number.isFinite(target) ? Math.max(0, Math.ceil((target - now) / 1000)) : null;
const parts = remaining != null ? split(remaining, units) : null;
const done = remaining === 0;
const spoken = done ? doneText : parts ? spell(parts) : "";
useEffect(() => {
if (!done || doneFor.current === target) return;
doneFor.current = target;
onDoneRef.current?.();
}, [done, target]);
useEffect(() => {
const prev = previous.current;
previous.current = remaining;
if (remaining == null || prev == null || remaining >= prev) return;
// Polite and rare: each minute down to one minute, then 30 and 10 seconds, then done.
// Minute announcements only in the last ten minutes: a two-day countdown must not speak every minute.
const minuteMark = remaining >= 60 && remaining <= 600 && Math.ceil(remaining / 60) < Math.ceil(prev / 60);
const secondMark = [30, 10].some((mark) => prev > mark && remaining <= mark);
if (remaining === 0) setAnnouncement(`${label} ${doneText}`);
else if (minuteMark || secondMark) setAnnouncement(`${spoken} left`);
}, [remaining, label, doneText, spoken]);
const slots = parts ?? ORDER.filter((u) => units.includes(u)).map((unit) => ({ unit, value: null }));
return (
<span
{...rest}
className={["eq-countdown", className].filter(Boolean).join(" ")}
data-compact={compact || undefined}
data-done={done || undefined}
>
<time className="eq-countdown-time" dateTime={remaining != null ? duration(remaining) : undefined}>
<span className="eq-countdown-text">
{label} {spoken}
</span>
{done ? (
<span className="eq-countdown-done" aria-hidden="true">
{doneText}
</span>
) : compact ? (
<span className="eq-countdown-compact" aria-hidden="true">
{slots.map(({ unit, value }, i) => (
<span key={unit} className="eq-countdown-value">
<span key={value ?? "-"} className="eq-countdown-digits">
{value == null ? "--" : i === 0 ? value : String(value).padStart(2, "0")}
{SHORT[unit]}
</span>
</span>
))}
</span>
) : (
<span className="eq-countdown-units" aria-hidden="true">
{slots.map(({ unit, value }) => (
<span key={unit} className="eq-countdown-unit">
<span className="eq-countdown-value">
<span key={value ?? "-"} className="eq-countdown-digits">
{value == null ? "--" : unit === "days" ? value : String(value).padStart(2, "0")}
</span>
</span>
<span className="eq-countdown-caption">{CAPTION[unit]}</span>
</span>
))}
</span>
)}
</time>
<span role="status" className="eq-countdown-live">
{announcement}
</span>
</span>
);
}components/equip/countdown.css84 lines
@layer equip {
.eq-countdown {
display: inline-block;
color: var(--eq-ink);
font: var(--eq-weight) var(--eq-text-m) / 1 var(--eq-font);
}
.eq-countdown-time {
display: block;
}
.eq-countdown-text,
.eq-countdown-live {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.eq-countdown-units {
display: flex;
gap: var(--eq-space-2);
}
.eq-countdown-unit {
display: grid;
justify-items: center;
gap: var(--eq-space-1);
min-width: 44px;
padding: var(--eq-space-2) var(--eq-space-1);
border: 1px solid var(--eq-line);
border-radius: var(--eq-radius-m);
background: var(--eq-raised);
}
.eq-countdown-value {
display: inline-block;
overflow: clip;
font-family: var(--eq-font-mono);
font-variation-settings: var(--eq-font-mono-settings);
font-variant-numeric: tabular-nums;
font-weight: var(--eq-weight-strong);
}
.eq-countdown-unit .eq-countdown-value {
font-size: var(--eq-text-l);
line-height: 1.2;
}
/* A changed value is a new element, so it rolls in from just above. */
.eq-countdown-digits {
display: inline-block;
translate: 0 0;
opacity: 1;
transition:
translate var(--eq-dur-move) var(--eq-spring),
opacity var(--eq-dur) var(--eq-ease);
}
@starting-style {
.eq-countdown-digits {
translate: 0 -0.35em;
opacity: 0;
}
}
.eq-countdown-caption {
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
}
.eq-countdown-compact {
display: inline-flex;
gap: 0.5ch;
}
.eq-countdown-done {
display: inline-block;
font-weight: var(--eq-weight-strong);
color: var(--eq-ink-2);
}
}