Key token field
A read-only field for an identifier or an API key, sized to its own content.
import { KeyTokenField } from '@/components/key-token-field'
export default function Example() {
return <KeyTokenField value="pk_test_51H8kQ2eZvKYlo2C" />
}<script setup lang="ts">
import KeyTokenField from '@/components/key-token-field.vue'
</script>
<template>
<KeyTokenField value="pk_test_51H8kQ2eZvKYlo2C" />
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
'use client'
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/16/solid'
import clsx from 'clsx'
import type React from 'react'
import { useState } from 'react'
export function KeyTokenField({
value,
maxWidth = 28,
revealable = false,
className,
...props
}: {
value: string
maxWidth?: number
revealable?: boolean
} & Omit<React.ComponentPropsWithoutRef<'input'>, 'value' | 'readOnly'>) {
let [revealed, setRevealed] = useState(false)
let hidden = revealable && !revealed
let input = (
<input
readOnly
type="text"
value={hidden ? '•'.repeat(Math.min(value.length, maxWidth)) : value}
aria-label={props['aria-label'] ?? (props['aria-labelledby'] ? undefined : 'Token')}
{...props}
style={{ width: `${Math.min(value.length, maxWidth)}ch`, boxSizing: 'content-box', ...props.style }}
onFocus={(event) => {
if (!hidden) event.target.select()
props.onFocus?.(event)
}}
className={clsx(
className,
'max-w-full min-w-0 rounded-md bg-zinc-950/5 px-2 py-1 font-mono text-base/6 text-zinc-950 ring-1 ring-zinc-950/10 focus:outline-hidden sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/15',
hidden && 'tracking-tighter text-zinc-400 dark:text-zinc-500'
)}
/>
)
if (!revealable) return input
return (
<span className="inline-flex max-w-full items-center gap-1">
{input}
<button
type="button"
aria-pressed={revealed}
aria-label="Reveal token"
onClick={() => setRevealed((current) => !current)}
className="relative shrink-0 cursor-pointer rounded-md p-1 text-zinc-500 hover:bg-zinc-950/5 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white"
>
<span className="absolute top-1/2 left-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden" aria-hidden="true" />
{revealed ? <EyeSlashIcon className="size-4 shrink-0" /> : <EyeIcon className="size-4 shrink-0" />}
</button>
</span>
)
}<script setup lang="ts">
import { EyeIcon, EyeSlashIcon } from '@heroicons/vue/16/solid'
import { computed, ref, useAttrs } from 'vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(
defineProps<{
value: string
maxWidth?: number
revealable?: boolean
}>(),
{ maxWidth: 28, revealable: false }
)
const revealed = ref(false)
const hidden = computed(() => props.revealable && !revealed.value)
const attrs = useAttrs()
const ariaLabel = computed(() =>
attrs['aria-labelledby'] ? undefined : ((attrs['aria-label'] as string | undefined) ?? 'Token'),
)
const widthCh = computed(() => `${Math.min(props.value.length, props.maxWidth)}ch`)
const shown = computed(() => (hidden.value ? '•'.repeat(Math.min(props.value.length, props.maxWidth)) : props.value))
function onFocus(event: FocusEvent) {
if (!hidden.value) (event.target as HTMLInputElement).select()
}
</script>
<template>
<span v-if="revealable" class="inline-flex max-w-full items-center gap-1" :class="attrs.class">
<input
v-bind="{ ...attrs, class: undefined }"
readonly
type="text"
:value="shown"
:aria-label="ariaLabel"
:style="{ width: widthCh, boxSizing: 'content-box' }"
class="max-w-full min-w-0 rounded-md bg-zinc-950/5 px-2 py-1 font-mono text-base/6 text-zinc-950 ring-1 ring-zinc-950/10 focus:outline-hidden sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/15"
:class="hidden && 'tracking-tighter text-zinc-400 dark:text-zinc-500'"
@focus="onFocus"
/>
<button
type="button"
:aria-pressed="revealed"
aria-label="Reveal token"
class="relative shrink-0 cursor-pointer rounded-md p-1 text-zinc-500 hover:bg-zinc-950/5 hover:text-zinc-950 focus:outline-hidden focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent-500 dark:text-zinc-400 dark:hover:bg-white/5 dark:hover:text-white"
@click="revealed = !revealed"
>
<span class="absolute top-1/2 left-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden" aria-hidden="true" />
<EyeSlashIcon v-if="revealed" class="size-4 shrink-0" />
<EyeIcon v-else class="size-4 shrink-0" />
</button>
</span>
<input
v-else
v-bind="attrs"
readonly
type="text"
:value="value"
:aria-label="ariaLabel"
:style="{ width: widthCh, boxSizing: 'content-box' }"
class="max-w-full min-w-0 rounded-md bg-zinc-950/5 px-2 py-1 font-mono text-base/6 text-zinc-950 ring-1 ring-zinc-950/10 focus:outline-hidden sm:text-sm/6 dark:bg-white/5 dark:text-white dark:ring-white/15"
@focus="onFocus"
/>
</template>Component API
| Prop | Default | Description |
|---|---|---|
KeyTokenFieldrenders a <input> element | ||
value | - | The token shown. The field is read-only, it never edits anything. |
revealable | false | Masks the token behind bullets, with an eye button to reveal it: secret keys. |
maxWidth | 28 | Width ceiling in `ch`, past which the token scrolls inside the field. |
| Prop | Default | Description |
|---|---|---|
KeyTokenFieldrenders a <input> element | ||
value | - | The token shown. The field is read-only, it never edits anything. |
revealable | false | Masks the token behind bullets, with an eye button to reveal it: secret keys. |
maxWidth | 28 | Width ceiling in `ch`, past which the token scrolls inside the field. |
Examples
Basic example
The field measures itself against its value, so a short id does not leave a half-empty box behind. Clicking it selects the whole token:
import { KeyTokenField } from '@/components/key-token-field'
export default function Example() {
return <KeyTokenField value="pk_test_51H8kQ2eZvKYlo2C" />
}<script setup lang="ts">
import KeyTokenField from '@/components/key-token-field.vue'
</script>
<template>
<KeyTokenField value="pk_test_51H8kQ2eZvKYlo2C" />
</template>With a copy button
Pair it with a CopyButton when the token is meant to be pasted somewhere else:
import { CopyButton } from '@/components/copy-button'
import { KeyTokenField } from '@/components/key-token-field'
export default function Example() {
return (
<div className="flex items-center gap-2">
<KeyTokenField value="pk_test_51H8kQ2eZvKYlo2C" />
<CopyButton text="pk_test_51H8kQ2eZvKYlo2C" label="Copy public key" />
</div>
)
}<script setup lang="ts">
import CopyButton from '@/components/copy-button.vue'
import KeyTokenField from '@/components/key-token-field.vue'
</script>
<template>
<div class="flex items-center gap-2">
<KeyTokenField value="pk_test_51H8kQ2eZvKYlo2C" />
<CopyButton text="pk_test_51H8kQ2eZvKYlo2C" label="Copy public key" />
</div>
</template>Revealable
A secret stays behind bullets until the eye is pressed, and keeps its width so nothing jumps. Pair it with a copy button, which copies the real value either way:
<KeyTokenField value="sk_live_pLbWuVV4k9m2Qw8e" revealable aria-label="Secret key" />Long tokens
Growth stops at maxWidth characters. Beyond that the field keeps its width and the token scrolls inside it, so a table column stays aligned:
<>
<KeyTokenField value="evt_1P3k" />
<KeyTokenField value="pk_test_51H8kQ2eZvKYlo2CfHhP9nQx7" />
<KeyTokenField value="whsec_ZmFrZS1zZWNyZXQtdmFsdWUtZm9yLWRvY3M" maxWidth={16} />
</><template>
<KeyTokenField value="evt_1P3k" />
<KeyTokenField value="pk_test_51H8kQ2eZvKYlo2CfHhP9nQx7" />
<KeyTokenField value="whsec_ZmFrZS1zZWNyZXQtdmFsdWUtZm9yLWRvY3M" :max-width="16" />
</template>