Headless browser viewport for a computer-use AI agent: live address bar, animated agent cursor with click ripples, step progress dots, and a Take over button for manual human input.
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/browser-agent
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 { BrowserAgent, type BrowserAgentStep } from "@/components/nextui/browser-agent";45export function WebOperator({ steps }: { steps: BrowserAgentStep[] }) {6return (7<BrowserAgent8url="https://portal.northwind.test/orders"9steps={steps}10onToggleControl={(manual) => console.log("Manual takeover:", manual)}11/>12);13}
| Prop | Type | Default | Description |
|---|---|---|---|
| url | string | "https://portal.northwind.test/orders" | Current URL shown in the address bar |
| status | "running" | "paused" | "done" | – | Execution state of the browser session |
| steps | BrowserAgentStep[] | – | Sequence of navigation, click, type and extract steps with cursor coordinates |
| onToggleControl | (userControlling: boolean) => void | – | Called when the user clicks Take over or Resume |
| screenshot | ReactNode | – | Custom page content rendered inside the viewport instead of the scripted scene |
| autoPlay | boolean | false | Loop the scripted browser session |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Camera, Check, Hand, Lock, MousePointer2, Play, RotateCw, ShieldAlert } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type BrowserAgentStep = {
id: string;
action: "navigate" | "click" | "type" | "extract" | "wait" | "done";
label: string;
url?: string;
/** Screenshot of the page after this step, shown in the Steps view. */
screenshot?: string;
};
export type BrowserAgentProps = {
url?: string;
status?: "running" | "paused" | "done";
/** Action log. Leave unset for the built-in scripted demo. */
steps?: BrowserAgentStep[];
/** Live frame of the remote browser (image URL). Leave unset to show the demo page. */
screenshot?: string;
/** Called when the user takes over (true) or hands control back (false). */
onToggleControl?: (userControlling: boolean) => void;
/** Loop a scripted sign-in → 2FA → extract demo. */
autoPlay?: boolean;
className?: string;
};
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 12400;
const HOST = "portal.northwind.test";
const EMAIL = "ops@studio.test";
const SCRIPT: (BrowserAgentStep & { at: number })[] = [
{ id: "1", at: 300, action: "navigate", label: `Opened ${HOST}/login`, url: `https://${HOST}/login` },
{ id: "2", at: 2700, action: "type", label: `Typed “${EMAIL}” into Email` },
{ id: "3", at: 3500, action: "click", label: "Clicked “Sign in”" },
{ id: "4", at: 3900, action: "wait", label: "2FA required · handed to you" },
{ id: "5", at: 6600, action: "navigate", label: `Opened ${HOST}/orders`, url: `https://${HOST}/orders` },
{ id: "6", at: 9300, action: "extract", label: "Extracted 12 rows" },
];
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 };
}
type Box = { x: number; y: number; w: number; h: number; label: string };
type Scene = {
url: string;
loading: number | null;
page: "login" | "2fa" | "orders";
email: string;
code: number;
cursor: [number, number];
click: number | null;
box: Box | null;
human: boolean;
scan: number;
extracted: boolean;
};
const EMAIL_BOX: Box = { x: 30, y: 37, w: 40, h: 13, label: "input#email" };
const BUTTON_BOX: Box = { x: 30, y: 70, w: 40, h: 13, label: "button “Sign in”" };
const TABLE_BOX: Box = { x: 3, y: 31, w: 94, h: 56, label: "table.orders · 12 rows" };
function sceneAt(t: number): Scene {
const s: Scene = {
url: `https://${HOST}/login`,
loading: t < 1000 ? Math.min(1, t / 900) : null,
page: "login",
email: "",
code: 0,
cursor: [82, 14],
click: null,
box: null,
human: false,
scan: -1,
extracted: false,
};
if (t >= 1300) s.cursor = [44, 44];
if (t >= 1300 && t < 2800) s.box = EMAIL_BOX;
if (t >= 1800) s.click = 1800;
if (t >= 1900) s.email = EMAIL.slice(0, Math.min(EMAIL.length, Math.floor((t - 1900) / 50) + 1));
if (t >= 2900) {
s.cursor = [52, 77];
s.box = BUTTON_BOX;
}
if (t >= 3500) s.click = 3500;
if (t >= 3900) {
s.page = "2fa";
s.box = null;
s.cursor = [74, 30];
}
if (t >= 4900 && t < 6200) s.human = true;
if (t >= 5100) s.code = Math.min(6, Math.floor((t - 5100) / 150) + 1);
if (t >= 6600) {
s.page = "orders";
s.url = `https://${HOST}/orders`;
s.loading = t < 7300 ? Math.min(1, (t - 6600) / 600) : null;
s.cursor = [70, 16];
}
if (t >= 7500) {
s.cursor = [22, 37];
s.box = TABLE_BOX;
}
if (t >= 7700) {
s.scan = Math.min(4, Math.floor((t - 7700) / 320));
s.cursor = [22, 37 + s.scan * 10];
}
if (t >= 9300) {
s.extracted = true;
s.box = null;
s.scan = -1;
s.cursor = [62, 8];
}
return s;
}
const FINAL = sceneAt(LOOP - 100);
const SNAP_AT: Record<string, number> = { "1": 1200, "2": 2800, "3": 3600, "4": 4500, "5": 7400, "6": 9600 };
/** A browser viewport for a computer-use agent: URL bar, a moving agent cursor with click ripples, a live action log, Take over for CAPTCHA / 2FA, and a step history. */
export function BrowserAgent({
url,
status: controlledStatus,
steps: stepsProp,
screenshot,
onToggleControl,
autoPlay = false,
className,
}: BrowserAgentProps) {
const reduce = useReducedMotion() ?? false;
const uid = useId();
const [touched, setTouched] = useState(false);
const [userControl, setUserControl] = useState(false);
const [tab, setTab] = useState<"live" | "steps">("live");
const [picked, setPicked] = useState<string | null>(null);
const demo = autoPlay && !reduce && !touched;
const { ref, t } = useDemoClock(demo, LOOP);
const liveScene = demo ? sceneAt(t) : FINAL;
const steps: BrowserAgentStep[] = stepsProp ?? (demo ? SCRIPT.filter((s) => t >= s.at) : SCRIPT);
const pickedStep = steps.find((s) => s.id === picked) ?? steps[steps.length - 1];
const viewing = tab === "steps" && pickedStep;
const scene = viewing && SNAP_AT[pickedStep.id] !== undefined && !stepsProp ? { ...sceneAt(SNAP_AT[pickedStep.id]), click: null, loading: null } : liveScene;
const human = userControl || (!viewing && scene.human);
const needsHuman = !viewing && scene.page === "2fa" && !human;
const shownUrl = viewing ? (pickedStep.url ?? scene.url) : (url ?? scene.url);
const status =
controlledStatus ?? (human || needsHuman ? "paused" : demo ? (scene.extracted ? "done" : "running") : "done");
const toggleControl = () => {
setTouched(true);
const next = !userControl;
setUserControl(next);
onToggleControl?.(next);
};
const lastLog = steps.slice(-3);
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)]">
{/* Chrome */}
<div className="relative flex items-center gap-2 border-b bg-gradient-to-b from-muted/60 to-muted/20 px-3 py-2">
<div className="flex gap-1.5" aria-hidden>
<span className="size-2.5 rounded-full bg-foreground/15" />
<span className="size-2.5 rounded-full bg-foreground/15" />
<span className="size-2.5 rounded-full bg-foreground/15" />
</div>
<div className="flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded-lg border bg-background px-2 shadow-xs">
<Lock className="size-3 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden />
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={shownUrl}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -6 }}
transition={{ duration: 0.25, ease: EASE }}
className="truncate font-mono text-[11px]"
>
<span className="text-foreground">{shownUrl.replace(/^https?:\/\//, "").split("/")[0]}</span>
<span className="text-muted-foreground">/{shownUrl.replace(/^https?:\/\//, "").split("/").slice(1).join("/")}</span>
</motion.span>
</AnimatePresence>
<RotateCw className={cn("ml-auto size-3 shrink-0 text-muted-foreground", scene.loading !== null && !reduce && "animate-spin")} aria-hidden />
</div>
<div className="relative shrink-0">
{needsHuman && !reduce && <span className="absolute -inset-1 animate-ping rounded-lg bg-amber-500/30" aria-hidden />}
<button
type="button"
onClick={toggleControl}
className={cn(
"relative inline-flex h-7 cursor-pointer items-center gap-1.5 rounded-lg border px-2.5 text-xs font-medium shadow-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
human ? "border-foreground bg-foreground text-background" : needsHuman ? "border-amber-500/60 bg-amber-500 text-white" : "bg-background hover:bg-muted",
)}
>
{human ? <Play className="size-3" aria-hidden /> : <Hand className="size-3" aria-hidden />}
{human ? "Resume agent" : "Take over"}
</button>
</div>
{scene.loading !== null && (
<span className="absolute inset-x-0 bottom-0 h-0.5 overflow-hidden" aria-hidden>
<span className="block h-full bg-foreground transition-[width] duration-100" style={{ width: `${scene.loading * 100}%` }} />
</span>
)}
</div>
{/* Viewport */}
<div className="relative h-52 overflow-hidden bg-background select-none">
{screenshot ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={screenshot} alt="Live view of the agent's browser" className="size-full object-cover object-top" />
) : (
<DemoPage scene={scene} reduce={reduce} />
)}
{/* Target box */}
<AnimatePresence>
{scene.box && !human && (
<motion.div
key={scene.box.label}
initial={reduce ? false : { opacity: 0, scale: 1.08 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3, ease: EASE }}
className="pointer-events-none absolute rounded-md border-[1.5px] border-dashed border-sky-500 bg-sky-500/[0.06]"
style={{ left: `${scene.box.x}%`, top: `${scene.box.y}%`, width: `${scene.box.w}%`, height: `${scene.box.h}%` }}
>
<span className="absolute -top-[18px] left-0 rounded bg-sky-500 px-1 font-mono text-[9px] leading-4 whitespace-nowrap text-white">
{scene.box.label}
</span>
</motion.div>
)}
</AnimatePresence>
{/* Agent cursor */}
{!human && !viewing && (
<motion.div
className="pointer-events-none absolute z-10"
initial={false}
animate={{ left: `${scene.cursor[0]}%`, top: `${scene.cursor[1]}%` }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 140, damping: 20, mass: 0.8 }}
>
{scene.click !== null && !reduce && (
<span key={scene.click} className="absolute -top-3 -left-3 size-6" aria-hidden>
<span className="ba-ripple absolute inset-0 rounded-full border-2 border-sky-500" />
<span className="ba-ripple absolute inset-0 rounded-full bg-sky-500/30 [animation-delay:90ms]" />
</span>
)}
<MousePointer2 className="size-[18px] -translate-x-[3px] -translate-y-[2px] fill-foreground text-background drop-shadow-[0_2px_3px_rgb(0_0_0/0.3)]" aria-hidden />
<span className="absolute top-3.5 left-3 rounded-full bg-sky-500 px-1.5 py-px text-[9px] font-semibold whitespace-nowrap text-white shadow-sm">Agent</span>
</motion.div>
)}
{/* Human control */}
<AnimatePresence>
{human && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="pointer-events-none absolute inset-0 ring-[3px] ring-amber-500 ring-inset"
>
<span className="absolute top-2 left-1/2 -translate-x-1/2 rounded-full bg-amber-500 px-2.5 py-0.5 text-[10px] font-semibold text-white shadow">
You are in control · agent paused
</span>
</motion.div>
)}
</AnimatePresence>
{viewing && (
<span className="absolute top-2 right-2 inline-flex items-center gap-1 rounded-full bg-foreground/80 px-2 py-0.5 text-[10px] font-medium text-background backdrop-blur">
<Camera className="size-3" aria-hidden />
Step {pickedStep.id} snapshot
</span>
)}
</div>
{/* Log / history */}
<div className="border-t">
<div className="flex items-center justify-between gap-2 px-3 pt-2">
<div className="flex items-center gap-2 text-xs" aria-live="polite">
<StatusDot status={status} reduce={reduce} />
<span className="font-medium">
{status === "done" ? "Task complete" : status === "paused" ? (human ? "Waiting for you" : "Needs you · 2FA code") : "Agent browsing"}
</span>
</div>
<div className="inline-flex rounded-md border bg-muted/50 p-0.5" role="tablist">
{(["live", "steps"] as const).map((k) => (
<button
key={k}
type="button"
role="tab"
aria-selected={tab === k}
onClick={() => {
setTouched(true);
setTab(k);
}}
className={cn(
"relative h-5 cursor-pointer rounded px-2 text-[10px] font-medium capitalize outline-none focus-visible:ring-2 focus-visible:ring-ring",
tab === k ? "text-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
{tab === k && <motion.span layoutId={`${uid}-tab`} className="absolute inset-0 rounded bg-background shadow-xs" transition={{ type: "spring", stiffness: 500, damping: 36 }} />}
<span className="relative">{k}</span>
</button>
))}
</div>
</div>
{tab === "live" ? (
<ol className="h-[66px] space-y-0.5 overflow-hidden px-3 pt-1.5 pb-2 font-mono text-[10.5px]">
<AnimatePresence initial={false} mode="popLayout">
{lastLog.map((s, i) => (
<motion.li
key={s.id}
layout={!reduce}
initial={reduce ? false : { opacity: 0, y: 10 }}
animate={{ opacity: i === lastLog.length - 1 ? 1 : 0.55, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -10 }}
transition={{ duration: 0.3, ease: EASE }}
className="flex items-center gap-2 truncate"
>
<span className="text-muted-foreground">{String(steps.indexOf(s) + 1).padStart(2, "0")}</span>
<span className={cn("truncate", s.action === "extract" && "font-semibold text-emerald-700 dark:text-emerald-400")}>{s.label}</span>
</motion.li>
))}
</AnimatePresence>
</ol>
) : (
<div className="flex h-[66px] gap-1.5 overflow-x-auto px-3 pt-1.5 pb-2 [scrollbar-width:none]">
{steps.map((s) => {
const on = pickedStep?.id === s.id;
return (
<button
key={s.id}
type="button"
onClick={() => setPicked(s.id)}
className={cn(
"flex w-24 shrink-0 cursor-pointer flex-col justify-between rounded-lg border p-1.5 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
on ? "border-foreground/40 bg-muted" : "hover:bg-muted/60",
)}
>
<span className="font-mono text-[9px] text-muted-foreground uppercase">
{s.id} · {s.action}
</span>
<span className="line-clamp-2 text-[10px] leading-tight">{s.label}</span>
</button>
);
})}
</div>
)}
</div>
</div>
<style>{`
.ba-ripple { animation: ba-ripple 0.65s cubic-bezier(0.22, 1, 0.36, 1) forwards; }
@keyframes ba-ripple { from { transform: scale(0.3); opacity: 1; } to { transform: scale(2.4); opacity: 0; } }
`}</style>
</div>
);
}
function StatusDot({ status, reduce }: { status: "running" | "paused" | "done"; reduce: boolean }) {
if (status === "done")
return (
<span className="grid size-4 place-items-center rounded-full bg-emerald-500 text-white">
<Check className="size-2.5" strokeWidth={3} aria-hidden />
</span>
);
const color = status === "paused" ? "bg-amber-500" : "bg-sky-500";
return (
<span className="relative grid size-4 place-items-center">
{!reduce && <span className={cn("absolute size-2.5 animate-ping rounded-full opacity-60", color)} />}
<span className={cn("relative size-2 rounded-full", color)} />
</span>
);
}
const ORDERS = [
["#10482", "Fern & Pine", "$1,240.00", "Paid"],
["#10481", "Harbor Labs", "$318.40", "Paid"],
["#10480", "Quill Studio", "$2,075.00", "Pending"],
["#10479", "Mesa Coffee", "$96.20", "Paid"],
["#10478", "Atlas Freight", "$5,410.00", "Refunded"],
];
function DemoPage({ scene, reduce }: { scene: Scene; reduce: boolean }) {
return (
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={scene.page}
initial={reduce ? false : { opacity: 0, y: 8 }}
animate={{ opacity: scene.loading !== null && scene.loading < 0.5 ? 0.4 : 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0 }}
transition={{ duration: 0.3, ease: EASE }}
className="absolute inset-0 bg-gradient-to-b from-muted/40 to-background"
>
<div className="flex h-[16%] items-center justify-between border-b bg-background/80 px-3">
<span className="flex items-center gap-1.5 text-[11px] font-semibold">
<span className="size-3 rounded-[4px] bg-foreground" />
Northwind
</span>
<span className="flex gap-2" aria-hidden>
<span className="h-1.5 w-8 rounded bg-foreground/10" />
<span className="h-1.5 w-8 rounded bg-foreground/10" />
</span>
</div>
{scene.page === "login" && (
<>
<p className="absolute top-[21%] w-full text-center text-[12px] font-semibold">Sign in to your portal</p>
<div className="absolute flex items-center rounded-md border bg-background px-2 text-[10px]" style={{ left: "30%", top: "37%", width: "40%", height: "13%" }}>
{scene.email ? <span className="font-mono">{scene.email}</span> : <span className="text-muted-foreground">Email</span>}
</div>
<div className="absolute flex items-center rounded-md border bg-background px-2 text-[10px] text-muted-foreground" style={{ left: "30%", top: "53%", width: "40%", height: "13%" }}>
••••••••••
</div>
<div className="absolute grid place-items-center rounded-md bg-foreground text-[10px] font-medium text-background" style={{ left: "30%", top: "70%", width: "40%", height: "13%" }}>
Sign in
</div>
</>
)}
{scene.page === "2fa" && (
<div className="absolute inset-x-[18%] top-[24%] rounded-xl border bg-background p-3 text-center shadow-sm">
<ShieldAlert className="mx-auto size-4 text-amber-500" aria-hidden />
<p className="mt-1 text-[11px] font-semibold">Two-step verification</p>
<p className="text-[9.5px] text-muted-foreground">Enter the code from your authenticator</p>
<div className="mt-2 flex justify-center gap-1">
{Array.from({ length: 6 }, (_, i) => (
<span key={i} className={cn("grid size-5 place-items-center rounded border text-[10px] transition-colors", i < scene.code ? "border-foreground/40 bg-muted" : "")}>
{i < scene.code ? "•" : ""}
</span>
))}
</div>
</div>
)}
{scene.page === "orders" && (
<div className="absolute inset-x-[4%] top-[22%]">
<div className="mb-1 flex items-center justify-between px-1 text-[11px] font-semibold">
Orders
{scene.extracted && (
<motion.span
initial={reduce ? false : { opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
className="inline-flex items-center gap-1 rounded-full bg-emerald-500/15 px-1.5 text-[9px] font-medium text-emerald-700 dark:text-emerald-300"
>
<Check className="size-2.5" aria-hidden />
12 rows extracted
</motion.span>
)}
</div>
<div className="divide-y overflow-hidden rounded-md border bg-background text-[9.5px]">
{ORDERS.map((r, i) => (
<div
key={r[0]}
className={cn(
"grid grid-cols-[52px_1fr_64px_50px] items-center px-2 py-[3px] transition-colors duration-200",
scene.scan === i && "bg-sky-500/15",
scene.extracted && "bg-emerald-500/[0.06]",
)}
>
<span className="font-mono text-muted-foreground">{r[0]}</span>
<span className="truncate">{r[1]}</span>
<span className="text-right font-mono tabular-nums">{r[2]}</span>
<span className="text-right text-muted-foreground">{r[3]}</span>
</div>
))}
</div>
</div>
)}
</motion.div>
</AnimatePresence>
);
}
export default function BrowserAgentDemo() {
return (
<div className="flex h-[380px] w-full items-start justify-center pt-2">
<BrowserAgent autoPlay />
</div>
);
}