# API Keys

> API key manager with reveal/hide, copy-to-clipboard, key metadata and a create action. Ships composable parts.

A shadcn/ui block from the [@hirael](https://hirael.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/hirael/api-keys).

- Page, with a live preview: https://shadcn.exchange/r/hirael/api-keys
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/hirael/api-keys-light-d4b2cab51b2b05bc.png
- Registry item: https://hirael.com/r/api-keys.json
- Categories: blocks, saas
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://hirael.com/r/api-keys.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `button`, `https://hirael.com/r/copy-button.json`, `empty`

## Files

### `registry/hirael/bases/radix/blocks/api-keys/api-keys.tsx`

```tsx
// API Keys from Hirael <https://hirael.com/blocks/saas/api-keys>
// MIT · Mohammad Shehadeh · https://github.com/MohammadShehadeh/hirael

'use client';

import * as React from 'react';
import { Eye, EyeOff, KeyRound, Plus, Trash2 } from 'lucide-react';

import { cn } from '@/lib/utils';
import { CopyButton } from '@/registry/hirael/bases/radix/components/copy-button';
import { Button } from '@/registry/hirael/bases/radix/ui/button';
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/registry/hirael/bases/radix/ui/empty';

type ApiKeysProps = React.ComponentProps<'div'>;

const ApiKeys = ({ className, ...props }: ApiKeysProps) => {
  return (
    <div
      data-slot="api-keys"
      className={cn(
        'flex flex-col overflow-hidden rounded-lg border border-border bg-card text-card-foreground',
        className,
      )}
      {...props}
    />
  );
};

type ApiKeysHeaderProps = React.ComponentProps<'div'>;

const ApiKeysHeader = ({ className, ...props }: ApiKeysHeaderProps) => {
  return (
    <div
      data-slot="api-keys-header"
      className={cn('flex items-center justify-between gap-2 border-b border-border px-4 py-3', className)}
      {...props}
    />
  );
};

type ApiKeysTitleProps = React.ComponentProps<'h3'>;

const ApiKeysTitle = ({ className, ...props }: ApiKeysTitleProps) => {
  return <h3 data-slot="api-keys-title" className={cn('text-sm font-medium text-foreground', className)} {...props} />;
};

type ApiKeysListProps = React.ComponentProps<'ul'>;

const ApiKeysList = ({ className, ...props }: ApiKeysListProps) => {
  return <ul data-slot="api-keys-list" className={cn('divide-y divide-border', className)} {...props} />;
};

type ApiKeyItemProps = React.ComponentProps<'li'>;

const ApiKeyItem = ({ className, ...props }: ApiKeyItemProps) => {
  return (
    <li
      data-slot="api-key-item"
      className={cn('flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-3', className)}
      {...props}
    />
  );
};

interface ApiKeyNameProps extends React.ComponentProps<'div'> {
  label: React.ReactNode;
}

const ApiKeyName = ({ label, className, children, ...props }: ApiKeyNameProps) => {
  return (
    <div data-slot="api-key-name" className={cn('flex min-w-0 flex-col', className)} {...props}>
      <span className="truncate text-sm font-medium text-foreground">{label}</span>
      {children ? <span className="truncate text-xs text-muted-foreground uppercase">{children}</span> : null}
    </div>
  );
};

interface ApiKeyValueProps extends Omit<React.ComponentProps<'div'>, 'children'> {
  value: string;
  /** Whether the key starts revealed. */
  defaultRevealed?: boolean;
  /** Show the reveal toggle. Turn off for keys that can only be seen once. */
  revealable?: boolean;
  /** Show the copy button. */
  copyable?: boolean;
}

const ApiKeyValue = ({
  value,
  defaultRevealed = false,
  revealable = true,
  copyable = true,
  className,
  ...props
}: ApiKeyValueProps) => {
  const [revealed, setRevealed] = React.useState(defaultRevealed);
  const masked = `${value.slice(0, 3)}${'•'.repeat(8)}${value.slice(-4)}`;

  return (
    <div
      data-slot="api-key-value"
      className={cn(
        'inline-flex items-center gap-0.5 rounded-md border border-border bg-background py-0.5 ps-2 pe-0.5',
        className,
      )}
      {...props}
    >
      <span dir="ltr" className="truncate text-xs tracking-wide text-foreground tabular-nums">
        {revealed ? value : masked}
      </span>
      {revealable ? (
        <Button
          type="button"
          variant="ghost"
          size="icon-xs"
          aria-label={revealed ? 'Hide key' : 'Reveal key'}
          aria-pressed={revealed}
          onClick={() => setRevealed((value) => !value)}
        >
          {revealed ? <EyeOff /> : <Eye />}
        </Button>
      ) : null}
      {copyable ? <CopyButton value={value} size="sm" label="Copy key" /> : null}
    </div>
  );
};

type ApiKeyMetaProps = React.ComponentProps<'span'>;

const ApiKeyMeta = ({ className, ...props }: ApiKeyMetaProps) => {
  return (
    <span data-slot="api-key-meta" className={cn('text-xs text-muted-foreground uppercase', className)} {...props} />
  );
};

export { ApiKeys, ApiKeysHeader, ApiKeysTitle, ApiKeysList, ApiKeyItem, ApiKeyName, ApiKeyValue, ApiKeyMeta };

const ENTER =
  'animate-in fade-in slide-in-from-bottom-2 duration-500 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';
const SWAP =
  'animate-in fade-in slide-in-from-bottom-1 duration-250 ease-[cubic-bezier(0.22,1,0.36,1)] fill-mode-both motion-reduce:animate-none';

interface ApiKeyRow {
  id: string;
  label: string;
  created: string;
  used: string;
  value: string;
  /** Keys created here can't be revealed again once the one-time notice is dismissed. */
  sealed?: boolean;
}

const API_KEY_ROWS: ApiKeyRow[] = [
  {
    id: 'production',
    label: 'Production',
    created: 'Created Mar 4',
    used: 'Used 2h ago',
    value: 'sk_live_9f8a7b6c5d4e3f2a1b0c',
  },
  {
    id: 'development',
    label: 'Development',
    created: 'Created Apr 18',
    used: 'Used 5d ago',
    value: 'sk_test_1a2b3c4d5e6f7g8h9i0j',
  },
];

const generateSecret = () => {
  const bytes = new Uint8Array(10);
  crypto.getRandomValues(bytes);

  return `sk_live_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')}`;
};

