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 & AgentsGuardrail check
Copy promptCopy prompt

Customize

Header label

Guardrail scan duration in milliseconds

12ms

Loop a progressive policy scan demo

JSX · copied with Copy prompt

<GuardrailCheck autoPlay />

Guardrail checkNew

Safety and policy verification card showing PII masking, prompt-injection screening and role/spend boundary checks before an agent executes.

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/guardrail-check
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 { GuardrailCheck } from "@/components/nextui/guardrail-check";
4
5export function SafetyAudit() {
6 return <GuardrailCheck title="Guardrails passed" latencyMs={12} />;
7}

API reference

Edit props live in the preview above, or see all 7 props
PropTypeDefaultDescription
titlestring"Pre-send safety check"Header label
rulesGuardrailRule[]–Policy checks with label, detail and state (passed or redacted)
latencyMsnumber12Guardrail scan duration in milliseconds
messagestring–Outgoing message that the checks scan; PII is redacted inline
onRequestAccess() => void–Called from the Request access button when an RBAC rule blocks
autoPlaybooleanfalseLoop a progressive policy scan demo
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/guardrail-check.tsx
"use client";

import { useEffect, useRef, useState } from "react";
import { Ban, Check, EyeOff, ShieldCheck, ShieldX, UserRoundCheck, Wallet } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type GuardrailState = "pending" | "passed" | "redacted" | "blocked";

export type GuardrailRule = {
  id: string;
  label: string;
  detail: string;
  state: GuardrailState;
  /** Optional usage meter, e.g. spend against a cap. */
  meter?: { value: number; max: number; prefix?: string };
  kind?: "pii" | "spend" | "rbac";
};

export type GuardrailCheckProps = {
  title?: string;
  rules?: GuardrailRule[];
  /** The outgoing text. Parts wrapped in [[double brackets]] are shown redacted. */
  message?: string;
  latencyMs?: number;
  /** Called by the Request access button when a rule blocks. */
  onRequestAccess?: () => void;
  /** Loop a scripted scan, alternating a redacted pass and a block. */
  autoPlay?: boolean;
  className?: string;
};

const MESSAGE = "Refund order #10480 for [[jane.doe@mail.test]], card [[4242 4242 4242 4242]], amount $182.00.";

function rulesFor(blocked: boolean): GuardrailRule[] {
  return [
    { id: "pii", kind: "pii", label: "PII redaction", detail: "1 email and 1 card number masked", state: "redacted" },
    { id: "spend", kind: "spend", label: "Spend cap", detail: "$182 of $250 daily refund budget", state: "passed", meter: { value: 182, max: 250, prefix: "$" } },
    blocked
      ? { id: "rbac", kind: "rbac", label: "Permission check", detail: "support-agent lacks refunds:write", state: "blocked" }
      : { id: "rbac", kind: "rbac", label: "Permission check", detail: "billing-agent has refunds:write", state: "passed" },
  ];
}

const ICONS = { pii: EyeOff, spend: Wallet, rbac: UserRoundCheck };
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 8000;
const RESULT_AT = [1300, 2500, 3700];

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

function verdictOf(rules: GuardrailRule[]) {
  if (rules.some((r) => r.state === "pending")) return "scanning" as const;
  if (rules.some((r) => r.state === "blocked")) return "blocked" as const;
  if (rules.some((r) => r.state === "redacted")) return "redacted" as const;
  return "passed" as const;
}

const VERDICT = {
  scanning: { label: "Scanning…", cls: "text-muted-foreground" },
  passed: { label: "Passed", cls: "border-emerald-500/30 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300" },
  redacted: { label: "Redacted · safe to send", cls: "border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300" },
  blocked: { label: "Blocked", cls: "border-red-500/30 bg-red-500/10 text-red-700 dark:text-red-300" },
};

const STATE = {
  pending: { label: "Checking", cls: "text-muted-foreground", bar: "bg-muted-foreground/25" },
  passed: { label: "Passed", cls: "text-emerald-700 dark:text-emerald-300 bg-emerald-500/10", bar: "bg-emerald-500" },
  redacted: { label: "Redacted", cls: "text-amber-700 dark:text-amber-300 bg-amber-500/10", bar: "bg-amber-500" },
  blocked: { label: "Blocked", cls: "text-red-700 dark:text-red-300 bg-red-500/10", bar: "bg-red-500" },
};

