API Key Row
An API key row: masked by default, reveal with a press, copy with a tick, and roll the key behind an inline confirm.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/api-key-row.json'use client'
import { useEffect, useState } from 'react'
import './api-key-row.css'
export interface ApiKeyRowProps {
name: string
secret: string
env?: 'live' | 'test'
lastUsed?: string
scopes?: string
onRoll?: () => Promise<string>
fill?: 'dark' | 'light'
className?: string
}
/** Masked API key with reveal, copy and roll. */
export function ApiKeyRow({ name, secret: init, env = 'live', lastUsed = '2 hours ago', scopes = 'read, write', onRoll, fill = 'dark', className }: ApiKeyRowProps) {
const [secret, setSecret] = useState(init)
const [show, setShow] = useState(false)
const [copied, setCopied] = useState(false)
const [arm, setArm] = useState(false)
const [msg, setMsg] = useState('')
const [created, setCreated] = useState('Created Oct 12')
useEffect(() => { if (!copied) return; const t = setTimeout(() => setCopied(false), 1400); return () => clearTimeout(t) }, [copied])
useEffect(() => { if (!arm) return; const t = setTimeout(() => setArm(false), 3000); return () => clearTimeout(t) }, [arm])
const prefix = secret.slice(0, secret.indexOf('_', 3) + 1 || 7)
const masked = `${prefix}${'•'.repeat(18)}${secret.slice(-4)}`
const roll = async () => {
if (!arm) { setArm(true); return }
setArm(false)
const next = onRoll ? await onRoll() : `${prefix}${Array.from({ length: 24 }, () => 'abcdefghijkmnpqrstuvwxyz23456789'[Math.floor(Math.random() * 32)]).join('')}`
setSecret(next); setShow(true); setCreated('Created just now'); setMsg('Key rolled. The old key stops working in 24 hours.')
}
return (
<div className={['sd-akr', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-akr__top"><span className="sd-akr__name">{name}</span><span className="sd-akr__env" data-live={env === 'live' || undefined}>{env}</span></div>
<div className="sd-akr__meta">Last used {lastUsed} · {scopes}</div>
<div className="sd-akr__well">
<code className="sd-akr__key" aria-label={show ? `API key ${secret}` : `API key ending ${secret.slice(-4)}, hidden`}><span key={String(show) + secret}>{show ? secret : masked}</span></code>
<button type="button" className="sd-akr__b" aria-pressed={show} aria-label={show ? 'Hide key' : 'Reveal key'} onClick={() => setShow(!show)}>
<svg viewBox="0 0 16 16" aria-hidden>{show ? <path d="M2 2l12 12M6.5 6.6A2 2 0 0 0 9.4 9.5M4.3 4.4C2.9 5.3 2 6.6 1.5 8c1 2.6 3.6 4.5 6.5 4.5 1.2 0 2.3-.3 3.3-.9M7 3.6c.3 0 .7-.1 1-.1 2.9 0 5.5 1.9 6.5 4.5-.3.8-.7 1.5-1.2 2.1" /> : <><path d="M1.5 8C2.5 5.4 5.1 3.5 8 3.5s5.5 1.9 6.5 4.5c-1 2.6-3.6 4.5-6.5 4.5S2.5 10.6 1.5 8Z" /><circle cx="8" cy="8" r="2" /></>}</svg>
</button>
<button type="button" className="sd-akr__b" aria-label={copied ? 'Copied' : 'Copy key'} onClick={async () => { try { await navigator.clipboard.writeText(secret) } catch {} setCopied(true); setMsg('Key copied') }}>
{copied ? <svg key="ok" className="sd-akr__ok" viewBox="0 0 16 16" aria-hidden><path d="m3.5 8.5 3 3 6-7" /></svg> : <svg viewBox="0 0 16 16" aria-hidden><rect x="5.5" y="5.5" width="8" height="8" rx="1.5" /><path d="M10.5 5.5v-2a1 1 0 0 0-1-1h-6a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2" /></svg>}
</button>
</div>
<div className="sd-akr__foot"><span>{created}</span><button type="button" className="sd-akr__roll" data-arm={arm || undefined} onClick={roll}>{arm ? 'Click to confirm' : 'Roll key'}</button></div>
<span className="sr" aria-live="polite">{msg}</span>
</div>
)
}.sd-akr { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-akr[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-akr button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-akr :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-akr .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-akr { width: min(100%, 340px); padding: 14px; border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-akr__top { display: flex; align-items: center; gap: 8px; }
.sd-akr__name { font-weight: 600; font-size: 14px; }
.sd-akr__env { font: 600 10.5px/1 ui-monospace, monospace; padding: 3px 6px; border-radius: 99px; box-shadow: inset 0 0 0 1px var(--line); text-transform: uppercase; }
.sd-akr__env[data-live] { background: var(--strong); color: var(--on); box-shadow: none; }
.sd-akr__meta { font-size: 12px; color: var(--mute); margin: 3px 0 10px; }
.sd-akr__well { display: flex; align-items: center; gap: 4px; height: 40px; padding: 0 4px 0 12px; border-radius: 10px; background: var(--soft); }
.sd-akr__key { flex: 1; min-width: 0; overflow-x: auto; white-space: nowrap; scrollbar-width: none; font: 12.5px/1 ui-monospace, 'JetBrains Mono', monospace; letter-spacing: 0.02em; }
.sd-akr__key span { display: inline-block; animation: sd-akr-in 220ms ease; }
@keyframes sd-akr-in { from { opacity: 0; filter: blur(0); transform: translateY(3px); } }
.sd-akr__b { position: relative; display: grid; place-items: center; width: 32px; height: 32px; border-radius: 8px; color: var(--mute); flex: none; }
.sd-akr__b:hover, .sd-akr__b[aria-pressed='true'] { color: var(--fg); background: var(--bg) !important; }
.sd-akr__b svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
.sd-akr__ok { animation: sd-akr-pop 300ms cubic-bezier(0.3, 1.7, 0.4, 1); }
@keyframes sd-akr-pop { from { transform: scale(0.3); } }
.sd-akr__foot { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; font-size: 12px; color: var(--mute); }
.sd-akr__roll { height: 28px; padding: 0 10px; border-radius: 8px; box-shadow: inset 0 0 0 1px var(--line); font-size: 12px; color: var(--fg); }
.sd-akr__roll[data-arm] { background: var(--strong) !important; color: var(--on); box-shadow: none; }
@media (prefers-reduced-motion: reduce) { .sd-akr *, .sd-akr *::before, .sd-akr *::after { animation: none !important; transition: none !important; } }Overview
The key shows as a prefix and dots. Reveal swaps in the full key in a monospace well; copy writes it and flashes a tick. Roll asks inline, then replaces the key with a new one and notes when it was created.
Last-used time and scopes sit under the name so people can tell keys apart.
When to use
- Developer settings, integrations, webhooks secrets.
When not to use
- Showing a key once at creation: use a one-time reveal dialog.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/api-key-row.json$
npx shadcn@latest add https://sundaeui.dev/r/api-key-row.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/api-key-row.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/api-key-row.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy api-key-row.tsx and api-key-row.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ApiKeyRow } from '@/components/sundae/api-key-row'
<ApiKeyRow />Props
| Prop | Type | Default |
|---|---|---|
name*Key name. | string | — |
secret*The key. | string | — |
lastUsedHuman-readable last use. | string | — |
onRollReturns a new key. | () => Promise<string> | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Reveal is a toggle button with aria-pressed; copy and roll announce results politely.
- The masked key is labelled so screen readers do not read dots.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-akr; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is API Key Row free?
- Yes. API Key Row is free for personal and commercial projects, with full source on this page.
- How do I install API Key Row?
- Run npx shadcn@latest add https://sundaeui.dev/r/api-key-row.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does API Key Row support keyboard and screen readers?
- Reveal is a toggle button with aria-pressed; copy and roll announce results politely. The masked key is labelled so screen readers do not read dots.
- Does API Key Row respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Share Sheet
Components · RefinedCode Tabs
Components · RefinedActive Sessions
Components · RefinedContact Card
Components · RefinedMini Terminal
Components · RefinedWebhook Endpoints
Components · Refined