bpdm/ui
Form

Secure Field

Masked input for secrets — API keys, license keys, tokens — with reveal and copy, grouping and a visible tail, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Secure Field is a masked input for sensitive values — API keys, license keys, tokens, serials. It's masked at rest (with an optional visible tail), has a reveal toggle, and an optional copy button. It uses a regular text input with masking rather than type="password", so password managers don't try to hijack it — while onValueChange and copy always give the real value. In React it's <SecureField>; in Angular it's <bpdm-secure-field>.

Usage

Give it a value and a placeholder. By default it's masked with a reveal toggle; unmaskedTail leaves the last few characters visible for recognition.

license.tsx
import { SecureField } from '@bpdm/ui/secure-field';

export function License() {
  return <SecureField format="grouped" unmaskedTail={4} placeholder="License key" />;
}

For a controlled field, pass value and onValueChange (you always receive the real value):

controlled.tsx
import { useState } from 'react';
import { SecureField } from '@bpdm/ui/secure-field';

export function Controlled() {
  const [key, setKey] = useState('');
  return <SecureField value={key} onValueChange={setKey} placeholder="License key" />;
}
license.component.ts
import { Component } from '@angular/core';
import { BpdmSecureField } from '@bpdm/ng';

@Component({
  selector: 'app-license',
  standalone: true,
  imports: [BpdmSecureField],
  template: `<bpdm-secure-field format="grouped" [unmaskedTail]="4" placeholder="License key" />`,
})
export class LicenseComponent {}

For a controlled field, use two-way binding on value:

controlled.component.ts
import { Component } from '@angular/core';
import { BpdmSecureField } from '@bpdm/ng';

@Component({
  selector: 'app-controlled',
  standalone: true,
  imports: [BpdmSecureField],
  template: `<bpdm-secure-field [(value)]="key" placeholder="License key" />`,
})
export class ControlledComponent {
  key = '';
}

Reveal & copy

The reveal toggle is on by default (revealable={false} removes it). Add copyable for a copy button — it copies the real value, not the masked text.

api-key.tsx
import { SecureField } from '@bpdm/ui/secure-field';

export function ApiKey() {
  return <SecureField copyable placeholder="API key" />;
}
api-key.component.ts
import { Component } from '@angular/core';
import { BpdmSecureField } from '@bpdm/ng';

@Component({
  selector: 'app-api-key',
  standalone: true,
  imports: [BpdmSecureField],
  template: `<bpdm-secure-field copyable placeholder="API key" />`,
})
export class ApiKeyComponent {}

Masking & format

unmaskedTail keeps the last N characters visible so a value is recognisable without exposing it. format="grouped" chunks the masked value for readability; format="none" (the default) masks it as a single run.

format.tsx
import { SecureField } from '@bpdm/ui/secure-field';

export function Format() {
  return (
    <div className="flex w-80 flex-col gap-4">
      <SecureField format="grouped" unmaskedTail={4} placeholder="Grouped, last 4 shown" />
      <SecureField unmaskedTail={4} copyable placeholder="Serial, last 4 shown" />
      <SecureField format="none" placeholder="Fully masked" />
    </div>
  );
}
format.component.ts
import { Component } from '@angular/core';
import { BpdmSecureField } from '@bpdm/ng';

@Component({
  selector: 'app-format',
  standalone: true,
  imports: [BpdmSecureField],
  template: `
    <div class="flex w-80 flex-col gap-4">
      <bpdm-secure-field format="grouped" [unmaskedTail]="4" placeholder="Grouped, last 4 shown" />
      <bpdm-secure-field [unmaskedTail]="4" copyable placeholder="Serial, last 4 shown" />
      <bpdm-secure-field format="none" placeholder="Fully masked" />
    </div>
  `,
})
export class FormatComponent {}

Sizes

Three heights — sm, md (default), and lg — to match the rest of the form.

sizes.tsx
import { SecureField } from '@bpdm/ui/secure-field';

export function Sizes() {
  return (
    <div className="flex w-80 flex-col gap-3">
      {(['sm', 'md', 'lg'] as const).map((s) => (
        <SecureField key={s} size={s} format="grouped" unmaskedTail={4} placeholder={`Size ${s}`} />
      ))}
    </div>
  );
}
sizes.component.ts
import { Component } from '@angular/core';
import { BpdmSecureField } from '@bpdm/ng';