/** A safety gate before the agent sends or runs something: PII redaction, spend cap and permission checks with Passed / Redacted / Blocked states. */
export function GuardrailCheck({
  title = "Pre-send safety check",
  rules: rulesProp,
  message = MESSAGE,
  latencyMs = 12,
  onRequestAccess,
  autoPlay = false,
  className,
}: GuardrailCheckProps) {
  const reduce = useReducedMotion() ?? false;
  const demo = autoPlay && !reduce && !rulesProp;
  const { ref, t, cycle } = useDemoClock(demo, LOOP);
  const base = rulesProp ?? rulesFor(demo ? cycle % 2 === 1 : false);
  const rules = demo ? base.map((r, i) => (t < RESULT_AT[i] ? { ...r, state: "pending" as const } : r)) : base;
  const scanning = demo ? rules.findIndex((r) => r.state === "pending") : -1;
  const piiDone = rules.find((r) => r.kind === "pii")?.state !== "pending";
  const verdict = verdictOf(rules);
  const done = rules.filter((r) => r.state !== "pending").length;

  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-center gap-3 bg-gradient-to-b from-muted/50 to-transparent px-4 pt-3 pb-2.5">
          <span className="relative grid size-9 shrink-0 place-items-center">
            {verdict === "scanning" && !reduce && <span className="gc-scan absolute inset-0 rounded-xl" aria-hidden />}
            <span className="absolute inset-[2px] rounded-[10px] border bg-background shadow-xs" />
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={verdict === "blocked" ? "x" : "ok"}
                initial={reduce ? false : { scale: 0.4, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                exit={reduce ? undefined : { scale: 0.4, opacity: 0 }}
                transition={{ type: "spring", stiffness: 500, damping: 20 }}
                className="relative"
              >
                {verdict === "blocked" ? (
                  <ShieldX className="size-4 text-red-600 dark:text-red-400" aria-hidden />
                ) : (
                  <ShieldCheck className={cn("size-4", verdict === "scanning" ? "text-muted-foreground" : "text-emerald-600 dark:text-emerald-400")} aria-hidden />
                )}
              </motion.span>
            </AnimatePresence>
          </span>
          <div className="min-w-0 flex-1">
            <p className="truncate font-medium leading-tight">{title}</p>
            <p className="mt-0.5 font-mono text-[10px] text-muted-foreground tabular-nums">
              {done}/{rules.length} checks · {latencyMs}ms
            </p>
          </div>
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={verdict}
              role="status"
              initial={reduce ? false : { opacity: 0, scale: 0.8, y: 4 }}
              animate={{ opacity: 1, scale: 1, y: 0 }}
              exit={reduce ? undefined : { opacity: 0, scale: 0.8 }}
              transition={{ type: "spring", stiffness: 500, damping: 26 }}
              className={cn("inline-flex h-6 shrink-0 items-center rounded-full border px-2 text-[11px] font-medium", VERDICT[verdict].cls)}
            >
              {VERDICT[verdict].label}
            </motion.span>
          </AnimatePresence>
        </div>

        {/* Segmented progress */}
        <div className="flex gap-1 px-4 pb-3" aria-hidden>
          {rules.map((r, i) => (
            <span key={r.id} className="relative h-1 flex-1 overflow-hidden rounded-full bg-muted">
              <motion.span
                className={cn("absolute inset-y-0 left-0 rounded-full", STATE[r.state].bar)}
                initial={false}
                animate={{ width: r.state !== "pending" ? "100%" : scanning === i ? "60%" : "0%" }}
                transition={{ duration: scanning === i ? 1 : 0.35, ease: EASE }}
              />
            </span>
          ))}
        </div>

        {/* Outgoing message */}
        <div className="mx-3 rounded-xl border bg-background px-3 py-2.5">
          <p className="mb-1 font-mono text-[9px] tracking-wider text-muted-foreground uppercase">Outgoing message</p>
          <p className="text-xs leading-relaxed">
            {message.split(/(\[\[[^\]]+\]\])/).map((part, i) =>
              part.startsWith("[[") ? <Redact key={i} text={part.slice(2, -2)} on={piiDone} reduce={reduce} /> : <span key={i}>{part}</span>,
            )}
          </p>
        </div>

        {/* Checks */}
        <ul className="px-1.5 pt-1.5 pb-1.5">
          {rules.map((r, i) => {
            const Icon = ICONS[r.kind ?? "pii"];
            const active = scanning === i;
            return (
              <li key={r.id} className={cn("flex items-center gap-3 rounded-lg px-2.5 py-2 transition-colors duration-300", active && "bg-muted/60")}>
                <span className={cn("grid size-7 shrink-0 place-items-center rounded-lg border bg-background", r.state === "pending" && !active && "opacity-50")}>
                  <Icon className="size-3.5 text-muted-foreground" aria-hidden />
                </span>
                <div className="min-w-0 flex-1">
                  <p className="truncate text-xs font-medium">{r.label}</p>
                  {r.meter && r.state !== "pending" ? (
                    <div className="mt-1 flex items-center gap-2">
                      <span className="relative h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
                        <motion.span
                          className="absolute inset-y-0 left-0 rounded-full bg-foreground/70"
                          initial={reduce ? false : { width: 0 }}
                          animate={{ width: `${(r.meter.value / r.meter.max) * 100}%` }}
                          transition={{ duration: 0.8, ease: EASE }}
                        />
                      </span>
                      <span className="shrink-0 font-mono text-[10px] tabular-nums text-muted-foreground">
                        {r.meter.prefix}
                        {r.meter.value} / {r.meter.prefix}
                        {r.meter.max}
                      </span>
                    </div>
                  ) : (
                    <p className={cn("truncate text-[11px] text-muted-foreground", r.state === "blocked" && "text-red-600 dark:text-red-400")}>
                      {r.state === "pending" ? (active ? "Scanning…" : "Queued") : r.detail}
                    </p>
                  )}
                </div>
                <StatePill state={r.state} active={active} reduce={reduce} />
              </li>
            );
          })}
        </ul>

        <AnimatePresence initial={false}>
          {verdict === "blocked" && (
            <motion.div
              initial={reduce ? false : { height: 0, opacity: 0 }}
              animate={{ height: "auto", opacity: 1 }}
              exit={reduce ? undefined : { height: 0, opacity: 0 }}
              transition={{ duration: 0.35, ease: EASE }}
              className="overflow-hidden"
            >
              <div className="flex items-center gap-2 border-t bg-red-500/[0.05] px-4 py-2.5 text-xs">
                <Ban className="size-3.5 shrink-0 text-red-600 dark:text-red-400" aria-hidden />
                <span className="min-w-0 flex-1 truncate">Not sent. A billing admin must approve this refund.</span>
                <button
                  type="button"
                  onClick={onRequestAccess}
                  className="h-7 shrink-0 cursor-pointer rounded-md bg-foreground px-2.5 text-[11px] font-medium text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring"
                >
                  Request access
                </button>
              </div>
            </motion.div>
          )}
        </AnimatePresence>
      </div>
      <style>{`
        .gc-scan { background: conic-gradient(from 0deg, transparent 0 70%, color-mix(in oklab, var(--foreground) 45%, transparent)); animation: gc-spin 1s linear infinite; }
        @keyframes gc-spin { to { transform: rotate(360deg); } }
      `}</style>
    </div>
  );
}

