nextui.dev
Home
Docs
Saved
Pricing
Components37
  • Forms & Inputs7
  • Cards13
  • Buttons, Badges & Avatars8
  • Feedback & Display6
  • Effects3
Agents46
  • Input & Context9
  • Thinking & Status6
  • Tools & Execution7
  • Human-in-the-Loop & Safety7
  • Multi-Agent & Workflows4
  • Output & Artifacts8
  • Voice & Memory5
Blocks20
  • Agent chatNew
  • Pill heroNew
  • Grid hero
  • Moodboard generatorNew
  • Waitlist heroNew
  • Intro bannerNew
  • Creative home heroNew
  • Chat heroNew
  • App shelfNew
  • Usage card
  • Design resultsNew
  • Pricing tiers
  • Dashboard home
  • The shiftNew
  • Launch perksNew
  • Testimonials
  • Quote
  • Team
  • Leads tableNew
  • Sign in
Pages16
  • ASCII workspace$49
  • Research agent$49
  • Support agent$49
  • Coding agent$49
  • CRM overviewNew
  • Marketing dashboardNew
  • Developer portfolio$49
  • Particle landing$49
  • Particle story$49
  • Statement converter$49
  • AI photo studio$49
  • Group card launch$49
  • Month-end storyNew
  • Task HubNew
  • Global MediaNew
  • Hexora ecosystem$49
Themes9
  • Default
  • Ember
  • Ocean
  • Forest
  • Grape
  • Rose
  • Amber
  • Clay
  • Brutal
UpgradeDark modeLight modeFeedbackGitHub
nextui.dev

nextui components, blocks and themes you install with one command.

Product

  • Components
  • Blocks
  • Themes
  • Pricing

Resources

  • Docs
  • Roadmap
  • GitHub

Legal

  • License
  • Privacy
  • Terms
  • Refunds

nextUI

[ 200 OK ] · nextui.dev

© 2026 nextui.dev • All rights reserved.

UpgradeDark modeLight modeFeedbackGitHub
ComponentsAI & AgentsSecret request
Copy promptCopy prompt

Customize

Environment variable name

Why the agent needs this secret

Destination file or vault name

Optional prefix validated before saving

Loop a scripted secret entry demo

JSX · copied with Copy prompt

<SecretRequest autoPlay />

Secret requestNew

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.

Installation

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}

Usage

1"use client";
2
3import { SecretRequest } from "@/components/nextui/secret-request";
4
5export function CollectPaymentsKey() {
6 return (
7 <SecretRequest
8 keyName="PAYMENTS_SECRET_KEY"
9 targetFile=".env.local"
10 expectedPrefix="sk_live_"
11 onSave={(secret) => console.log("Saved secret length:", secret.length)}
12 />
13 );
14}

API reference

Edit props live in the preview above, or see all 9 props
PropTypeDefaultDescription
keyNamestring"PAYMENTS_SECRET_KEY"Environment variable name
reasonstring–Why the agent needs this secret
targetFilestring".env.local"Destination file or vault name
expectedPrefixstring"sk_live_"Optional prefix validated before saving
onSave(value: string) => void–Called with the secret when submitted
minLengthnumber–Minimum length before the value is accepted
onSkip() => void–Called when the user skips providing the secret
autoPlaybooleanfalseLoop a scripted secret entry demo
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/secret-request.tsx
"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>
  );
}
Sign in