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 & AgentsOAuth consent
Copy promptCopy prompt

Customize

App asking for access

App being connected

Loop the scripted demo; it stops on interaction and pauses offscreen

JSX · copied with Copy prompt

<OAuthConsent autoPlay />

OAuth consentNew

Permission card for connecting an agent to an app: scopes with risk levels, optional trims, and an animated connect handshake.

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/oauth-consent
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 { OAuthConsent } from "@/components/nextui/oauth-consent";
4
5export function ConnectDrive() {
6 return (
7 <OAuthConsent
8 agentName="Acme Assistant"
9 appName="Northwind Drive"
10 account="kim@northwind.test"
11 onAllow={(scopes) => startOAuth(scopes)}
12 />
13 );
14}

API reference

Edit props live in the preview above, or see all 11 props
PropTypeDefaultDescription
agentNamestring"Acme Assistant"App asking for access
appNamestring"Northwind Drive"App being connected
accountstring"kim@northwind.test"Signed-in account
scopesOAuthScope[]–Scopes with id, label, description, risk (low, medium, high), required and icon
defaultDeniedstring[]–Optional scopes that start unchecked
status"pending" | "connecting" | "connected" | "denied"–Controlled status
onAllow(scopeIds: string[]) => void–Called with the granted scopes
onDeny() => void–Called when access is denied
onRevoke() => void–Called from Revoke on the connected state
autoPlaybooleanfalseLoop the scripted demo; it stops on interaction and pauses offscreen
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/oauth-consent.tsx
"use client";

import { useEffect, useId, useRef, useState } from "react";
import { Check, Eye, HardDrive, Lock, PencilLine, ShieldCheck, Sparkles, Trash2, Clock, type LucideIcon } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type OAuthScope = {
  id: string;
  label: string;
  description: string;
  risk: "low" | "medium" | "high";
  /** Required scopes can't be unchecked. */
  required?: boolean;
  icon?: LucideIcon;
};

export type OAuthConsentProps = {
  agentName?: string;
  appName?: string;
  account?: string;
  scopes?: OAuthScope[];
  /** Scopes that start unchecked. */
  defaultDenied?: string[];
  /** Controlled status. */
  status?: "pending" | "connecting" | "connected" | "denied";
  onAllow?: (scopeIds: string[]) => void;
  onDeny?: () => void;
  onRevoke?: () => void;
  /** Loop a scripted review → trim scopes → allow → connected demo. */
  autoPlay?: boolean;
  className?: string;
};

const DEFAULT_SCOPES: OAuthScope[] = [
  { id: "read", label: "View files and folders", description: "Search and read documents you can open", risk: "low", required: true, icon: Eye },
  { id: "write", label: "Create and edit documents", description: "Draft files and update ones it created", risk: "medium", icon: PencilLine },
  { id: "delete", label: "Permanently delete files", description: "Cannot be undone from the trash", risk: "high", icon: Trash2 },
  { id: "offline", label: "Work while you're away", description: "Keeps access for scheduled tasks", risk: "low", icon: Clock },
];

const RISK = {
  low: "bg-muted text-muted-foreground",
  medium: "bg-amber-500/10 text-amber-700 dark:text-amber-300",
  high: "bg-rose-500/10 text-rose-700 dark:text-rose-300",
} as const;

const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 9600;

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 Status = "pending" | "connecting" | "connected" | "denied";
type View = { status: Status; denied: string[]; press: "allow" | "delete" | null };

function demoView(t: number): View {
  return {
    status: t >= 4900 ? "connected" : t >= 3500 ? "connecting" : "pending",
    denied: t >= 1900 ? ["delete"] : [],
    press: t >= 1700 && t < 1900 ? "delete" : t >= 3300 && t < 3500 ? "allow" : null,
  };
}