@Component({
  selector: 'app-sizes',
  standalone: true,
  imports: [BpdmSecureField],
  template: `
    <div class="flex w-80 flex-col gap-3">
      @for (s of sizes; track s) {
        <bpdm-secure-field [size]="s" format="grouped" [unmaskedTail]="4" [placeholder]="'Size ' + s" />
      }
    </div>
  `,
})
export class SizesComponent {
  sizes = ['sm', 'md', 'lg'] as const;
}

States

Disable the field, or mark it invalid with aria-invalid and a described error message.

That key looks too short.

states.tsx
import { SecureField } from '@bpdm/ui/secure-field';

export function States() {
  return (
    <div className="flex w-80 flex-col gap-4">
      <SecureField disabled placeholder="Disabled" />
      <div className="flex flex-col gap-1.5">
        <SecureField aria-invalid aria-describedby="sf-err" format="grouped" unmaskedTail={4} />
        <p id="sf-err" className="text-sm text-destructive">
          That key looks too short.
        </p>
      </div>
    </div>
  );
}
states.component.ts
import { Component } from '@angular/core';
import { BpdmSecureField } from '@bpdm/ng';

@Component({
  selector: 'app-states',
  standalone: true,
  imports: [BpdmSecureField],
  template: `
    <div class="flex w-80 flex-col gap-4">
      <bpdm-secure-field [disabled]="true" placeholder="Disabled" />
      <div class="flex flex-col gap-1.5">
        <bpdm-secure-field aria-invalid aria-describedby="sf-err" format="grouped" [unmaskedTail]="4" />
        <p id="sf-err" class="text-sm text-destructive">That key looks too short.</p>
      </div>
    </div>
  `,
})
export class StatesComponent {}

API

SecureField / bpdm-secure-field

onValueChange (React) and [(value)] (Angular) always give you the real value, never the masked display.

PropTypeDefaultDescription
valuestringControlled value. React: pair with onValueChange. Angular: two-way [(value)].
defaultValuestring""Uncontrolled initial value.
onValueChange (React)(value: string) => voidCalled with the real (unmasked) value on every change.
formatgrouped | nonenonegrouped chunks the value 4-4-4-4 and restricts input to digits.
unmaskedTailnumber0Characters kept visible while masked (e.g. 4•••• •••• •••• 4242).
revealablebooleantrueShow the reveal (eye) toggle.
copyablebooleanfalseShow a copy-to-clipboard button.
sizesm | md | lgmdHeight + text scale.
disabledbooleanfalseDisable the field and its controls.
namestringNative name for form submission (forwarded to the <input>).
aria-invalidbooleanfalseInvalid state — destructive border, announced by assistive tech.
aria-label / aria-describedbystringAccessible name / description, forwarded to the <input>.
messages{ reveal, hide, copy, copied }EnglishOverride the control labels + copy announcement — see Internationalization.
idstringField id (for <label> association).
placeholderstringPlaceholder shown when empty.
className (React) / class (Angular)stringExtra classes on the wrapper.

In React, SecureField forwards its ref to the underlying <input> and spreads any other native <input> prop (required, readOnly, data-*, onBlur, …) onto it. In Angular, name, aria-label, and aria-describedby are dedicated inputs on <bpdm-secure-field>.

Accessibility

  • It's a standard text input with visual masking (not type="password"), so browser and third-party password managers leave it alone (data-1p-ignore / data-lpignore). At rest the input's value is the masked bullets, so a screen reader never reads the secret aloud until it's revealed or focused.
  • The reveal control is a real <button type="button"> with an aria-label (Reveal / Hide) and aria-pressed; the eye glyph is aria-hidden.
  • The copy control is a labelled <button> (Copy); because the success tick is a visual-only change, copying also announces “Copied to clipboard” through a polite live region.
  • format="grouped" sets inputmode="numeric" for a numeric keypad on mobile.
  • Invalid state is set with aria-invalid — it colours the border and is announced (never colour-only). Pair it with aria-describedby pointing at your error text.
  • Give the field a name — associate a <label> (via htmlFor/id) or add aria-label.

Internationalization

  • The control labels (Reveal / Hide / Copy) and the copy announcement (Copied to clipboard) are translatable via the messages prop — see Translating labels. Give the field a translated accessible name.
  • RTL-safe: no physical layout (the controls sit via flex), so the field mirrors under dir="rtl".

On this page