An environment-variable manager with masked secret values, blur-reveal toggles, copy buttons, an add-variable row, and rows that animate in and out.
npx shadcn@latest add @paragon/env-var-managerAlso installs: tooltip
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Copy, Eye, EyeOff, Plus, Trash2 } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/paragon/ui/tooltip";
import { cn } from "@/lib/utils";
export interface EnvVar {
id: string;
key: string;
value: string;
secret?: boolean;
}
export interface EnvVarManagerProps extends React.ComponentProps<"div"> {
/** Environment label shown as a scope tag on each row. */
environment?: string;
vars?: EnvVar[];
/** Disables the enter stagger. */
static?: boolean;
}
const DEFAULT_VARS: EnvVar[] = [
{ id: "db", key: "DATABASE_URL", value: "postgres://prod:9x!k@db.acme.io:5432/main", secret: true },
{ id: "stripe", key: "STRIPE_SECRET_KEY", value: "sk_live_51Hq8fQ2eZvKYlo2Cx9RtP", secret: true },
{ id: "api", key: "NEXT_PUBLIC_API_URL", value: "https://api.acme.io" },
{ id: "log", key: "LOG_LEVEL", value: "warn" },
];
const rowVariants = {
initial: { opacity: 0, y: 8, filter: "blur(4px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: { opacity: 0, y: -8, filter: "blur(4px)" },
};
export function EnvVarManager({
environment = "Production",
vars = DEFAULT_VARS,
static: isStatic = false,
className,
...props
}: EnvVarManagerProps) {
const reduced = useReducedMotion();
const [rows, setRows] = React.useState(vars);
const [revealed, setRevealed] = React.useState<Set<string>>(new Set());
const [copied, setCopied] = React.useState<string | null>(null);
const [draftKey, setDraftKey] = React.useState("");
const [draftValue, setDraftValue] = React.useState("");
const seq = React.useRef(0);
const timeout = React.useRef<ReturnType<typeof setTimeout>>(null);
// Keep in sync when the scenario/environment prop changes.
React.useEffect(() => {
setRows(vars);
setRevealed(new Set());
}, [vars]);
React.useEffect(
() => () => {
if (timeout.current) clearTimeout(timeout.current);
},
[],
);
const toggle = (id: string) =>
setRevealed((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
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 remove = (id: string) =>
setRows((rs) => rs.filter((r) => r.id !== id));
const add = () => {
const key = draftKey.trim().toUpperCase().replace(/\s+/g, "_");
if (!key) return;
seq.current += 1;
const id = `new-${seq.current}`;
setRows((rs) => [
...rs,
{
id,
key,
value: draftValue,
secret: /SECRET|KEY|TOKEN|PASSWORD|URL/.test(key),
},
]);
setDraftKey("");
setDraftValue("");
};
const animateRows = !isStatic && !reduced;
return (
<TooltipProvider>
<div
className={cn(
"w-full max-w-2xl overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<div className="flex items-center justify-between border-b border-border px-3 py-2">
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Environment variables
</span>
<span className="inline-flex items-center gap-1 rounded-md bg-secondary px-1.5 py-0.5 text-[11px] font-medium text-secondary-foreground">
<span className="size-1.5 rounded-full bg-success" aria-hidden />
{environment}
</span>
</div>
<ul className="divide-y divide-border">
<AnimatePresence initial={false}>
{rows.map((v, i) => {
const shown = revealed.has(v.id) || !v.secret;
const isCopied = copied === v.id;
return (
<motion.li
key={v.id}
layout={animateRows ? "position" : false}
variants={rowVariants}
initial={animateRows ? "initial" : false}
animate="animate"
exit={reduced ? { opacity: 0 } : "exit"}
transition={{
duration: 0.25,
ease: [0.22, 1, 0.36, 1],
delay: isStatic ? 0 : Math.min(i * 0.04, 0.24),
}}
className="group grid grid-cols-[minmax(0,200px)_1fr_auto] items-center gap-2 px-3 py-2"
>
<span className="truncate font-mono text-xs font-medium">
{v.key}
</span>
<div className="relative flex min-w-0 items-center">
<span
className={cn(
"truncate font-mono text-xs text-muted-foreground transition-[filter,opacity] duration-(--duration-base) ease-out",
!shown && "select-none opacity-70 blur-[5px]",
)}
>
{shown ? v.value : v.value.replace(/./g, "•")}
</span>
</div>
<div className="flex shrink-0 items-center gap-0.5">
{v.secret && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={shown ? `Hide ${v.key}` : `Reveal ${v.key}`}
aria-pressed={revealed.has(v.id)}
onClick={() => toggle(v.id)}
className="pressable relative flex size-6 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors duration-(--duration-quick) ease-out after:absolute after:-inset-1 after:content-[''] hover:bg-secondary hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
{revealed.has(v.id) ? (
<EyeOff className="size-3.5" />
) : (
<Eye className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent>
{shown ? "Hide secret" : "Reveal secret"}
</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`Copy ${v.key}`}
onClick={() => copy(v.id, v.value)}
className="pressable relative flex size-6 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>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`Delete ${v.key}`}
onClick={() => remove(v.id)}
className="pressable relative flex size-6 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-destructive focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring group-hover:opacity-100"
>
<Trash2 className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
</motion.li>
);
})}
</AnimatePresence>
</ul>
{rows.length === 0 && (
<div className="px-3 py-6 text-center text-xs text-muted-foreground">
No variables set for {environment}.
</div>
)}
{/* Add-variable row */}
<form
onSubmit={(e) => {
e.preventDefault();
add();
}}
className="grid grid-cols-[minmax(0,200px)_1fr_auto] items-center gap-2 border-t border-border bg-secondary/30 px-3 py-2"
>
<input
value={draftKey}
onChange={(e) => setDraftKey(e.target.value)}
placeholder="NEW_KEY"
aria-label="New variable key"
className="h-7 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs uppercase outline-none placeholder:normal-case placeholder:text-muted-foreground/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
<input
value={draftValue}
onChange={(e) => setDraftValue(e.target.value)}
placeholder="value"
aria-label="New variable value"
className="h-7 w-full min-w-0 rounded-md border border-input bg-background px-2 font-mono text-xs outline-none placeholder:text-muted-foreground/60 focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
type="submit"
disabled={!draftKey.trim()}
className="pressable inline-flex h-7 shrink-0 items-center gap-1 rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground outline-none transition-[opacity] duration-(--duration-quick) ease-out hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"
>
<Plus className="size-3.5" />
Add
</button>
</form>
</div>
</TooltipProvider>
);
}