/** Permission card for connecting an agent to an app: scopes with risk levels, optional trims, and a connect → connected handshake. */
export function OAuthConsent({
  agentName = "Acme Assistant",
  appName = "Northwind Drive",
  account = "kim@northwind.test",
  scopes = DEFAULT_SCOPES,
  defaultDenied = [],
  status: statusProp,
  onAllow,
  onDeny,
  onRevoke,
  autoPlay = false,
  className,
}: OAuthConsentProps) {
  const reduce = useReducedMotion() ?? false;
  const uid = useId();
  const [touched, setTouched] = useState(false);
  const demo = autoPlay && !reduce && !touched && !statusProp && scopes === DEFAULT_SCOPES;
  const { ref, t } = useDemoClock(demo, LOOP);
  const [view, setView] = useState<View>({ status: "pending", denied: defaultDenied, press: null });
  // 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));
  }

  const status = statusProp ?? view.status;
  const { denied, press } = view;
  const granted = scopes.filter((s) => s.required || !denied.includes(s.id));
  const busy = status === "connecting";
  const linked = status === "connected";

  useEffect(() => {
    if (demo || statusProp || view.status !== "connecting") return;
    const id = setTimeout(() => setView((v) => ({ ...v, status: "connected" })), reduce ? 0 : 1400);
    return () => clearTimeout(id);
  }, [demo, statusProp, view.status, reduce]);

  const patch = (fn: (v: View) => View) => {
    setTouched(true);
    setView(fn);
  };

  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)]">
        {/* Handshake */}
        <div className="relative flex items-center justify-center gap-0 bg-gradient-to-b from-muted/60 to-transparent px-4 pt-5 pb-3" aria-hidden>
          <span className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_30%,rgb(14_165_233/0.12),transparent_60%)]" />
          <AppTile icon={Sparkles} className="bg-foreground text-background" pulse={busy && !reduce} />
          <span className="relative mx-1 flex h-10 w-28 items-center">
            <span className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 border-t border-dashed border-foreground/25" />
            <motion.span
              className="absolute top-1/2 left-0 h-0.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-sky-500 via-indigo-500 to-emerald-500"
              initial={false}
              animate={{ width: linked ? "100%" : busy ? "100%" : "0%", opacity: linked || busy ? 1 : 0 }}
              transition={{ duration: busy ? 1.2 : 0.4, ease: EASE }}
            />
            {busy && !reduce && <span className="absolute top-1/2 size-1.5 -translate-y-1/2 animate-[oc-dot_1s_linear_infinite] rounded-full bg-sky-500 shadow-[0_0_8px_2px_rgb(14_165_233/0.6)]" />}
            <motion.span
              className={cn(
                "absolute top-1/2 left-1/2 grid size-8 -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full border bg-background shadow-md",
                linked && "border-emerald-500/40",
              )}
              animate={{ scale: linked ? [1, 1.2, 1] : busy ? 1.08 : 1 }}
              transition={{ duration: 0.45, ease: EASE }}
            >
              <AnimatePresence mode="popLayout" initial={false}>
                <motion.span key={linked ? "ok" : "lock"} initial={reduce ? false : { scale: 0, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={reduce ? undefined : { scale: 0, opacity: 0 }} transition={{ type: "spring", stiffness: 600, damping: 24 }}>
                  {linked ? <Check className="size-4 text-emerald-600 dark:text-emerald-400" strokeWidth={3} /> : <Lock className="size-3.5" />}
                </motion.span>
              </AnimatePresence>
            </motion.span>
          </span>
          <AppTile icon={HardDrive} className="bg-gradient-to-br from-sky-500 to-indigo-600 text-white" pulse={false} />
        </div>

        <div className="px-4 pb-2 text-center">
          <p className="text-sm font-semibold">
            {linked ? (
              <>
                {appName} is connected
              </>
            ) : (
              <>
                {agentName} wants access to {appName}
              </>
            )}
          </p>
          <p className="mt-0.5 truncate text-[11px] text-muted-foreground">
            as <span className="font-mono">{account}</span>
          </p>
        </div>

        <ul className="mx-3 mb-3 divide-y overflow-hidden rounded-xl border bg-background" aria-label="Requested permissions">
          {scopes.map((s, i) => {
            const on = s.required || !denied.includes(s.id);
            const Icon = s.icon ?? ShieldCheck;
            const hide = linked && !on;
            return (
              <motion.li
                key={s.id}
                initial={reduce ? false : { opacity: 0, x: -8 }}
                animate={{ opacity: hide ? 0.35 : 1, x: 0 }}
                transition={{ delay: i * 0.06, duration: 0.35, ease: EASE }}
                className={cn("relative flex items-center gap-3 px-3 py-2", s.risk === "high" && on && !linked && "bg-rose-500/[0.04]")}
              >
                <span className={cn("grid size-7 shrink-0 place-items-center rounded-lg", RISK[s.risk])}>
                  <Icon className="size-3.5" aria-hidden />
                </span>
                <span className="min-w-0 flex-1">
                  <span className={cn("block truncate text-xs font-medium", !on && "text-muted-foreground line-through decoration-foreground/30")}>{s.label}</span>
                  <span className="block truncate text-[10.5px] text-muted-foreground">{s.description}</span>
                </span>
                {s.risk !== "low" && <span className={cn("shrink-0 rounded-full px-1.5 py-px text-[9px] font-medium capitalize", RISK[s.risk])}>{s.risk} risk</span>}
                {linked ? (
                  <span className={cn("grid size-[18px] shrink-0 place-items-center rounded-full", on ? "bg-emerald-500 text-white" : "bg-muted")}>{on && <Check className="size-2.5" strokeWidth={3.5} aria-label="Granted" />}</span>
                ) : (
                  <button
                    type="button"
                    role="checkbox"
                    aria-checked={on}
                    aria-label={s.label}
                    disabled={s.required || busy}
                    title={s.required ? "Required" : undefined}
                    onClick={() => patch((v) => ({ ...v, denied: on ? [...v.denied, s.id] : v.denied.filter((x) => x !== s.id) }))}
                    className={cn(
                      "grid size-[18px] shrink-0 cursor-pointer place-items-center rounded-[5px] border outline-none transition-[background-color,border-color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default",
                      on ? "border-foreground bg-foreground text-background" : "bg-background",
                      s.required && "opacity-50",
                      press === "delete" && s.id === "delete" && "scale-90",
                    )}
                  >
                    <AnimatePresence initial={false}>
                      {on && (
                        <motion.svg viewBox="0 0 12 12" className="size-2.5" initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
                          <motion.path d="M2.5 6.2 5 8.5 9.5 3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.25 }} />
                        </motion.svg>
                      )}
                    </AnimatePresence>
                  </button>
                )}
              </motion.li>
            );
          })}
        </ul>

        <div className="flex h-12 items-center gap-2 border-t px-3">
          <AnimatePresence mode="popLayout" initial={false}>
            {linked ? (
              <motion.div key="on" initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={reduce ? undefined : { opacity: 0, y: -8 }} transition={{ duration: 0.3, ease: EASE }} className="flex min-w-0 flex-1 items-center gap-2" role="status">
                <ShieldCheck className="size-4 shrink-0 text-emerald-600 dark:text-emerald-400" aria-hidden />
                <span className="min-w-0 flex-1 truncate text-xs">
                  {granted.length} of {scopes.length} scopes · <span className="text-muted-foreground">revoke anytime</span>
                </span>
                <button
                  type="button"
                  onClick={() => {
                    onRevoke?.();
                    patch((v) => ({ ...v, status: "pending" }));
                  }}
                  className="h-8 shrink-0 cursor-pointer rounded-lg border px-3 text-xs font-medium text-rose-600 outline-none hover:bg-rose-500/5 focus-visible:ring-2 focus-visible:ring-ring dark:text-rose-400"
                >
                  Revoke
                </button>
              </motion.div>
            ) : status === "denied" ? (
              <motion.p key="no" initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} className="flex-1 text-center text-xs text-muted-foreground" role="status">
                Access denied · nothing was shared
              </motion.p>
            ) : (
              <motion.div key="ask" initial={reduce ? false : { opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={reduce ? undefined : { opacity: 0, y: -8 }} transition={{ duration: 0.3, ease: EASE }} className="flex min-w-0 flex-1 items-center gap-2">
                <span id={`${uid}-n`} className="min-w-0 flex-1 truncate text-[11px] text-muted-foreground">
                  {granted.length} of {scopes.length} permissions
                </span>
                <button
                  type="button"
                  disabled={busy}
                  onClick={() => {
                    onDeny?.();
                    patch((v) => ({ ...v, status: "denied" }));
                  }}
                  className="h-8 shrink-0 cursor-pointer rounded-lg px-3 text-xs font-medium text-muted-foreground outline-none hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
                >
                  Deny
                </button>
                <motion.button
                  type="button"
                  disabled={busy}
                  aria-describedby={`${uid}-n`}
                  onClick={() => {
                    onAllow?.(granted.map((s) => s.id));
                    patch((v) => ({ ...v, status: "connecting" }));
                  }}
                  animate={{ scale: press === "allow" ? 0.94 : 1 }}
                  transition={{ type: "spring", stiffness: 500, damping: 26 }}
                  className="relative inline-flex h-8 shrink-0 cursor-pointer items-center gap-1.5 overflow-hidden rounded-lg bg-foreground px-3.5 text-xs font-medium text-background outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-wait"
                >
                  {busy && !reduce && <span className="absolute inset-0 animate-[oc-shine_1.1s_ease-in-out_infinite] bg-gradient-to-r from-transparent via-background/25 to-transparent" aria-hidden />}
                  <span className="relative">{busy ? "Connecting…" : "Allow"}</span>
                </motion.button>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </div>
      <style>{`@keyframes oc-dot{0%{left:0;opacity:0}15%{opacity:1}85%{opacity:1}100%{left:100%;opacity:0}}@keyframes oc-shine{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}@keyframes oc-pulse{0%{transform:scale(1);opacity:.5}100%{transform:scale(1.5);opacity:0}}`}</style>
    </div>
  );
}

function AppTile({ icon: Icon, className, pulse }: { icon: LucideIcon; className: string; pulse: boolean }) {
  return (
    <span className="relative grid size-12 shrink-0 place-items-center">
      {pulse && <span className="absolute inset-0 animate-[oc-pulse_1.2s_ease-out_infinite] rounded-2xl bg-sky-500/40" />}
      <span className={cn("relative grid size-12 place-items-center rounded-2xl shadow-lg ring-1 ring-black/5", className)}>
        <Icon className="size-5" />
      </span>
    </span>
  );
}

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