function Redact({ text, on, reduce }: { text: string; on: boolean; reduce: boolean }) {
  return (
    <span className="relative inline-block whitespace-nowrap">
      <span className={cn("transition-opacity duration-300", on && "opacity-0")}>{text}</span>
      <motion.span
        aria-hidden={!on}
        className="absolute inset-x-0 top-[2px] bottom-[2px] origin-left rounded-[3px] bg-foreground"
        initial={false}
        animate={{ scaleX: on ? 1 : 0 }}
        transition={reduce ? { duration: 0 } : { duration: 0.45, ease: EASE }}
      />
      {on && (
        <motion.span
          initial={reduce ? false : { opacity: 0 }}
          animate={{ opacity: 1 }}
          transition={{ delay: 0.35 }}
          className="absolute inset-0 grid place-items-center font-mono text-[9px] tracking-wider text-background uppercase"
        >
          {text.includes("@") ? "email" : "card ••" + text.slice(-4)}
        </motion.span>
      )}
    </span>
  );
}

function StatePill({ state, active, reduce }: { state: GuardrailState; active: boolean; reduce: boolean }) {
  return (
    <AnimatePresence mode="popLayout" initial={false}>
      <motion.span
        key={state}
        initial={reduce ? false : { opacity: 0, scale: 0.6 }}
        animate={{ opacity: 1, scale: 1 }}
        exit={reduce ? undefined : { opacity: 0, scale: 0.6 }}
        transition={{ type: "spring", stiffness: 600, damping: 22 }}
        className={cn("inline-flex h-6 shrink-0 items-center gap-1 rounded-full px-2 text-[11px] font-medium", STATE[state].cls)}
      >
        {state === "pending" ? (
          active ? (
            <span className={cn("size-2.5 rounded-full border-[1.5px] border-foreground/20 border-t-foreground", !reduce && "animate-spin")} />
          ) : (
            <span className="size-1.5 rounded-full bg-muted-foreground/40" />
          )
        ) : state === "passed" ? (
          <Check className="size-3" strokeWidth={3} aria-hidden />
        ) : state === "redacted" ? (
          <EyeOff className="size-3" aria-hidden />
        ) : (
          <Ban className="size-3" aria-hidden />
        )}
        {STATE[state].label}
      </motion.span>
    </AnimatePresence>
  );
}

export default function GuardrailCheckDemo() {
  return (
    <div className="flex h-[380px] w-full items-start justify-center pt-2">
      <GuardrailCheck autoPlay />
    </div>
  );
}
Sign in