A CIDR allowlist manager that validates ranges on the fly and animates rows in and out as entries are added or removed.
npx shadcn@latest add @paragon/ip-allowlist"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Globe, Plus, Trash2 } from "lucide-react";
import { cn } from "@/lib/utils";
export interface CidrEntry {
id: string;
cidr: string;
note?: string;
}
export interface IpAllowlistProps extends React.ComponentProps<"div"> {
/** Initial entries. */
initial?: CidrEntry[];
/** Disables enter animation. */
static?: boolean;
}
const DEFAULT_ENTRIES: CidrEntry[] = [
{ id: "hq", cidr: "203.0.113.0/24", note: "SF office" },
{ id: "vpn", cidr: "198.51.100.14/32", note: "Corp VPN gateway" },
{ id: "ci", cidr: "10.20.0.0/16", note: "CI runners" },
];
/** Validates an IPv4 CIDR like 10.0.0.0/8. Returns a reason when invalid. */
function validateCidr(input: string): string | null {
const trimmed = input.trim();
const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})\/(\d{1,2})$/.exec(
trimmed,
);
if (!match) return "Use CIDR notation, e.g. 10.0.0.0/24";
const octets = match.slice(1, 5).map(Number);
if (octets.some((o) => o > 255)) return "Each octet must be 0–255";
const prefix = Number(match[5]);
if (prefix > 32) return "Prefix must be /0–/32";
return null;
}
/**
* A CIDR allowlist manager: type a range, it validates on the fly, and adding
* inserts a row that slides in while the list settles. Removing sweeps a row out
* with layout animation via AnimatePresence. Validation is synchronous and
* deterministic. Reduced motion drops the enter/exit movement but keeps the
* list correct. IDs are seeded from a useId counter, never Math.random.
*/
export function IpAllowlist({
initial = DEFAULT_ENTRIES,
static: isStatic = false,
className,
...props
}: IpAllowlistProps) {
const reduced = useReducedMotion();
const seed = React.useId();
const counter = React.useRef(0);
const [entries, setEntries] = React.useState<CidrEntry[]>(initial);
const [draft, setDraft] = React.useState("");
const [touched, setTouched] = React.useState(false);
const error = draft.trim() ? validateCidr(draft) : null;
const duplicate =
!error && entries.some((e) => e.cidr === draft.trim())
? "Range already in the allowlist"
: null;
const problem = touched ? error || duplicate : null;
const canAdd = draft.trim().length > 0 && !error && !duplicate;
const add = () => {
if (!canAdd) {
setTouched(true);
return;
}
counter.current += 1;
setEntries((prev) => [
...prev,
{ id: `${seed}-${counter.current}`, cidr: draft.trim() },
]);
setDraft("");
setTouched(false);
};
const remove = (id: string) => {
setEntries((prev) => prev.filter((e) => e.id !== id));
};
return (
<div
data-slot="ip-allowlist"
className={cn(
"w-full max-w-md rounded-xl bg-card p-5 text-card-foreground shadow-border",
className,
)}
style={
!isStatic && !reduced
? ({
animation: "ip-allowlist-enter var(--duration-base) var(--ease-out)",
} as React.CSSProperties)
: undefined
}
{...props}
>
<style href="paragon-ip-allowlist" precedence="paragon">{`
@keyframes ip-allowlist-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="ip-allowlist"] { animation: none !important; }
}
`}</style>
<div className="flex items-center gap-2.5">
<span className="flex size-8 items-center justify-center rounded-lg bg-secondary text-foreground">
<Globe className="size-4" />
</span>
<div>
<h3 className="text-sm font-semibold">IP allowlist</h3>
<p className="text-xs text-muted-foreground tabular-nums">
{entries.length} range{entries.length === 1 ? "" : "s"} can reach the API
</p>
</div>
</div>
{/* Add form */}
<div className="mt-4">
<div className="flex gap-2">
<input
value={draft}
onChange={(e) => {
setDraft(e.target.value);
if (!touched) setTouched(true);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
placeholder="e.g. 192.0.2.0/24"
aria-label="CIDR range"
aria-invalid={!!problem}
className={cn(
"h-9 min-w-0 flex-1 rounded-lg border bg-background px-3 font-mono text-[13px] outline-none transition-[border-color,box-shadow] duration-(--duration-fast)",
problem
? "border-destructive focus-visible:ring-2 focus-visible:ring-destructive/40"
: "border-input focus-visible:ring-2 focus-visible:ring-ring",
)}
/>
<button
type="button"
onClick={add}
disabled={!canAdd}
className="pressable inline-flex h-9 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-primary px-3 text-[13px] font-medium text-primary-foreground transition-[opacity] duration-(--duration-fast) hover:opacity-90 disabled:opacity-40 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Plus className="size-4" />
Add
</button>
</div>
<p
className="mt-1.5 h-4 text-[11px] text-destructive"
aria-live="polite"
>
{problem}
</p>
</div>
{/* Entries */}
<ul className="space-y-1.5">
<AnimatePresence initial={false} mode="popLayout">
{entries.map((entry) => (
<motion.li
key={entry.id}
layout={!reduced}
initial={reduced ? { opacity: 0 } : { opacity: 0, y: 8, filter: "blur(4px)" }}
animate={reduced ? { opacity: 1 } : { opacity: 1, y: 0, filter: "blur(0px)" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.96, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
>
<div className="flex items-center gap-3 rounded-lg border bg-background px-3 py-2">
<span className="flex-1 truncate font-mono text-[13px] tabular-nums">
{entry.cidr}
</span>
{entry.note && (
<span className="shrink-0 truncate text-[11px] text-muted-foreground">
{entry.note}
</span>
)}
<button
type="button"
aria-label={`Remove ${entry.cidr}`}
onClick={() => remove(entry.id)}
className="pressable relative flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-(--duration-fast) hover:text-destructive outline-none focus-visible:ring-2 focus-visible:ring-ring after:absolute after:top-1/2 after:left-1/2 after:size-9 after:-translate-1/2"
>
<Trash2 className="size-4" />
</button>
</div>
</motion.li>
))}
</AnimatePresence>
</ul>
</div>
);
}