An editable DNS record table with A/AAAA/CNAME/TXT/MX types, inline value editing, and per-record propagation status.
npx shadcn@latest add @paragon/dns-recordsAlso installs: tooltip
"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>
);
}