Countdown Timer
Countdown and SLA timer for deadlines, escalations, and response windows. Install Countdown Timer from the VLLNT UI registry with the shadcn CLI.
Countdown and SLA timer for deadlines, escalations, and response windows. Part of the data family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Countdown Timer to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/countdown-timer.jsonSource
"use client";
import * as React from "react";
import { normalizeDate } from "../../lib/format";
import { useLiveDate } from "../../lib/use-live-date";
import { cn } from "../../lib/utils";
import { Badge } from "../badge/badge";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../card/card";
export type CountdownTimerProps = React.ComponentPropsWithoutRef<"div"> & {
deadline: Date | number | string;
description?: string;
now?: Date | number | string;
startedAt?: Date | number | string;
tickMs?: number;
title?: string;
warningThresholdMs?: number;
};
type TimerSegment = {
label: string;
value: string;
};
function getRemainingMs(deadline: Date, now: Date): number {
return deadline.getTime() - now.getTime();
}
function getStatus(
remainingMs: number,
warningThresholdMs: number,
): {
badgeVariant: "default" | "destructive" | "secondary";
label: string;
toneClassName: string;
} {
if (remainingMs <= 0) {
return {
badgeVariant: "destructive",
label: "Breached",
toneClassName: "bg-destructive",
};
}
if (remainingMs <= warningThresholdMs) {
return {
badgeVariant: "secondary",
label: "At risk",
toneClassName: "bg-amber-500",
};
}
return {
badgeVariant: "default",
label: "On track",
toneClassName: "bg-emerald-500",
};
}
function formatSegments(milliseconds: number): TimerSegment[] {
const totalSeconds = Math.max(0, Math.floor(milliseconds / 1000));
const days = Math.floor(totalSeconds / 86_400);
const hours = Math.floor((totalSeconds % 86_400) / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return [
{ label: "Days", value: String(days).padStart(2, "0") },
{ label: "Hours", value: String(hours).padStart(2, "0") },
{ label: "Minutes", value: String(minutes).padStart(2, "0") },
{ label: "Seconds", value: String(seconds).padStart(2, "0") },
];
}
const DEADLINE_FORMATTER = new Intl.DateTimeFormat("en-US", {
day: "numeric",
hour: "numeric",
minute: "2-digit",
month: "short",
timeZoneName: "short",
});
function formatDeadline(date: Date): string {
return DEADLINE_FORMATTER.format(date);
}
function getProgress(deadline: Date, now: Date, startedAt?: Date): number {
if (!startedAt) {
return 0;
}
const total = deadline.getTime() - startedAt.getTime();
if (total <= 0) {
return 100;
}
const elapsed = now.getTime() - startedAt.getTime();
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
function TimerSegments({ segments }: { segments: TimerSegment[] }) {
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{segments.map((segment) => (
<div
className="rounded-lg border bg-background/80 px-3 py-4 text-center"
key={segment.label}
>
<div className="text-2xl font-semibold tracking-tight">
{segment.value}
</div>
<div className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{segment.label}
</div>
</div>
))}
</div>
);
}
function TimerProgress({
progress,
remainingMs,
segments,
startedAt,
toneClassName,
}: {
progress: number;
remainingMs: number;
segments: TimerSegment[];
startedAt?: Date;
toneClassName: string;
}) {
const progressWidth = startedAt ? progress : remainingMs <= 0 ? 100 : 0;
const statusText = startedAt
? `${Math.round(progress)}% used`
: segments.map((segment) => segment.value).join(":");
return (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{startedAt ? "SLA elapsed" : "Time remaining"}</span>
<span>{statusText}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full transition-all", toneClassName)}
style={{ width: `${progressWidth}%` }}
/>
</div>
</div>
);
}
export const CountdownTimer = ({
className,
deadline,
description,
now,
ref,
startedAt,
tickMs = 1000,
title = "Countdown timer",
warningThresholdMs = 15 * 60 * 1000,
...props
}: CountdownTimerProps & { ref?: React.Ref<HTMLDivElement> }) => {
const deadlineDate = React.useMemo(() => normalizeDate(deadline), [deadline]);
const startedAtDate = React.useMemo(
() => (startedAt ? normalizeDate(startedAt) : undefined),
[startedAt],
);
const liveNow = useLiveDate(now, tickMs);
const remainingMs = getRemainingMs(deadlineDate, liveNow);
const status = getStatus(remainingMs, warningThresholdMs);
const segments = formatSegments(Math.abs(remainingMs));
const progress = getProgress(deadlineDate, liveNow, startedAtDate);
return (
<Card className={cn("shadow-sm", className)} ref={ref} {...props}>
<CardHeader className="space-y-2 pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>
{description ?? `Deadline ${formatDeadline(deadlineDate)}`}
</CardDescription>
</div>
<Badge variant={status.badgeVariant}>{status.label}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<TimerSegments segments={segments} />
<TimerProgress
progress={progress}
remainingMs={remainingMs}
segments={segments}
startedAt={startedAtDate}
toneClassName={status.toneClassName}
/>
</CardContent>
</Card>
);
};
CountdownTimer.displayName = "CountdownTimer";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.