shadimage
neon-ui
componentapi-key-listsource: ok · hit

ApiKeyList

Create, reveal-once, and revoke Neon API keys (personal and organization), with an inline create flow, a one-time secret reveal, and per-row revoke confirmation.

npx shadcn add "https://ui.neon.com/r/api-key-list.json"
free

Visual style

neon-cyberpunk

Interaction

scroll-reveal

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@hugeicons/react@hugeicons/core-free-icons

Registry dependencies

utilshttps://ui.neon.com/r/button.jsonhttps://ui.neon.com/r/input.jsonhttps://ui.neon.com/r/tooltip.jsonhttps://ui.neon.com/r/skeleton.jsonhttps://ui.neon.com/r/neon-tokens.jsonhttps://ui.neon.com/r/select.json

Files1 · source cached 10/13/2026, 11:42:16 AM

src/components/api-key-list/api-key-list.tsx
"use client";

import {
  Alert02Icon,
  Building01Icon,
  Copy01Icon,
  Delete02Icon,
  Key01Icon,
  PlusSignIcon,
  Tick02Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useRef, useState } from "react";
import type { ComponentProps, FormEvent, ReactNode } from "react";

import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
  Tooltip,
  TooltipContent,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";

const COPY_FLASH_MS = 1500;

/** Where the key lives: a personal account key or an organization key. */
export type ApiKeyScope = "personal" | "organization";

export interface ApiKey {
  id: string;
  name: string;
  scope: ApiKeyScope;
  /** Preformatted creation date, e.g. "Jul 12, 2026". */
  createdAt: string;
  /** Preformatted last use, e.g. "2h ago". Omit for a never-used key. */
  lastUsedAt?: string;
}

const SCOPE_ICON = {
  organization: Building01Icon,
  personal: Key01Icon,
} as const;

const SCOPE_SUBLABEL: Record<ApiKeyScope, string> = {
  organization: "Organization",
  personal: "Personal",
};

const SCOPES: { value: ApiKeyScope; label: string }[] = [
  { label: "personal", value: "personal" },
  { label: "org", value: "organization" },
];

/* ─────────────────────────────────────────────────────────
 * A copy that carries the real token and flashes a primary
 * check, announcing through a polite live region. The
 * "Copied" note masks the value under a gradient so the row
 * never breaks.
 * ───────────────────────────────────────────────────────── */
const CopyButton = ({ value, label }: { value: string; label: string }) => {
  const [copied, setCopied] = useState(false);

  const copy = async () => {
    setCopied(true);
    window.setTimeout(() => setCopied(false), COPY_FLASH_MS);

    try {
      await navigator.clipboard.writeText(value);
    } catch {
      // Clipboard unavailable (blur, permissions); the feedback still shows.
    }
  };

  return (
    <div className="relative flex shrink-0 items-center">
      <span
        aria-hidden="true"
        className={cn(
          "pointer-events-none absolute inset-y-0 right-full flex items-center bg-gradient-to-r from-transparent via-card to-card pr-2 pl-8 font-mono text-[10px] text-primary transition-all duration-200 ease-out motion-reduce:transition-none",
          copied ? "translate-x-0 opacity-100" : "translate-x-1 opacity-0"
        )}
      >
        Copied
      </span>
      <Tooltip>
        <TooltipTrigger
          render={
            <Button
              aria-label={label}
              onClick={copy}
              size="icon-xs"
              type="button"
              variant="ghost"
            >
              <HugeiconsIcon
                className={cn("transition-colors", copied && "text-primary")}
                icon={copied ? Tick02Icon : Copy01Icon}
                strokeWidth={2}
              />
            </Button>
          }
        />
        <TooltipContent>{label}</TooltipContent>
      </Tooltip>
      <span aria-live="polite" className="sr-only">
        {copied ? `${label} copied` : ""}
      </span>
    </div>
  );
};

