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.

Annual plan at 20% off ends tonight
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.

available in
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

PropTypeDefaultWhat it does
tonumber | string | Date | nullnoneThe moment to count to: epoch ms, a date string or a Date. null or an invalid date shows the placeholder.
labelstringnoneWhat is being counted down to, such as "Public beta opens in". Read before the time.
units("days" | "hours" | "minutes" | "seconds")[]all fourUnits to show. The largest absorbs overflow, the smallest rounds up, and leading zero units drop away.
onDone() => voidnoneCalled once per target at zero, including a target that is already past when it mounts.
doneTextstring"Ended"Shown in place of the time at zero.
compactbooleanfalseOne mono line, such as 2d 04h 12m, instead of boxes with captions.
...restComponentProps<"span">noneNative attributes for the root, a span so it can sit inside a sentence.

Anatomy

root.eq-countdowndata-done is set at zero.
time.eq-countdown-timeA <time> whose datetime is the remaining ISO 8601 duration.
text.eq-countdown-textVisually hidden label and full time in words, read on demand, never live.
units.eq-countdown-units
unit.eq-countdown-unitOne box: value and caption.
value.eq-countdown-value
digits.eq-countdown-digitsKeyed 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-liveVisually 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);
  }
}

EqUIp is in beta: free for non-commercial use, no warranty, and things can change. Terms

Nothing picked yet. Press A on a part, or use the plus on the board, and the install command builds here.

Loading the index.

Loading the index