Displays a secret such as an API key, masked by default (sk-live-••••••••a1b2), with a reveal toggle, a copy button that copies the full key, and an optional regenerate action.
sk-live-, rest hidden, ends in a1b2Created Sep 28, 2026. Keep it on your server.
"use client"
import { ApiKeyField } from "@/components/ballmac/api-key-field"
export default function ApiKeyFieldDemo() {
return (
<ApiKeyField
className="w-full max-w-md"
label="Secret key"
description="Created Sep 28, 2026. Keep it on your server."
value="sk-live-7f3a9c1e5b2d4f6a8c0e2b4d6f8a1c3e5b7d9fa1b2"
visiblePrefix={8}
visibleSuffix={4}
onRegenerate={() => {}}
/>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/api-key-fieldInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/api-key-field.tsx// Ballmac UI: API Key Field. https://ui.ballmac.com/components/api-key-field "use client" import * as React from "react" import { Check, Copy, Eye, EyeOff, RefreshCw } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" const MASK = "••••••••" /** Masks a secret, keeping `prefix` leading and `suffix` trailing characters. The mask length is fixed so it doesn't leak the key's length. */ function maskSecret(value: string, prefix: number, suffix: number) { if (value.length <= prefix + suffix + 4) return MASK return `${value.slice(0, prefix)}${MASK}${suffix > 0 ? value.slice(-suffix) : ""}` } const iconButton = "flex size-8 shrink-0 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors duration-150 hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-pressed:text-foreground" type ApiKeyFieldProps = Omit<React.ComponentProps<"div">, "children"> & { /** The secret. It is only rendered in full while revealed and is never logged. */ value: string /** Visible label, also the accessible name of the group. */ label?: React.ReactNode /** Helper text under the field, e.g. when the key was created. */ description?: React.ReactNode /** Leading characters left visible while masked, e.g. 8 keeps "sk-live-". */ visiblePrefix?: number /** Trailing characters left visible while masked. */ visibleSuffix?: number /** Controlled reveal state. */ revealed?: boolean /** Initial reveal state when uncontrolled. */ defaultRevealed?: boolean /** Called when the reveal toggle is pressed. */ onRevealedChange?: (revealed: boolean) => void /** Called after the full key was copied to the clipboard. */ onCopy?: () => void /** Shows a regenerate button. Asking for confirmation is up to you. */ onRegenerate?: () => void /** Disables the regenerate button and spins its icon while a new key is created. */ regenerating?: boolean } function ApiKeyField({ value, label, description, visiblePrefix = 8, visibleSuffix = 4, revealed: revealedProp, defaultRevealed = false, onRevealedChange, onCopy, onRegenerate, regenerating = false, className, ...props }: ApiKeyFieldProps) { const msg = useMessages() label ??= msg("api-key-field.label", "API key") const id = React.useId() const labelId = `${id}-label` const descriptionId = `${id}-description` const [internalRevealed, setInternalRevealed] = React.useState(defaultRevealed) const revealed = revealedProp ?? internalRevealed const [copied, setCopied] = React.useState(false) const timer = React.useRef<ReturnType<typeof setTimeout>>(undefined) React.useEffect(() => () => clearTimeout(timer.current), []) const masked = maskSecret(value, visiblePrefix, visibleSuffix) const partial = value.length > visiblePrefix + visibleSuffix + 4 const head = partial ? value.slice(0, visiblePrefix) : "" const tail = partial && visibleSuffix > 0 ? value.slice(-visibleSuffix) : "" async function copy() { try { await navigator.clipboard.writeText(value) } catch { return } setCopied(true) onCopy?.() clearTimeout(timer.current) timer.current = setTimeout(() => setCopied(false), 1800) } function toggle() { const next = !revealed if (revealedProp === undefined) setInternalRevealed(next) onRevealedChange?.(next) } return ( <div data-slot="api-key-field" data-revealed={revealed || undefined} role="group" aria-labelledby={labelId} aria-describedby={description ? descriptionId : undefined} className={cn("grid w-full min-w-0 gap-2", className)} {...props} > <div id={labelId} className="text-sm font-medium"> {label} </div> <div className="flex h-10 min-w-0 items-center gap-1 rounded-md border border-input bg-card pe-1 ps-3 shadow-xs"> <code data-slot="api-key-field-value" translate="no" dir="ltr" // Focusable so keyboard users can scroll a long key in a narrow field. tabIndex={0} className={cn( "min-w-0 flex-1 overflow-x-auto rounded-sm font-mono text-[13px] whitespace-nowrap outline-none [scrollbar-width:none] focus-visible:ring-[3px] focus-visible:ring-ring/50", revealed ? "select-all" : "select-none" )} > {revealed ? ( value ) : ( <> <span aria-hidden="true">{masked}</span> <span className="sr-only"> {head ? `${head}, rest hidden` : "Hidden"} {tail ? `, ends in ${tail}` : ""} </span> </> )} </code> <button type="button" aria-label={msg("api-key-field.showKey", "Show key")} aria-pressed={revealed} title={revealed ? msg("api-key-field.hideKey", "Hide key") : msg("api-key-field.showKey", "Show key")} onClick={toggle} className={iconButton}> {revealed ? <EyeOff aria-hidden="true" className="size-4" /> : <Eye aria-hidden="true" className="size-4" />} </button> <button type="button" aria-label={copied ? msg("api-key-field.copied", "Copied") : msg("api-key-field.copyKey", "Copy key")} title={msg("api-key-field.copyKey", "Copy key")} onClick={copy} className={iconButton}> {copied ? <Check aria-hidden="true" className="size-4" /> : <Copy aria-hidden="true" className="size-4" />} </button> {onRegenerate ? ( <button type="button" aria-label={msg("api-key-field.regenerateKey", "Regenerate key")} title={msg("api-key-field.regenerateKey", "Regenerate key")} disabled={regenerating} aria-busy={regenerating || undefined} onClick={onRegenerate} className={iconButton} > <RefreshCw aria-hidden="true" className={cn("size-4", regenerating && "animate-spin motion-reduce:animate-none")} /> </button> ) : null} </div> {description ? ( <p id={descriptionId} className="text-xs text-muted-foreground"> {description} </p> ) : null} <span className="sr-only" aria-live="polite"> {copied ? "Key copied to clipboard" : ""} </span> </div> ) } export { ApiKeyField, maskSecret, type ApiKeyFieldProps }Update the import paths to match your project setup.
Usage
import { ApiKeyField, maskSecret } from "@/components/ballmac/api-key-field"The full example is in the Code tab above.
API reference
| Prop | Type | Default |
|---|---|---|
value*The secret. It is only rendered in full while revealed and is never logged. | string | — |
labelVisible label, also the accessible name of the group. | React.ReactNode | — |
descriptionHelper text under the field, e.g. when the key was created. | React.ReactNode | — |
visiblePrefixLeading characters left visible while masked, e.g. 8 keeps "sk-live-". | number | 8 |
visibleSuffixTrailing characters left visible while masked. | number | 4 |
revealedControlled reveal state. | boolean | — |
defaultRevealedInitial reveal state when uncontrolled. | boolean | false |
onRevealedChangeCalled when the reveal toggle is pressed. | (revealed: boolean) => void | — |
onCopyCalled after the full key was copied to the clipboard. | () => void | — |
onRegenerateShows a regenerate button. Asking for confirmation is up to you. | () => void | — |
regeneratingDisables the regenerate button and spins its icon while a new key is created. | boolean | false |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Moves between reveal, copy and regenerate buttons |
| Enter / Space | Reveal is a toggle button (aria-pressed); copy announces 'Key copied to clipboard' |
| — | While masked, screen readers hear the prefix and last characters, not bullet characters |
Use with AI
Show an existing secret: <ApiKeyField value={key} label='Secret key' onRegenerate={confirmThenRotate} />. It is read-only; copy always copies the full value, even while masked. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI API Key Field (@ballmac/api-key-field) to this project with the shadcn MCP, then use it where it fits.
Use it for
- API keys, webhook signing secrets and access tokens on settings or developer pages
- Showing a newly created key once so the user can copy it
Not for
- Password entry (use an input with type=password)
- Secrets you should not send to the browser at all (show only the last four characters from the server)
Registry JSON: https://ui.ballmac.com/r/api-key-field.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Button
A button with six variants, three sizes, a pill shape and a built-in loading state. buttonVariants() styles links the same way.
Dialog
A Radix modal dialog with a blurred overlay, centered card surface that fits 360 px screens, header and footer parts, optional close button and animations.
API Endpoint
An endpoint reference card: method and path with highlighted parameters, auth, grouped parameter lists, and request and response examples in tabs with copy.
Code Block
A code panel with filename and language header, line numbers, highlighted lines, a wrap toggle, copy feedback and file tabs. No highlighter bundled; pass Shiki output as children.