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-fieldNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { ApiKeyField } from "@/components/ui/api-key-field"
const LIVE_KEY = "sk_live_9f2a4c7e11b3d84a06c5"
// A fixed reference instant, not Date.now(): a statically built page freezes
// the server's copy at build time, so a live clock here would read one thing in
// the HTML and another once the reader's own clock takes over. format-demo.tsx
// pins its instant for the same reason.
const NOW = new Date(2026, 8, 4, 12, 0, 0).getTime()
const LAST_USED = new Date(NOW - 2 * 60 * 60 * 1000)
export default function ApiKeyFieldDemo() {
const [note, setNote] = React.useState<string | null>(null)
return (
<div className="flex w-full max-w-md flex-col gap-2">
<ApiKeyField
value={LIVE_KEY}
label="Live secret key"
createdAt="2026-09-01T09:12:00.000Z"
lastUsedAt={LAST_USED}
onRegenerate={() => setNote("A new key would replace this one immediately.")}
onRevoke={() => setNote("Revoking stops every request signed with this key.")}
/>
{note ? <p className="text-xs text-muted-foreground">{note}</p> : null}
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| value | string | — | The real key; it is copied in full even while the field shows the masked form. |
| label | React.ReactNode | — | Labels the field; omit it inside a table row that is already labelled. |
| masked | boolean | true | Whether 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. |
| createdAt | Date | string | — | Renders "Created Sep 1, 2026" in the meta line. |
| lastUsedAt | Date | string | — | Renders "Last used 2h ago" in the meta line, through RelativeTime, so it ticks rather than freezing at render. |
Dependencies
Source
"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 }