const ScopeToggle = ({
  scope,
  onSelect,
}: {
  scope: ApiKeyScope;
  onSelect: (scope: ApiKeyScope) => void;
}) => (
  <fieldset className="m-0 inline-flex shrink-0 rounded-md border border-border/60 p-0.5">
    <legend className="sr-only">Key scope</legend>
    {SCOPES.map((entry) => (
      <button
        aria-pressed={scope === entry.value}
        className={cn(
          "rounded-[calc(var(--radius-md)-3px)] px-2 py-0.5 font-mono text-[11px] transition-colors",
          scope === entry.value
            ? "bg-primary/10 font-medium text-primary"
            : "text-muted-foreground hover:text-foreground"
        )}
        key={entry.value}
        onClick={() => onSelect(entry.value)}
        type="button"
      >
        {entry.label}
      </button>
    ))}
  </fieldset>
);

type ScopeFilterValue = ApiKeyScope | "all";

const ScopeFilter = ({
  value,
  onChange,
}: {
  value: ScopeFilterValue;
  onChange: (value: ScopeFilterValue) => void;
}) => (
  <Select
    onValueChange={(next) => {
      if (next !== null) {
        onChange(next as ScopeFilterValue);
      }
    }}
    value={value}
  >
    <SelectTrigger
      aria-label="Filter by scope"
      className="h-7 rounded-md border-border/60 bg-muted/20 font-mono text-xs transition-colors hover:border-border hover:bg-muted/40"
      size="sm"
    >
      <SelectValue />
    </SelectTrigger>
    <SelectContent>
      <SelectItem className="font-mono text-xs" value="all">
        all
      </SelectItem>
      <SelectItem className="font-mono text-xs" value="personal">
        personal
      </SelectItem>
      <SelectItem className="font-mono text-xs" value="organization">
        org
      </SelectItem>
    </SelectContent>
  </Select>
);

/* The one-time reveal: the full token, a copy, and a warning that it
 * will never be shown again. Recessed well so it reads as a secret. */
const RevealOncePanel = ({
  name,
  secret,
  onDone,
}: {
  name: string;
  secret: string;
  onDone: () => void;
}) => (
  <div
    className="fade-in-0 slide-in-from-top-1 animate-in space-y-2 rounded-md border border-primary/30 bg-background p-3 duration-200 motion-reduce:animate-none"
    data-slot="api-key-reveal"
  >
    <div className="flex items-center gap-2 text-primary">
      <HugeiconsIcon className="size-3.5" icon={Tick02Icon} strokeWidth={2} />
      <p className="min-w-0 truncate font-medium text-xs">{name} created</p>
    </div>
    <div className="flex min-w-0 items-center gap-1 rounded-md border border-border/60 bg-card py-1.5 pr-1 pl-2.5">
      <code className="min-w-0 flex-1 truncate font-mono text-[11px]">
        {secret}
      </code>
      <CopyButton label="Copy API key" value={secret} />
    </div>
    <div className="flex items-center justify-between gap-2">
      <p className="flex items-center gap-1.5 text-[10px] text-muted-foreground">
        <HugeiconsIcon
          className="size-3 shrink-0"
          icon={Alert02Icon}
          strokeWidth={2}
        />
        Copy it now. You will not see this key again.
      </p>
      <Button className="shrink-0" onClick={onDone} size="xs" variant="ghost">
        Done
      </Button>
    </div>
  </div>
);

const CreateForm = ({
  pending,
  onSubmit,
  onCancel,
}: {
  pending: boolean;
  onSubmit: (name: string, scope: ApiKeyScope) => void;
  onCancel: () => void;
}) => {
  const [name, setName] = useState("");
  const [scope, setScope] = useState<ApiKeyScope>("personal");

  const submit = (event: FormEvent) => {
    event.preventDefault();

    if (name.trim()) {
      onSubmit(name.trim(), scope);
    }
  };

  return (
    <form
      className="fade-in-0 slide-in-from-top-1 flex animate-in flex-wrap items-center gap-2 duration-200 motion-reduce:animate-none"
      onSubmit={submit}
    >
      <Input
        className="h-8 min-w-40 flex-1 rounded-md font-mono text-xs focus-visible:border-primary/50 focus-visible:ring-[3px] focus-visible:ring-primary/15"
        disabled={pending}
        onChange={(event) => setName(event.target.value)}
        placeholder="Key name"
        value={name}
      />
      <ScopeToggle onSelect={setScope} scope={scope} />
      <Button
        disabled={pending || name.trim().length === 0}
        size="sm"
        type="submit"
      >
        {pending ? "Creating\u2026" : "Create"}
      </Button>
      <Button
        disabled={pending}
        onClick={onCancel}
        size="sm"
        type="button"
        variant="ghost"
      >
        Cancel
      </Button>
    </form>
  );
};

