Skip to content
  1. Inputs
  2. Key token field

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

PropDefaultDescription
KeyTokenFieldrenders a <input> element
value-The token shown. The field is read-only, it never edits anything.
revealablefalseMasks the token behind bullets, with an eye button to reveal it: secret keys.
maxWidth28Width ceiling in `ch`, past which the token scrolls inside the field.
PropDefaultDescription
KeyTokenFieldrenders a <input> element
value-The token shown. The field is read-only, it never edits anything.
revealablefalseMasks the token behind bullets, with an eye button to reveal it: secret keys.
maxWidth28Width 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>