Safety and policy verification card showing PII masking, prompt-injection screening and role/spend boundary checks before an agent executes.
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}
1"use client";23import { GuardrailCheck } from "@/components/nextui/guardrail-check";45export function SafetyAudit() {6return <GuardrailCheck title="Guardrails passed" latencyMs={12} />;7}
| Prop | Type | Default | Description |
|---|---|---|---|
| title | string | "Pre-send safety check" | Header label |
| rules | GuardrailRule[] | – | Policy checks with label, detail and state (passed or redacted) |
| latencyMs | number | 12 | Guardrail scan duration in milliseconds |
| message | string | – | Outgoing message that the checks scan; PII is redacted inline |
| onRequestAccess | () => void | – | Called from the Request access button when an RBAC rule blocks |
| autoPlay | boolean | false | Loop a progressive policy scan demo |
| className | string | – | Extra classes on the wrapper |
"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>
);
}