const HOLD_MS = 1000;
const RELEASE_MS = 180;

/* Hold-to-confirm: a destructive fill sweeps left to right for HOLD_MS
 * (linear, it is progress), revealing a white copy of the label under the
 * same clip-path. Releasing early rewinds it. Pointer and keyboard both work.
 * Same grammar as the confirm-dialog. */
const HoldButton = ({
  label,
  onHold,
}: {
  label: string;
  onHold: () => void;
}) => {
  const [holding, setHolding] = useState(false);
  const timerRef = useRef(0);

  const cancel = () => {
    window.clearTimeout(timerRef.current);
    setHolding(false);
  };

  const start = () => {
    setHolding(true);
    timerRef.current = window.setTimeout(() => {
      setHolding(false);
      onHold();
    }, HOLD_MS);
  };

  useEffect(() => () => window.clearTimeout(timerRef.current), []);

  return (
    <Button
      className="relative select-none overflow-hidden border border-destructive/50 bg-destructive/10 text-destructive hover:bg-destructive/15 hover:text-destructive"
      data-holding={holding || undefined}
      onKeyDown={(event) => {
        if (event.repeat || !(event.key === "Enter" || event.key === " ")) {
          return;
        }

        event.preventDefault();

        if (!holding) {
          start();
        }
      }}
      onKeyUp={cancel}
      onPointerCancel={cancel}
      onPointerDown={start}
      onPointerLeave={cancel}
      onPointerUp={cancel}
      size="xs"
      type="button"
      variant="ghost"
    >
      <span className="relative">{label}</span>
      <span
        aria-hidden="true"
        className="absolute inset-0 flex items-center justify-center bg-destructive text-destructive-foreground"
        style={{
          clipPath: holding ? "inset(0 0% 0 0)" : "inset(0 100% 0 0)",
          transition: `clip-path ${holding ? HOLD_MS : RELEASE_MS}ms ${
            holding ? "linear" : "ease-out"
          }`,
        }}
      >
        {label}
      </span>
    </Button>
  );
};

const KeyRow = ({
  apiKey,
  revoking,
  onAskRevoke,
  onCancelRevoke,
  onRevoke,
}: {
  apiKey: ApiKey;
  revoking: boolean;
  onAskRevoke: () => void;
  onCancelRevoke: () => void;
  onRevoke: () => void;
}) => (
  <li
    className={cn(
      "group relative flex items-center gap-3 border-border/60 border-t py-3 transition-colors first:border-t-0 hover:bg-muted/20",
      // Hold the hover tint for the whole confirm so the mask matches.
      revoking && "bg-muted/20"
    )}
    data-slot="api-key-row"
  >
    <span className="flex size-8 shrink-0 items-center justify-center rounded-full border border-border/60 bg-background text-muted-foreground">
      <HugeiconsIcon
        className="size-3.5"
        icon={SCOPE_ICON[apiKey.scope]}
        strokeWidth={2}
      />
    </span>

    <div className="min-w-0 flex-1">
      <p className="truncate font-mono font-medium text-foreground text-sm">
        {apiKey.name}
      </p>
      <p className="mt-0.5 text-[11px] text-muted-foreground/70">
        {SCOPE_SUBLABEL[apiKey.scope]}
      </p>
    </div>

    <div className="shrink-0 text-right">
      <p className="font-mono text-[11px] text-muted-foreground tabular-nums">
        {`created ${apiKey.createdAt}`}
      </p>
      <p className="text-[10px] text-muted-foreground/60">
        {apiKey.lastUsedAt ? `used ${apiKey.lastUsedAt}` : "never used"}
      </p>
    </div>

    <div className="flex shrink-0 justify-end">
      <Tooltip>
        <TooltipTrigger
          render={
            <Button
              aria-label={`Revoke ${apiKey.name}`}
              className="text-muted-foreground/40 transition-colors hover:text-destructive group-hover:text-muted-foreground/70"
              onClick={onAskRevoke}
              size="icon-xs"
              type="button"
              variant="ghost"
            >
              <HugeiconsIcon icon={Delete02Icon} strokeWidth={2} />
            </Button>
          }
        />
        <TooltipContent>Revoke</TooltipContent>
      </Tooltip>
    </div>

    {revoking ? (
      <div className="fade-in-0 slide-in-from-right-2 absolute inset-y-0 right-0 flex animate-in items-center gap-1.5 bg-gradient-to-l from-[color-mix(in_srgb,var(--muted)_20%,var(--card))] via-[85%] via-[color-mix(in_srgb,var(--muted)_20%,var(--card))] to-transparent pl-16 duration-200 motion-reduce:animate-none">
        <Button onClick={onCancelRevoke} size="xs" variant="ghost">
          Cancel
        </Button>
        <HoldButton label="Hold to revoke" onHold={onRevoke} />
      </div>
    ) : null}
  </li>
);

