A TLS certificate expiry monitor with per-domain countdown rings, urgency-sorted rows, threshold color bands, and hover detail tooltips.
npx shadcn@latest add @paragon/certificate-expiryAlso installs: number-ticker, tooltip
"use client";
import * as React from "react";
import { useInView, useReducedMotion } from "motion/react";
import { Lock, ShieldAlert } from "lucide-react";
import { cn } from "@/lib/utils";
import { NumberTicker } from "@/registry/paragon/ui/number-ticker";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
export interface Certificate {
id: string;
domain: string;
issuer: string;
/** Days until expiry (negative = already expired). */
daysLeft: number;
/** ISO or display date of expiry. */
expiresOn?: string;
}
export interface CertificateExpiryProps extends React.ComponentProps<"div"> {
certificates?: Certificate[];
/** Days-left threshold for the amber warning band. */
warnAt?: number;
/** Days-left threshold for the red critical band. */
criticalAt?: number;
/** Window (days) the ring represents as "full". */
maxWindow?: number;
/** Disables enter animation and ticker. */
static?: boolean;
}
const DEFAULT_CERTS: Certificate[] = [
{ id: "api", domain: "api.acme.com", issuer: "Let's Encrypt", daysLeft: 4, expiresOn: "Jul 10, 2026" },
{ id: "www", domain: "www.acme.com", issuer: "DigiCert", daysLeft: 21, expiresOn: "Jul 27, 2026" },
{ id: "wild", domain: "*.acme.dev", issuer: "Let's Encrypt", daysLeft: 68, expiresOn: "Sep 12, 2026" },
{ id: "mail", domain: "mail.acme.com", issuer: "Sectigo", daysLeft: -1, expiresOn: "Jul 5, 2026" },
{ id: "cdn", domain: "cdn.acme.com", issuer: "DigiCert", daysLeft: 143, expiresOn: "Nov 26, 2026" },
];
const RING = 18; // ring viewBox size
const RING_R = 7;
const RING_C = 2 * Math.PI * RING_R;
/**
* A TLS certificate expiry monitor. Each row carries a computed countdown ring
* whose stroke-dashoffset = C·(1 − daysLeft/maxWindow) with C = 2πr, colored by
* threshold (destructive < critical, warning < warn, else success — all via
* tokens). Rows are sorted by urgency (soonest first, expired pinned to top).
* Hover a row for issuer / expiry / days-left; the soonest countdown animates
* on a NumberTicker. Reduced motion drops the reveal and ticker.
*/
export function CertificateExpiry({
certificates = DEFAULT_CERTS,
warnAt = 30,
criticalAt = 7,
maxWindow = 90,
static: isStatic = false,
className,
...props
}: CertificateExpiryProps) {
const ref = React.useRef<HTMLDivElement>(null);
const reduced = useReducedMotion();
const inView = useInView(ref, { once: true, margin: "0px 0px -24px 0px" });
const animate = !isStatic && !reduced;
const armed = !animate || inView;
const bandOf = (days: number) =>
days <= criticalAt ? "critical" : days <= warnAt ? "warning" : "healthy";
const band = {
critical: {
stroke: "var(--color-destructive)",
text: "text-destructive",
dot: "bg-destructive",
},
warning: {
stroke: "var(--color-warning)",
text: "text-warning",
dot: "bg-warning",
},
healthy: {
stroke: "var(--color-success)",
text: "text-success",
dot: "bg-success",
},
} as const;
// Deterministic urgency ordering: soonest (incl. expired) first.
const sorted = React.useMemo(
() => [...certificates].sort((a, b) => a.daysLeft - b.daysLeft),
[certificates],
);
const soonest = sorted[0];
const expiredCount = sorted.filter((c) => c.daysLeft < 0).length;
const atRiskCount = sorted.filter(
(c) => c.daysLeft >= 0 && c.daysLeft <= warnAt,
).length;
return (
<TooltipProvider>
<div
ref={ref}
data-slot="certificate-expiry"
className={cn(
"w-full max-w-md rounded-xl bg-card p-4 text-card-foreground shadow-border",
className,
)}
style={
animate
? ({
animation: "cert-enter var(--duration-base) var(--ease-out)",
} as React.CSSProperties)
: undefined
}
{...props}
>
<style href="paragon-certificate-expiry" precedence="paragon">{`
@keyframes cert-enter {
from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
[data-slot="certificate-expiry"] { animation: none !important; }
}
`}</style>
<div className="mb-3 flex items-center gap-2.5">
<span
className={cn(
"flex size-8 items-center justify-center rounded-lg",
expiredCount > 0
? "bg-destructive/10 text-destructive"
: "bg-secondary text-foreground",
)}
>
{expiredCount > 0 ? (
<ShieldAlert className="size-4" />
) : (
<Lock className="size-4" />
)}
</span>
<div className="min-w-0 flex-1">
<h3 className="text-sm font-semibold">TLS certificates</h3>
<p className="text-[11px] text-muted-foreground">
{expiredCount > 0 && (
<span className="font-medium text-destructive">
{expiredCount} expired ·{" "}
</span>
)}
{atRiskCount} expiring within {warnAt}d
</p>
</div>
{soonest && (
<div className="shrink-0 text-right">
<div
className={cn(
"text-sm font-semibold tabular-nums",
band[bandOf(soonest.daysLeft)].text,
)}
>
{soonest.daysLeft < 0 ? (
"expired"
) : (
<>
<NumberTicker
value={soonest.daysLeft}
static={isStatic}
duration={0.8}
/>
d
</>
)}
</div>
<div className="text-[10px] text-muted-foreground">soonest</div>
</div>
)}
</div>
<ul className="divide-y">
{sorted.map((cert, i) => {
const b = bandOf(cert.daysLeft);
const meta = band[b];
// Ring fill fraction toward the max window.
const frac = Math.min(Math.max(cert.daysLeft / maxWindow, 0), 1);
const offset = RING_C * (1 - (armed ? frac : 0));
const expired = cert.daysLeft < 0;
return (
<li key={cert.id}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="group flex w-full items-center gap-3 rounded-md py-2.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{/* Countdown ring */}
<span className="relative shrink-0">
<svg
width={RING}
height={RING}
viewBox={`0 0 ${RING} ${RING}`}
className="-rotate-90 block"
aria-hidden
>
<circle
cx={RING / 2}
cy={RING / 2}
r={RING_R}
fill="none"
stroke="currentColor"
strokeOpacity={0.12}
strokeWidth={2}
/>
{!expired && (
<circle
cx={RING / 2}
cy={RING / 2}
r={RING_R}
fill="none"
stroke={meta.stroke}
strokeWidth={2}
strokeLinecap="round"
strokeDasharray={RING_C}
strokeDashoffset={offset}
style={{
transition: animate
? `stroke-dashoffset 700ms var(--ease-out) ${80 + i * 60}ms`
: undefined,
}}
/>
)}
</svg>
{expired && (
<span className="absolute inset-0 flex items-center justify-center">
<span className="size-1.5 rounded-full bg-destructive" />
</span>
)}
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-mono text-[13px] font-medium">
{cert.domain}
</span>
</div>
<span className="text-[11px] text-muted-foreground">
{cert.issuer}
</span>
</div>
<div className="w-16 shrink-0 text-right">
<span
className={cn(
"text-[13px] font-semibold tabular-nums",
meta.text,
)}
>
{expired ? "expired" : `${cert.daysLeft}d`}
</span>
<span className="block text-[10px] text-muted-foreground">
{expired ? "renew now" : "until expiry"}
</span>
</div>
</button>
</TooltipTrigger>
<TooltipContent>
<div className="font-medium">{cert.domain}</div>
<div className="mt-0.5 text-primary-foreground/70">
{cert.issuer}
{cert.expiresOn ? ` · expires ${cert.expiresOn}` : ""}
</div>
<div className="text-primary-foreground/70">
{expired
? `Expired ${Math.abs(cert.daysLeft)}d ago`
: `${cert.daysLeft} days left`}
</div>
</TooltipContent>
</Tooltip>
</li>
);
})}
</ul>
</div>
</TooltipProvider>
);
}