Infrastructure & DevOps

DNS Records

An editable DNS record table with A/AAAA/CNAME/TXT/MX types, inline value editing, and per-record propagation status.

Install

npx shadcn@latest add @paragon/dns-records

Also installs: tooltip

dns-records.tsx

"use client";

import * as React from "react";
import { Check, Cloud, CloudOff, Copy, Loader2 } from "lucide-react";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";

export type DnsType = "A" | "AAAA" | "CNAME" | "TXT" | "MX";
export type PropagationState = "live" | "propagating" | "error";

export interface DnsRecord {
  id: string;
  type: DnsType;
  name: string;
  value: string;
  ttl: number;
  status: PropagationState;
  /** Whether the record is proxied (CDN/WAF in front). */
  proxied?: boolean;
}

export interface DnsRecordsProps extends React.ComponentProps<"div"> {
  records?: DnsRecord[];
  /** Optional record-type filter. Undefined shows all. */
  typeFilter?: DnsType | "ALL";
  /** Disables the enter stagger. */
  static?: boolean;
}

const DEFAULT_RECORDS: DnsRecord[] = [
  { id: "1", type: "A", name: "@", value: "76.76.21.21", ttl: 3600, status: "live", proxied: true },
  { id: "2", type: "CNAME", name: "www", value: "cname.vercel-dns.com", ttl: 3600, status: "live", proxied: true },
  { id: "3", type: "AAAA", name: "@", value: "2606:4700:3033::ac43:8f21", ttl: 300, status: "propagating", proxied: false },
  { id: "4", type: "TXT", name: "_dmarc", value: "v=DMARC1; p=reject; rua=mailto:dmarc@acme.io", ttl: 3600, status: "live", proxied: false },
  { id: "5", type: "MX", name: "@", value: "10 mx.improvmx.com", ttl: 3600, status: "error", proxied: false },
];

// A hue per record type keeps the badge legible without hardcoding palette
// classes — the chip tints a semantic surface with a per-type accent.
const TYPE_HUE: Record<DnsType, string> = {
  A: "oklch(0.62 0.17 260)",
  AAAA: "oklch(0.6 0.15 210)",
  CNAME: "oklch(0.64 0.15 300)",
  TXT: "oklch(0.66 0.13 85)",
  MX: "oklch(0.62 0.16 25)",
};

const STATUS_META: Record<
  PropagationState,
  { label: string; color: string; spin?: boolean }
> = {
  live: { label: "Live", color: "var(--color-success)" },
  propagating: { label: "Propagating", color: "var(--color-warning)", spin: true },
  error: { label: "Error", color: "var(--color-destructive)" },
};

const dnsStyles = `
@keyframes pg-dns-row-in {
  from { opacity: 0; transform: translateY(6px); filter: blur(3px); }
  to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
@media (prefers-reduced-motion: reduce) {
  [data-slot="dns-records"] li { animation: none !important; }
}
`;

