Countdown Timer

Countdown and SLA timer for deadlines, escalations, and response windows. Install Countdown Timer from the VLLNT UI registry with the shadcn CLI.

Report a bug

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.json

Source

"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.

Dependencies

  • @vllnt/ui@^0.3.0