Security

Certificate Expiry

A TLS certificate expiry monitor with per-domain countdown rings, urgency-sorted rows, threshold color bands, and hover detail tooltips.

Install

npx shadcn@latest add @paragon/certificate-expiry

Also installs: number-ticker, tooltip

certificate-expiry.tsx

"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>
  );
}