export type ApiKeyListProps = Omit<
  ComponentProps<"div">,
  "children" | "onChange"
> & {
  /** The keys to list, in display order. */
  keys: ApiKey[];
  /**
   * Create a key of the chosen scope; return the full token to reveal once.
   * The create UI hides when omitted.
   */
  onCreate?: (name: string, scope: ApiKeyScope) => Promise<string> | string;
  /** Revoke a key. Receives the whole key so the scope is known. */
  onRevoke?: (key: ApiKey) => Promise<void> | void;
  /** Card title. */
  label?: string;
  isLoading?: boolean;
  error?: Error | string | null;
};

const cardClassName =
  "flex w-full min-w-0 flex-col rounded-lg border border-border/60 bg-card p-4 shadow-none ring-0";

const Header = ({
  label,
  actions,
}: {
  label: string;
  actions?: ComponentProps<"div">["children"];
}) => (
  <div className="flex items-center justify-between gap-3">
    <p className="font-medium text-foreground text-sm">{label}</p>
    {actions ? (
      <div className="flex shrink-0 items-center gap-2">{actions}</div>
    ) : null}
  </div>
);

const emptyMessage = (total: number, filter: ScopeFilterValue) => {
  if (total === 0) {
    return "No API keys yet.";
  }

  return `No ${filter === "organization" ? "organization" : "personal"} keys.`;
};

const HeaderActions = ({
  showFilter,
  filter,
  onFilter,
  showCreate,
  onCreate,
}: {
  showFilter: boolean;
  filter: ScopeFilterValue;
  onFilter: (value: ScopeFilterValue) => void;
  showCreate: boolean;
  onCreate: () => void;
}) => (
  <>
    {showFilter ? <ScopeFilter onChange={onFilter} value={filter} /> : null}
    {showCreate ? (
      <Button onClick={onCreate} size="sm">
        <HugeiconsIcon icon={PlusSignIcon} strokeWidth={2} />
        New key
      </Button>
    ) : null}
  </>
);

/**
 * Animate the body's height as it changes (create form opens, the reveal
 * panel appears, rows are added or revoked), so the card grows and shrinks
 * smoothly instead of jumping.
 */
const AnimatedHeight = ({ children }: { children: ReactNode }) => {
  const innerRef = useRef<HTMLDivElement>(null);
  const lastRef = useRef<number | null>(null);
  const [height, setHeight] = useState<number>();
  const [animating, setAnimating] = useState(false);

  useEffect(() => {
    const inner = innerRef.current;

    if (!inner) {
      return;
    }

    const observer = new ResizeObserver(() => {
      const next = inner.offsetHeight;

      if (lastRef.current !== null && lastRef.current !== next) {
        setAnimating(true);
      }

      lastRef.current = next;
      setHeight(next);
    });

    observer.observe(inner);
    lastRef.current = inner.offsetHeight;
    setHeight(inner.offsetHeight);

    return () => observer.disconnect();
  }, []);

  return (
    <div
      className={cn(
        "transition-[height] duration-300 ease-out motion-reduce:transition-none",
        // Clip only while the height animates; let focus rings and shadows
        // show once it settles.
        animating ? "overflow-hidden" : "overflow-visible"
      )}
      onTransitionEnd={() => setAnimating(false)}
      style={{ height }}
    >
      <div ref={innerRef}>{children}</div>
    </div>
  );
};

