Infrastructure & DevOps

Env Var Manager

An environment-variable manager with masked secret values, blur-reveal toggles, copy buttons, an add-variable row, and rows that animate in and out.

Install

npx shadcn@latest add @paragon/env-var-manager

Also installs: tooltip

env-var-manager.tsx

"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>
  );
}