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.
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):
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" />;
}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:
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.
import { SecureField } from '@bpdm/ui/secure-field';
export function ApiKey() {
return <SecureField copyable placeholder="API key" />;
}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.
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>
);
}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.
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>
);
}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.
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>
);
}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.
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Controlled value. React: pair with onValueChange. Angular: two-way [(value)]. |
defaultValue | string | "" | Uncontrolled initial value. |
onValueChange (React) | (value: string) => void | — | Called with the real (unmasked) value on every change. |
format | grouped | none | none | grouped chunks the value 4-4-4-4 and restricts input to digits. |
unmaskedTail | number | 0 | Characters kept visible while masked (e.g. 4 → •••• •••• •••• 4242). |
revealable | boolean | true | Show the reveal (eye) toggle. |
copyable | boolean | false | Show a copy-to-clipboard button. |
size | sm | md | lg | md | Height + text scale. |
disabled | boolean | false | Disable the field and its controls. |
name | string | — | Native name for form submission (forwarded to the <input>). |
aria-invalid | boolean | false | Invalid state — destructive border, announced by assistive tech. |
aria-label / aria-describedby | string | — | Accessible name / description, forwarded to the <input>. |
messages | { reveal, hide, copy, copied } | English | Override the control labels + copy announcement — see Internationalization. |
id | string | — | Field id (for <label> association). |
placeholder | string | — | Placeholder shown when empty. |
className (React) / class (Angular) | string | — | Extra 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 anaria-label(Reveal/Hide) andaria-pressed; the eye glyph isaria-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"setsinputmode="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 witharia-describedbypointing at your error text. - Give the field a name — associate a
<label>(viahtmlFor/id) or addaria-label.
Internationalization
- The control labels (
Reveal/Hide/Copy) and the copy announcement (Copied to clipboard) are translatable via themessagesprop — 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".
OTP Input
One-time-code input with auto-advance, paste-to-fill, masking and grouping — sizes and states, in React (@bpdm/ui) and Angular (@bpdm/ng).
Float Label
Floating-label wrapper — the label starts as a placeholder and floats up on focus or when filled, in three variants, in React (@bpdm/ui) and Angular (@bpdm/ng).