const EmptyState = ({
  message,
  showHint,
}: {
  message: string;
  showHint: boolean;
}) => (
  <div className="rounded-md border border-border/60 border-dashed bg-background px-3 py-6 text-center">
    <p className="text-muted-foreground text-xs">{message}</p>
    {showHint ? (
      <p className="mt-1 text-[11px] text-muted-foreground/60">
        Create one to call the Neon API.
      </p>
    ) : null}
  </div>
);

export const ApiKeyList = ({
  keys,
  onCreate,
  onRevoke,
  label = "API keys",
  isLoading = false,
  error = null,
  className,
  ...props
}: ApiKeyListProps) => {
  const [creating, setCreating] = useState(false);
  const [pending, setPending] = useState(false);
  const [created, setCreated] = useState<{ name: string; secret: string }>();
  const [revokingId, setRevokingId] = useState<string>();
  const [filter, setFilter] = useState<ScopeFilterValue>("all");

  const handleCreate = async (name: string, scope: ApiKeyScope) => {
    if (!onCreate) {
      return;
    }

    setPending(true);

    try {
      const secret = await onCreate(name, scope);
      setCreated({ name, secret });
      setCreating(false);
    } finally {
      setPending(false);
    }
  };

  const handleRevoke = async (key: ApiKey) => {
    setRevokingId(undefined);
    await onRevoke?.(key);
  };

  if (isLoading) {
    return (
      <div
        aria-busy="true"
        className={cn(cardClassName, className)}
        data-slot="api-key-list"
        {...props}
      >
        <Header label={label} />
        <div className="mt-3 space-y-3">
          <Skeleton aria-hidden="true" className="h-10 w-full" />
          <Skeleton aria-hidden="true" className="h-10 w-full" />
        </div>
      </div>
    );
  }

  if (error) {
    const message = typeof error === "string" ? error : error.message;

    return (
      <div
        className={cn(cardClassName, className)}
        data-slot="api-key-list"
        role="alert"
        {...props}
      >
        <Header label={label} />
        <p className="mt-3 flex items-baseline gap-2 text-sm">
          <span className="font-mono text-destructive text-xs">error</span>
          <span className="text-foreground">{message}</span>
        </p>
      </div>
    );
  }

  const hasOrg = keys.some((key) => key.scope === "organization");
  const hasPersonal = keys.some((key) => key.scope === "personal");
  const showFilter = hasOrg && hasPersonal;
  const filtered =
    filter === "all" ? keys : keys.filter((key) => key.scope === filter);
  const showCreateButton = Boolean(onCreate) && !(creating || created);
  const showEmpty = filtered.length === 0 && !(creating || created);

  return (
    <div
      className={cn(cardClassName, className)}
      data-slot="api-key-list"
      {...props}
    >
      <TooltipProvider>
        <Header
          actions={
            <HeaderActions
              filter={filter}
              onCreate={() => setCreating(true)}
              onFilter={setFilter}
              showCreate={showCreateButton}
              showFilter={showFilter}
            />
          }
          label={label}
        />

        <AnimatedHeight>
          <div className="flex flex-col gap-3 pt-3">
            {creating ? (
              <CreateForm
                onCancel={() => setCreating(false)}
                onSubmit={handleCreate}
                pending={pending}
              />
            ) : null}

            {created ? (
              <RevealOncePanel
                name={created.name}
                onDone={() => setCreated(undefined)}
                secret={created.secret}
              />
            ) : null}

            {filtered.length > 0 ? (
              <ul>
                {filtered.map((apiKey) => (
                  <KeyRow
                    apiKey={apiKey}
                    key={apiKey.id}
                    onAskRevoke={() => setRevokingId(apiKey.id)}
                    onCancelRevoke={() => setRevokingId(undefined)}
                    onRevoke={() => handleRevoke(apiKey)}
                    revoking={revokingId === apiKey.id}
                  />
                ))}
              </ul>
            ) : null}

            {showEmpty ? (
              <EmptyState
                message={emptyMessage(keys.length, filter)}
                showHint={Boolean(onCreate) && keys.length === 0}
              />
            ) : null}
          </div>
        </AnimatedHeight>
      </TooltipProvider>
    </div>
  );
};

More from neon-ui

Browse all components in this library →