Inline card that collects an API key or environment variable out-of-band: masked password input, reveal toggle, prefix check, target .env file badge and settled saved state.
Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.
$pnpm dlx shadcn@latest add @nextui/secret-request
1{2"registries": {3"@nextui": {4"url": "https://nextui.dev/r/{name}.json",5"headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }6}7}8}
1"use client";23import { SecretRequest } from "@/components/nextui/secret-request";45export function CollectPaymentsKey() {6return (7<SecretRequest8keyName="PAYMENTS_SECRET_KEY"9targetFile=".env.local"10expectedPrefix="sk_live_"11onSave={(secret) => console.log("Saved secret length:", secret.length)}12/>13);14}
| Prop | Type | Default | Description |
|---|---|---|---|
| keyName | string | "PAYMENTS_SECRET_KEY" | Environment variable name |
| reason | string | – | Why the agent needs this secret |
| targetFile | string | ".env.local" | Destination file or vault name |
| expectedPrefix | string | "sk_live_" | Optional prefix validated before saving |
| onSave | (value: string) => void | – | Called with the secret when submitted |
| minLength | number | – | Minimum length before the value is accepted |
| onSkip | () => void | – | Called when the user skips providing the secret |
| autoPlay | boolean | false | Loop a scripted secret entry demo |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useRef, useState, type FormEvent } from "react";
import { ArrowRight, Check, Eye, EyeOff, FileLock, KeyRound, LockKeyhole, RotateCcw, X } from "lucide-react";
import { AnimatePresence, motion, useAnimationControls, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type SecretRequestProps = {
keyName?: string;
reason?: string;
targetFile?: string;
/** Required prefix, e.g. "sk_live_". The value is checked as it is typed. */
expectedPrefix?: string;
/** Minimum length of a valid value. */
minLength?: number;
onSave?: (value: string) => void;
onSkip?: () => void;
/** Loop a scripted wrong prefix → fix → save demo. */
autoPlay?: boolean;
className?: string;
};
type Preset = { keyName: string; reason: string; prefix: string; wrong: string; value: string; minLength: number };
const PRESETS: Preset[] = [
{
keyName: "PAYMENTS_SECRET_KEY",
reason: "Needed to create the webhook endpoint and verify test events.",
prefix: "sk_live_",
wrong: "pk_live_",
value: "sk_live_51Nq9K2L8x0V4mP7rZc3f9a",
minLength: 24,
},
{
keyName: "DATABASE_URL",
reason: "Needed to run the migration and seed the staging database.",
prefix: "postgres://",
wrong: "http://",
value: "postgres://app:Tg7x2@db.internal:5432/main",
minLength: 20,
},
];
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 8400;
const GLYPHS = "ABCDEF0123456789#$%&*+=?";
function useDemoClock(enabled: boolean, loop: number) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [enabled]);
useEffect(() => {
if (!enabled || !visible) return;
const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
return () => clearInterval(id);
}, [enabled, visible]);
return { ref, t: elapsed % loop, cycle: Math.floor(elapsed / loop) };
}
type Phase = "input" | "saving" | "saved";
type View = { value: string; reveal: boolean; phase: Phase; pressing: boolean };
function demoView(t: number, p: Preset): View {
let value = "";
if (t >= 500 && t < 1900) value = p.wrong.slice(0, Math.floor((t - 500) / 90) + 1);
if (t >= 2100) value = p.value.slice(0, Math.floor((t - 2100) / 45) + 1);
return {
value,
reveal: t >= 2800 && t < 3500,
phase: t >= 4400 ? "saved" : t >= 3800 ? "saving" : "input",
pressing: t >= 3550 && t < 3800,
};
}
function mask(v: string, prefix: string) {
if (v.length <= prefix.length + 4) return v;
return `${v.slice(0, prefix.length)}${"•".repeat(8)}${v.slice(-4)}`;
}
/** Asks the user for an API key or env var the agent needs: masked input with reveal, live prefix / length checks, and the saved line in .env.local. */
export function SecretRequest({
keyName,
reason,
targetFile = ".env.local",
expectedPrefix,
minLength,
onSave,
onSkip,
autoPlay = false,
className,
}: SecretRequestProps) {
const reduce = useReducedMotion() ?? false;
const [touched, setTouched] = useState(false);
const [frozen, setFrozen] = useState(0);
const custom = keyName !== undefined;
const demo = autoPlay && !reduce && !touched && !custom;
const { ref, t, cycle } = useDemoClock(demo, LOOP);
const preset = PRESETS[demo ? cycle % PRESETS.length : frozen];
const name = keyName ?? preset.keyName;
const prefix = expectedPrefix ?? (custom ? "" : preset.prefix);
const min = minLength ?? (custom ? 1 : preset.minLength);
const [view, setView] = useState<View>({ value: "", reveal: false, phase: "input", pressing: false });
const shake = useAnimationControls();
const [scramble, setScramble] = useState("");
// Sync the scripted frame during render (no effect → no extra commit per tick).
const [syncedT, setSyncedT] = useState(-1);
if (demo && t !== syncedT) {
setSyncedT(t);
setView(demoView(t, preset));
}
const prefixOk = !prefix || view.value.startsWith(prefix) || prefix.startsWith(view.value);
const prefixDone = !prefix || view.value.startsWith(prefix);
const lengthOk = view.value.length >= min;
const valid = prefixDone && lengthOk;
const wrong = view.value.length > 0 && !prefixOk;
useEffect(() => {
if (wrong && !reduce) shake.start({ x: [0, -6, 6, -4, 4, 0], transition: { duration: 0.4 } });
}, [wrong, reduce, shake]);
// Scramble the value while it is being encrypted.
useEffect(() => {
if (view.phase !== "saving") return;
const id = setInterval(() => {
setScramble(
Array.from({ length: Math.min(28, view.value.length) }, () => GLYPHS[Math.floor(Math.random() * GLYPHS.length)]).join(""),
);
}, 60);
return () => clearInterval(id);
}, [view.phase, view.value.length]);
const patch = (p: Partial<View>) => {
if (demo) setFrozen(cycle % PRESETS.length);
setTouched(true);
setView((v) => ({ ...v, ...p }));
};
const submit = (e: FormEvent) => {
e.preventDefault();
if (!valid) return;
patch({ phase: "saving" });
onSave?.(view.value);
setTimeout(() => setView((v) => ({ ...v, phase: "saved" })), 650);
};
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
<div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
{/* Header */}
<div className="flex items-start gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 pt-3.5 pb-3">
<span className="relative grid size-9 shrink-0 place-items-center rounded-xl border bg-background shadow-xs">
<KeyRound className="size-4" aria-hidden />
<span className="absolute -right-1 -bottom-1 grid size-4 place-items-center rounded-full border bg-card">
<LockKeyhole className="size-2.5 text-muted-foreground" aria-hidden />
</span>
</span>
<div className="min-w-0 flex-1">
<p className="font-medium leading-tight">{view.phase === "saved" ? "Secret saved · agent resumed" : "The agent needs a secret"}</p>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={name}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -6 }}
transition={{ duration: 0.3, ease: EASE }}
className="mt-1.5 flex items-center gap-1.5 text-[11px]"
>
<span className="truncate rounded-md border bg-background px-1.5 font-mono font-semibold">{name}</span>
<ArrowRight className="size-3 shrink-0 text-muted-foreground" aria-hidden />
<span className="inline-flex shrink-0 items-center gap-1 rounded-md border border-dashed px-1.5 font-mono text-muted-foreground">
<FileLock className="size-3" aria-hidden />
{targetFile}
</span>
</motion.div>
</AnimatePresence>
</div>
</div>
<AnimatePresence mode="wait" initial={false}>
{view.phase !== "saved" ? (
<motion.form
key={`form-${name}`}
onSubmit={submit}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -8, filter: "blur(3px)" }}
transition={{ duration: 0.3, ease: EASE }}
className="space-y-2.5 px-4 pb-3.5"
>
<p className="text-xs leading-relaxed text-muted-foreground">{reason ?? preset.reason}</p>
<motion.div animate={shake} className="relative">
<input
type={view.reveal ? "text" : "password"}
aria-label={name}
aria-invalid={wrong}
autoComplete="off"
spellCheck={false}
value={view.phase === "saving" ? "" : view.value}
onChange={(e) => patch({ value: e.target.value })}
placeholder={prefix ? `${prefix}…` : "Paste the value"}
className={cn(
"h-9 w-full rounded-lg border bg-background pr-10 pl-3 font-mono text-xs tracking-wide shadow-xs outline-none transition-[border-color,box-shadow]",
wrong
? "border-red-500/70 shadow-[0_0_0_3px_rgb(239_68_68/0.15)]"
: valid
? "border-emerald-500/60 shadow-[0_0_0_3px_rgb(16_185_129/0.12)]"
: "focus:border-foreground/40",
)}
/>
{view.phase === "saving" && (
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center font-mono text-xs tracking-wide text-emerald-600 dark:text-emerald-400" aria-hidden>
{scramble}
</span>
)}
<button
type="button"
aria-label={view.reveal ? "Hide value" : "Show value"}
onClick={() => patch({ reveal: !view.reveal })}
className="absolute top-1/2 right-1.5 grid size-7 -translate-y-1/2 cursor-pointer place-items-center rounded-md text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={view.reveal ? "off" : "on"}
initial={reduce ? false : { opacity: 0, rotate: -45, scale: 0.6 }}
animate={{ opacity: 1, rotate: 0, scale: 1 }}
exit={reduce ? undefined : { opacity: 0, rotate: 45, scale: 0.6 }}
transition={{ duration: 0.2 }}
>
{view.reveal ? <EyeOff className="size-3.5" aria-hidden /> : <Eye className="size-3.5" aria-hidden />}
</motion.span>
</AnimatePresence>
</button>
</motion.div>
<ul className="flex flex-wrap gap-1.5 text-[11px]" aria-live="polite">
{prefix && <Check_ ok={prefixDone} bad={wrong} label={wrong ? `Must start with ${prefix}` : `Starts with ${prefix}`} reduce={reduce} />}
<Check_ ok={lengthOk} label={`${min}+ characters`} reduce={reduce} />
</ul>
<div className="flex items-center gap-2 pt-1">
<span className="flex min-w-0 flex-1 items-center gap-1.5 text-[11px] text-muted-foreground">
<LockKeyhole className="size-3 shrink-0" aria-hidden />
<span className="truncate">Encrypted · never sent to model context</span>
</span>
{onSkip && (
<button
type="button"
onClick={onSkip}
className="h-8 shrink-0 cursor-pointer rounded-md px-2.5 text-xs text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
Skip
</button>
)}
<motion.button
type="submit"
disabled={!valid || view.phase === "saving"}
animate={{ scale: view.pressing ? 0.94 : 1 }}
className="h-8 shrink-0 cursor-pointer rounded-md bg-foreground px-3 text-xs font-medium text-background outline-none transition-opacity hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-default disabled:opacity-35"
>
{view.phase === "saving" ? "Encrypting…" : `Save to ${targetFile}`}
</motion.button>
</div>
</motion.form>
) : (
<motion.div
key="saved"
role="status"
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0 }}
transition={{ duration: 0.35, ease: EASE }}
className="px-4 pb-3.5"
>
<div className="overflow-hidden rounded-xl border bg-background font-mono text-[11px]">
<div className="flex items-center justify-between border-b bg-muted/40 px-3 py-1.5 text-[10px] text-muted-foreground">
{targetFile}
<span className="inline-flex items-center gap-1 text-emerald-700 dark:text-emerald-400">
<Check className="size-3" aria-hidden />
written
</span>
</div>
<div className="py-1.5 leading-5">
<p className="px-3 text-muted-foreground/70">NODE_ENV=development</p>
<p className="px-3 text-muted-foreground/70">APP_URL=http://localhost:3000</p>
<motion.p
initial={reduce ? false : { backgroundColor: "rgb(16 185 129 / 0.28)" }}
animate={{ backgroundColor: "rgb(16 185 129 / 0.1)" }}
transition={{ duration: 1.2 }}
className="truncate px-3 text-emerald-800 dark:text-emerald-300"
>
<span className="mr-2 opacity-60">+</span>
{name}={mask(view.value, prefix)}
</motion.p>
</div>
</div>
<div className="mt-2.5 flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<LockKeyhole className="size-3" aria-hidden />
Model sees only the variable name
</span>
<button
type="button"
onClick={() => patch({ phase: "input", value: "", reveal: false })}
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md px-2 outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw className="size-3" aria-hidden />
Replace
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
function Check_({ ok, bad, label, reduce }: { ok: boolean; bad?: boolean; label: string; reduce: boolean }) {
return (
<li
className={cn(
"inline-flex h-6 items-center gap-1 rounded-full border px-2 transition-colors duration-300",
bad ? "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300" : ok ? "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" : "text-muted-foreground",
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={bad ? "bad" : ok ? "ok" : "idle"}
initial={reduce ? false : { scale: 0 }}
animate={{ scale: 1 }}
exit={reduce ? undefined : { scale: 0 }}
transition={{ type: "spring", stiffness: 600, damping: 20 }}
className="grid size-3 place-items-center"
>
{bad ? <X className="size-3" aria-hidden /> : ok ? <Check className="size-3" strokeWidth={3} aria-hidden /> : <span className="size-1.5 rounded-full bg-current opacity-50" />}
</motion.span>
</AnimatePresence>
{label}
</li>
);
}
export default function SecretRequestDemo() {
return (
<div className="flex h-[320px] w-full items-start justify-center pt-3">
<SecretRequest autoPlay />
</div>
);
}