Skip to contentVibraUI
Utilities

API key field

A read-only secret with a reveal toggle, a copy button, and its provenance underneath.

maskKey keeps the prefix up to the last underscore, so "sk_live_" still says which environment the key belongs to; the copy button always copies the real value, masked or not. The reveal toggle is named "Show API key" and "Hide API key" and reports its state through aria-pressed. The masked prop sets the starting state only — the toggle owns it from there.

Install

npx shadcn@latest add @vibra/api-key-field

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
valuestring—The real key; it is copied in full even while the field shows the masked form.
labelReact.ReactNode—Labels the field; omit it inside a table row that is already labelled.
maskedbooleantrueWhether the key starts masked.
onRegenerate() => void—Adds a Regenerate action; leave it out and the action is not rendered.
onRevoke() => void—Adds a Revoke action, styled as the destructive one.
createdAtDate | string—Renders "Created Sep 1, 2026" in the meta line.
lastUsedAtDate | string—Renders "Last used 2h ago" in the meta line, through RelativeTime, so it ticks rather than freezing at render.

Dependencies

Source

components/ui/api-key-field.tsx
"use client"

import * as React from "react"
import { EyeIcon, EyeOffIcon, RefreshCwIcon, Trash2Icon } from "lucide-react"

import { cn } from "@/lib/utils"
import { formatDate } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { CopyButton } from "@/components/ui/copy-button"
import { RelativeTime } from "@/components/ui/relative-time"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupButton,
  InputGroupInput,
} from "@/components/ui/input-group"

const MASK = "••••••••"

/** Masks a secret, keeping the prefix up to the last "_" and the last `visible` characters, e.g. "sk_live_abcdefgh1234" → "sk_live_••••••••1234". */
export function maskKey(key: string, visible = 4): string {
  if (!key) return ""
  // The prefix is the useful half of a key — it says which environment the key
  // is for — so it survives the mask; everything between it and the tail goes.
  const cut = key.lastIndexOf("_") + 1
  const tail = visible > 0 ? key.slice(cut).slice(-visible) : ""
  return `${key.slice(0, cut)}${MASK}${tail}`
}

export type ApiKeyFieldProps = React.ComponentProps<"div"> & {
  /** The real key. It is copied in full even while the field shows the masked form. */
  value: string
  label?: React.ReactNode
  /** Whether the key starts masked; the reveal toggle takes over from there. */
  masked?: boolean
  onRegenerate?: () => void
  onRevoke?: () => void
  createdAt?: Date | string
  lastUsedAt?: Date | string
}

function ApiKeyField({
  className,
  value,
  label,
  masked = true,
  onRegenerate,
  onRevoke,
  createdAt,
  lastUsedAt,
  ...props
}: ApiKeyFieldProps) {
  const fieldId = React.useId()
  const [hidden, setHidden] = React.useState(masked)

  // "Last used" goes through RelativeTime rather than formatRelative: reading
  // the clock here would freeze the string at build time in a static page and
  // disagree with the client's own reading on hydration.
  const meta: { key: string; content: React.ReactNode }[] = [
    createdAt ? { key: "created", content: `Created ${formatDate(createdAt, "medium")}` } : null,
    lastUsedAt
      ? { key: "last-used", content: <>Last used <RelativeTime date={lastUsedAt} /></> }
      : null,
  ].filter((entry) => entry !== null)

  const hasActions = Boolean(onRegenerate || onRevoke)

  return (
    <div
      data-slot="api-key-field"
      data-masked={hidden || undefined}
      className={cn("flex w-full flex-col gap-2", className)}
      {...props}
    >
      {label ? (
        <label
          data-slot="api-key-field-label"
          htmlFor={fieldId}
          className="text-sm leading-none font-medium"
        >
          {label}
        </label>
      ) : null}

      <InputGroup>
        <InputGroupInput
          id={fieldId}
          readOnly
          spellCheck={false}
          value={hidden ? maskKey(value) : value}
          className="font-mono text-xs"
        />
        <InputGroupAddon align="inline-end">
          <InputGroupButton
            data-slot="api-key-field-reveal"
            size="icon-xs"
            aria-label={hidden ? "Show API key" : "Hide API key"}
            aria-pressed={!hidden}
            onClick={() => setHidden((previous) => !previous)}
          >
            {hidden ? <EyeIcon /> : <EyeOffIcon />}
          </InputGroupButton>
          <CopyButton value={value} size="icon-xs" label="Copy" successLabel="Copied" />
        </InputGroupAddon>
      </InputGroup>

      {meta.length > 0 || hasActions ? (
        <div className="flex min-h-6 flex-wrap items-center justify-between gap-x-3 gap-y-1">
          <div
            data-slot="api-key-field-meta"
            className="flex items-center gap-1.5 text-xs text-muted-foreground"
          >
            {meta.map((entry, index) => (
              <React.Fragment key={entry.key}>
                {index > 0 ? <span aria-hidden="true">·</span> : null}
                <span>{entry.content}</span>
              </React.Fragment>
            ))}
          </div>

          {hasActions ? (
            <div data-slot="api-key-field-actions" className="flex items-center gap-1">
              {onRegenerate ? (
                <Button type="button" variant="ghost" size="xs" onClick={onRegenerate}>
                  <RefreshCwIcon />
                  Regenerate
                </Button>
              ) : null}
              {onRevoke ? (
                <Button
                  type="button"
                  variant="ghost"
                  size="xs"
                  onClick={onRevoke}
                  className="text-danger hover:bg-danger-muted hover:text-danger"
                >
                  <Trash2Icon />
                  Revoke
                </Button>
              ) : null}
            </div>
          ) : null}
        </div>
      ) : null}
    </div>
  )
}

export { ApiKeyField }