DNS Records
Manage DNS for a domain: records as rows, an add form that validates per type, new records go from pending to live.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/dns-records.json'use client'
import { useState } from 'react'
import './dns-records.css'
export interface DnsRecord { type: string; name: string; value: string; ttl: number; prio?: number; pending?: boolean }
export interface DnsRecordsProps {
domain?: string
records: DnsRecord[]
addType?: string
fill?: 'dark' | 'light'
className?: string
}
const TYPES = ['A', 'AAAA', 'CNAME', 'MX', 'TXT']
const HOST = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}\.?$/i
const check = (t: string, v: string, p: string) => {
if (!v.trim()) return 'Enter a value.'
if (t === 'A' && !/^(25[0-5]|2[0-4]\d|1?\d?\d)(\.(25[0-5]|2[0-4]\d|1?\d?\d)){3}$/.test(v)) return 'An A record needs an IPv4 address like 76.76.21.21.'
if (t === 'AAAA' && !/^[0-9a-f:]+$/i.test(v) || (t === 'AAAA' && !v.includes(':'))) return 'An AAAA record needs an IPv6 address.'
if ((t === 'CNAME' || t === 'MX') && !HOST.test(v)) return 'Use a hostname like cname.vercel-dns.com.'
if (t === 'MX' && !(Number(p) >= 0 && Number(p) <= 65535 && p !== '')) return 'MX priority is a number from 0 to 65535.'
return ''
}
/** DNS record manager with validation. */
export function DnsRecords({ domain = 'sundae.dev', records, addType = 'A', fill = 'dark', className }: DnsRecordsProps) {
const [list, setList] = useState(records)
const [type, setType] = useState(addType)
const [name, setName] = useState('@')
const [value, setValue] = useState('')
const [prio, setPrio] = useState('10')
const [touched, setTouched] = useState(false)
const [sure, setSure] = useState(-1)
const err = check(type, value.trim(), prio)
const add = () => {
setTouched(true); if (err) return
const rec: DnsRecord = { type, name: name.trim() || '@', value: value.trim(), ttl: 3600, prio: type === 'MX' ? Number(prio) : undefined, pending: true }
setList([rec, ...list]); setValue(''); setTouched(false)
setTimeout(() => setList((l) => l.map((x) => (x === rec ? { ...x, pending: false } : x))), 2200)
}
return (
<div className={['sd-dns', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-dns__h"><b>{domain}</b><span aria-live="polite">{list.length} records{list.some((r) => r.pending) ? ' · propagating' : ''}</span></div>
<ul>{list.map((r, i) => (
<li key={`${r.type}${r.name}${r.value}`}><b>{r.type}</b><div><code>{r.name === '@' ? domain : `${r.name}.${domain}`}</code><code>{r.prio !== undefined ? `${r.prio} ` : ''}{r.value}</code><small>TTL {r.ttl / 60}m · <em>{r.pending ? 'Pending' : 'Live'}</em></small></div>
<button type="button" data-sure={sure === i || undefined} aria-label={sure === i ? `Confirm delete ${r.type} ${r.name}` : `Delete ${r.type} ${r.name}`} onClick={() => { if (sure === i) { setList(list.filter((_, j) => j !== i)); setSure(-1) } else setSure(i) }} onBlur={() => setSure(-1)}>{sure === i ? 'Delete?' : 'Remove'}</button></li>
))}</ul>
<form onSubmit={(e) => { e.preventDefault(); add() }} noValidate>
<div className="sd-dns__r"><select aria-label="Type" value={type} onChange={(e) => { setType(e.target.value); setTouched(false) }}>{TYPES.map((t) => <option key={t}>{t}</option>)}</select><input aria-label="Name" value={name} placeholder="@ or subdomain" onChange={(e) => setName(e.target.value.replace(/\s/g, ''))} /></div>
<div className="sd-dns__r" style={type === 'MX' ? undefined : { gridTemplateColumns: 'minmax(0, 1fr)' }}>{type === 'MX' && <input aria-label="Priority" inputMode="numeric" value={prio} onChange={(e) => setPrio(e.target.value.replace(/\D/g, ''))} />}<input aria-label="Value" aria-invalid={touched && !!err} aria-describedby="sd-dns-e" spellCheck={false} placeholder={type === 'A' ? '76.76.21.21' : type === 'AAAA' ? '2606:4700::6810' : type === 'TXT' ? 'v=spf1 include:_spf.google.com ~all' : 'cname.vercel-dns.com'} value={value} onChange={(e) => setValue(e.target.value)} /></div>
<p className="sd-dns__e" id="sd-dns-e" role="alert">{touched ? err : ''}</p>
<button type="submit">Add {type} record</button>
</form>
</div>
)
}.sd-dns { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-dns[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-dns :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-dns :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-dns .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-dns { width: min(100%, 350px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; }
.sd-dns__h { display: flex; justify-content: space-between; align-items: baseline; }
.sd-dns__h b { font-size: 15px; }
.sd-dns__h span { font-size: 12px; color: var(--mute); }
.sd-dns ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 4px; max-height: 210px; overflow-y: auto; }
.sd-dns li { display: grid; grid-template-columns: 50px minmax(0, 1fr) auto; gap: 8px; align-items: center; padding: 8px 8px 8px 10px; border-radius: 10px; background: var(--soft); animation: sd-dns-in 220ms ease-out; }
@keyframes sd-dns-in { from { opacity: 0; transform: translateY(-4px); } }
.sd-dns li > b { font: 700 11px ui-monospace, monospace; padding: 3px 0; text-align: center; border-radius: 5px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-dns li div { min-width: 0; font-size: 12.5px; }
.sd-dns li code { display: block; font: 12px ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-dns li small { font-size: 11px; color: var(--mute); }
.sd-dns li small em { font-style: normal; color: var(--fg); font-weight: 600; }
.sd-dns li > button { height: 28px; padding: 0 8px; border-radius: 7px; font-size: 11.5px; color: var(--mute) !important; }
.sd-dns li > button[data-sure] { background: var(--strong) !important; color: var(--on) !important; }
.sd-dns form { display: grid; gap: 6px; padding-top: 10px; border-top: 1px solid var(--line); }
.sd-dns__r { display: grid; grid-template-columns: 82px minmax(0, 1fr); gap: 6px; }
.sd-dns form select, .sd-dns form input { height: 38px; min-width: 0; padding: 0 9px; border-radius: 9px; border: 0; background: var(--soft); color: var(--fg); font: 15px ui-sans-serif, system-ui; outline: 0; }
.sd-dns form input { font-family: ui-monospace, monospace; font-size: 14px; }
.sd-dns form select:focus, .sd-dns form input:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-dns form input[aria-invalid='true'] { box-shadow: inset 0 0 0 1.5px var(--mute); }
.sd-dns__e { margin: 0; font-size: 11.5px; color: var(--mute); min-height: 15px; }
.sd-dns form button { height: 40px; border-radius: 11px; font-size: 13px; font-weight: 600;background: var(--strong) !important; color: var(--on) !important; }
.sd-dns form button:disabled { opacity: 0.4; }
@media (prefers-reduced-motion: reduce) { .sd-dns *, .sd-dns *::before, .sd-dns *::after { animation: none !important; transition: none !important; } }Overview
A, AAAA, CNAME, MX and TXT records each validate their value: IPv4 and IPv6 addresses, hostnames, and an MX priority. Errors appear under the field.
New records show Pending while they propagate, then Live. Records can be deleted with an inline confirm.
When to use
- Hosting dashboards, domain settings, onboarding to custom domains.
When not to use
- Bulk zone editing: use a zone file import.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/dns-records.json$
npx shadcn@latest add https://sundaeui.dev/r/dns-records.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/dns-records.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/dns-records.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy dns-records.tsx and dns-records.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { DnsRecords } from '@/components/sundae/dns-records'
<DnsRecords />Props
| Prop | Type | Default |
|---|---|---|
domainZone name. | string | 'sundae.dev' |
records*Starting records. | { type: string; name: string; value: string; ttl: number }[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The add form uses labelled fields and describes errors with aria-describedby.
- Status changes are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-dns; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is DNS Records free?
- Yes. DNS Records is free for personal and commercial projects, with full source on this page.
- How do I install DNS Records?
- Run npx shadcn@latest add https://sundaeui.dev/r/dns-records.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does DNS Records support keyboard and screen readers?
- The add form uses labelled fields and describes errors with aria-describedby. Status changes are announced.
- Does DNS Records respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Danger Zone
Forms · RefinedNotification Matrix
Forms · Refined- Pro
Shortcut Recorder
Forms · Refined Settings List
Forms · RefinedAsync Switch
Forms · Refined- Pro
Feature Flag
Forms · Refined