const ApiKeysBlock = () => {
  const [rows, setRows] = React.useState<ApiKeyRow[]>(API_KEY_ROWS);
  const [revealedId, setRevealedId] = React.useState<string | null>(null);
  const [confirmId, setConfirmId] = React.useState<string | null>(null);
  const createdCount = React.useRef(0);

  const revealedRow = rows.find((row) => row.id === revealedId);

  const createKey = () => {
    createdCount.current += 1;
    const count = createdCount.current;
    const id = `key-${count}`;
    const row: ApiKeyRow = {
      id,
      label: `Server key ${count}`,
      created: 'Created just now',
      used: 'Never used',
      value: generateSecret(),
      sealed: true,
    };
    setRows((current) => [...current, row]);
    setRevealedId(id);
    setConfirmId(null);
  };

  const revoke = (id: string) => {
    setRows((current) => current.filter((row) => row.id !== id));
    setConfirmId(null);
    if (revealedId === id) setRevealedId(null);
  };

  return (
    <section data-slot="api-keys-block" className="flex w-full justify-center bg-background p-6 sm:p-10">
      <ApiKeys className={cn(ENTER, 'w-full max-w-xl')}>
        <ApiKeysHeader>
          <ApiKeysTitle>API keys</ApiKeysTitle>
          <Button type="button" size="sm" onClick={createKey}>
            <Plus />
            Create key
          </Button>
        </ApiKeysHeader>

        {revealedRow ? (
          <div
            key={revealedRow.id}
            data-slot="api-keys-reveal"
            role="status"
            className={cn(SWAP, 'flex flex-col gap-3 border-b border-border bg-muted/40 px-4 py-3')}
          >
            <div className="flex flex-col gap-0.5">
              <p className="text-sm font-medium">{revealedRow.label} is ready</p>
              <p className="text-xs text-muted-foreground">
                Copy it now and store it somewhere safe. You won&apos;t be able to see it again.
              </p>
            </div>
            <div className="flex flex-wrap items-center gap-2">
              <ApiKeyValue value={revealedRow.value} defaultRevealed revealable={false} className="min-w-0" />
              <Button type="button" variant="ghost" size="sm" className="ms-auto" onClick={() => setRevealedId(null)}>
                Done
              </Button>
            </div>
          </div>
        ) : null}

        {rows.length === 0 ? (
          <Empty key="empty" className={SWAP}>
            <EmptyHeader>
              <EmptyMedia variant="icon">
                <KeyRound />
              </EmptyMedia>
              <EmptyTitle>No API keys</EmptyTitle>
              <EmptyDescription>Create a key to call the API from your servers.</EmptyDescription>
            </EmptyHeader>
          </Empty>
        ) : (
          <ApiKeysList>
            {rows.map((row) =>
              confirmId === row.id ? (
                <ApiKeyItem key={`${row.id}-confirm`} className={cn(SWAP, 'bg-destructive/5')}>
                  <p className="min-w-0 flex-1 text-sm">
                    Revoke <span className="font-medium">{row.label}</span>? Requests using it start failing right away.
                  </p>
                  <div className="flex items-center gap-2">
                    <Button type="button" variant="ghost" size="sm" onClick={() => setConfirmId(null)}>
                      Cancel
                    </Button>
                    <Button type="button" variant="destructive" size="sm" onClick={() => revoke(row.id)}>
                      Revoke
                    </Button>
                  </div>
                </ApiKeyItem>
              ) : (
                <ApiKeyItem key={row.id} className={SWAP}>
                  <ApiKeyName label={row.label}>{row.created}</ApiKeyName>
                  <ApiKeyValue value={row.value} revealable={!row.sealed} copyable={!row.sealed} className="ms-auto" />
                  <ApiKeyMeta>{row.used}</ApiKeyMeta>
                  <Button
                    type="button"
                    variant="ghost"
                    size="icon-sm"
                    aria-label={`Revoke ${row.label}`}
                    onClick={() => setConfirmId(row.id)}
                  >
                    <Trash2 />
                  </Button>
                </ApiKeyItem>
              ),
            )}
          </ApiKeysList>
        )}
      </ApiKeys>
    </section>
  );
};

export default ApiKeysBlock;
```