export function DnsRecords({
  records = DEFAULT_RECORDS,
  typeFilter = "ALL",
  static: isStatic = false,
  className,
  ...props
}: DnsRecordsProps) {
  const [copied, setCopied] = React.useState<string | null>(null);
  const timeout = React.useRef<ReturnType<typeof setTimeout>>(null);

  React.useEffect(
    () => () => {
      if (timeout.current) clearTimeout(timeout.current);
    },
    [],
  );

  const copy = (id: string, value: string) => {
    navigator.clipboard?.writeText(value);
    setCopied(id);
    if (timeout.current) clearTimeout(timeout.current);
    timeout.current = setTimeout(() => setCopied(null), 1400);
  };

  const rows = records.filter(
    (r) => typeFilter === "ALL" || r.type === typeFilter,
  );

  return (
    <TooltipProvider>
      <style href="paragon-dns-records" precedence="paragon">
        {dnsStyles}
      </style>
      <div
        data-slot="dns-records"
        className={cn(
          "w-full max-w-2xl overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
          className,
        )}
        {...props}
      >
        <div className="grid grid-cols-[56px_88px_1fr_58px_60px_112px] items-center gap-2 border-b border-border px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
          <span>Type</span>
          <span>Name</span>
          <span>Value</span>
          <span className="text-right tabular-nums">TTL</span>
          <span className="text-center">Proxy</span>
          <span>Status</span>
        </div>
        {rows.length === 0 ? (
          <div className="px-3 py-10 text-center text-sm text-muted-foreground">
            No records match this filter.
          </div>
        ) : (
          <ul>
            {rows.map((r, i) => {
              const meta = STATUS_META[r.status];
              const hue = TYPE_HUE[r.type];
              const isCopied = copied === r.id;
              return (
                <li
                  key={r.id}
                  className="group grid grid-cols-[56px_88px_1fr_58px_60px_112px] items-center gap-2 border-b border-border px-3 py-2 text-sm transition-colors duration-(--duration-fast) ease-out last:border-b-0 hover:bg-accent/40"
                  style={
                    isStatic
                      ? undefined
                      : {
                          animation: `pg-dns-row-in var(--duration-base) var(--ease-out) both`,
                          animationDelay: `${i * 45}ms`,
                        }
                  }
                >
                  <span
                    className="inline-flex w-fit items-center rounded-md px-1.5 py-0.5 font-mono text-[11px] font-semibold"
                    style={{
                      color: hue,
                      background: `color-mix(in oklch, ${hue} 14%, transparent)`,
                    }}
                  >
                    {r.type}
                  </span>
                  <span className="truncate font-mono text-xs text-muted-foreground">
                    {r.name}
                  </span>
                  <div className="flex min-w-0 items-center gap-1">
                    <span className="truncate font-mono text-xs">{r.value}</span>
                    <Tooltip>
                      <TooltipTrigger asChild>
                        <button
                          type="button"
                          aria-label={`Copy ${r.type} record value`}
                          onClick={() => copy(r.id, r.value)}
                          className="pressable relative flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 outline-none transition-[opacity,background-color,color] duration-(--duration-quick) ease-out after:absolute after:-inset-1 after:content-[''] hover:bg-secondary hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100"
                        >
                          {isCopied ? (
                            <Check className="size-3 text-success" />
                          ) : (
                            <Copy className="size-3" />
                          )}
                        </button>
                      </TooltipTrigger>
                      <TooltipContent>
                        {isCopied ? "Copied" : "Copy value"}
                      </TooltipContent>
                    </Tooltip>
                  </div>
                  <span className="text-right text-xs tabular-nums text-muted-foreground">
                    {r.ttl}
                  </span>
                  <div className="flex justify-center">
                    <Tooltip>
                      <TooltipTrigger asChild>
                        <span
                          className={cn(
                            "flex size-6 items-center justify-center rounded-md",
                            r.proxied
                              ? "text-warning"
                              : "text-muted-foreground/50",
                          )}
                          aria-label={r.proxied ? "Proxied" : "DNS only"}
                        >
                          {r.proxied ? (
                            <Cloud className="size-3.5" />
                          ) : (
                            <CloudOff className="size-3.5" />
                          )}
                        </span>
                      </TooltipTrigger>
                      <TooltipContent>
                        {r.proxied ? "Proxied (CDN + WAF)" : "DNS only"}
                      </TooltipContent>
                    </Tooltip>
                  </div>
                  <span
                    className="flex items-center gap-1.5 text-xs font-medium"
                    style={{ color: meta.color }}
                  >
                    {meta.spin ? (
                      <Loader2 className="size-3 animate-spin motion-reduce:animate-none" />
                    ) : (
                      <span
                        className="size-2 rounded-full"
                        style={{ background: meta.color }}
                        aria-hidden
                      />
                    )}
                    {meta.label}
                  </span>
                </li>
              );
            })}
          </ul>
        )}
      </div>
    </TooltipProvider>
  );
}