Mischief

63 / Controls

Secret Field

An API key or token: hidden until asked for, copied whole either way.

API key hidden

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/secret-field
import { SecretField } from "mischief-ui/secret-field"
Also installs
  • lucide-react

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/secret-field/secret-field.tsx
"use client" import * as React from "react"import { Check, Clipboard, Eye, EyeOff } from "lucide-react" import { cn } from "@/lib/utils" export type SecretFieldProps = Omit<  React.HTMLAttributes<HTMLDivElement>,  "children" | "onCopy"> & {  value: string  /** Characters left readable at the start and end while it is hidden. */  visiblePrefix?: number

Usage

export function Key({ apiKey }) {
  return (
    <SecretField
      label="API key"
      value={apiKey}
      visiblePrefix={7}
      onCopied={() => track("key_copied")}
    />
  )
}

What stays readable

A fully masked key is hard to tell apart from another fully masked key, which matters the moment someone has more than one. Leaving the prefix and the last few characters visible makes them distinguishable at a glance without giving the secret away -- sk_live_••••1a2b is recognisably not sk_test_••••9f3c.

<SecretField value={key} visiblePrefix={8} visibleSuffix={4} />
The run of dots is capped, so a long token does not stretch the row.

Copying takes the whole value either way. Someone reaching for the copy button has decided already, and making them reveal it first only puts the secret on screen.

The part this cannot do

Masking is a courtesy to whoever is stood behind the reader. The value is in the page either way, so it is in the DOM, in the memory of the tab, and in anything that screenshots or records it. This component keeps a secret off the screen; it does not keep it out of the browser.

  • Send the secret only to someone entitled to it; masking is not authorisation.
  • Show a key in full once, at creation, and store only a prefix and a hash.
  • Where it must never be shown again, pass revealable={false} and leave copy as the only way to use it.

API

valuestringThe secret. Copied in full whether or not it is showing.
visiblePrefix, visibleSuffixnumber, numberCharacters left readable at each end. Default 0 and 4.
masked, defaultMaskedboolean, booleanControlled and uncontrolled hiding.
onMaskedChange(masked: boolean) => voidCalled when it is shown or hidden.
revealablebooleanDrop the reveal control for a value that must never be shown.
copyablebooleanShow the copy control. Defaults to true.
onCopied(value: string) => voidCalled after a copy that worked.
labelstringNames the thing, in every control. Defaults to "Secret".

Accessibility

While hidden, the run of dots is taken out of the accessibility tree and replaced with a spoken state, because a screen reader announcing forty bullets is worse than useless. The reveal control carries aria-pressed, so its state is known without seeing the icon. Copying announces its outcome through a polite live region, and a clipboard that refuses is reported rather than passing as success.