# Sensitive Input

A sensitive-value input with a fixed mask, click-to-reveal interaction, and copy feedback.

Page: https://sui.draco.dev/docs/components/sensitive-input

### Example: sensitive-input-demo

```tsx
"use client";

import { Button } from "@workspace/ui/components/button";
import {
  Field,
  FieldDescription,
  FieldLabel,
} from "@workspace/ui/components/field";
import { SensitiveInput } from "@workspace/ui/components/sensitive-input";
import { useId, useRef, useState } from "react";
import type { ExampleProps } from "../types";

const defaultPassword = "a-private-example";

export default function SensitiveInputDemo({ locale }: ExampleProps) {
  const id = useId();
  const inputRef = useRef<HTMLInputElement>(null);
  const [copyResult, setCopyResult] = useState<
    "matched" | "mismatched" | "failed" | null
  >(null);
  const [matchesDefault, setMatchesDefault] = useState<boolean | null>(null);
  const [submitted, setSubmitted] = useState<boolean | null>(null);
  const [submissions, setSubmissions] = useState(0);
  const chinese = locale === "zh-CN";
  const stateLabels = chinese
    ? {
        idle: "尚未复制",
        failed: "复制失败，请重试",
        matched: "复制值与当前输入匹配",
        mismatched: "复制值与当前输入不匹配",
        defaultMatched: "复制值与初始默认值匹配",
        defaultMismatched: "复制值与初始默认值不匹配",
        submittedMatched: "；提交值与当前输入匹配",
        submittedMismatched: "；提交值与当前输入不匹配",
      }
    : {
        idle: "Not copied yet",
        failed: "Copy failed. Try again.",
        matched: "Copied value matches the current input.",
        mismatched: "Copied value does not match the current input.",
        defaultMatched: "Copied value matches the original default.",
        defaultMismatched: "Copied value does not match the original default.",
        submittedMatched: "; submitted value matches the current input.",
        submittedMismatched:
          "; submitted value does not match the current input.",
      };

  return (
    <form
      className="grid w-full max-w-sm gap-4"
      onSubmit={(event) => {
        event.preventDefault();
        setSubmitted(
          new FormData(event.currentTarget).get("password") ===
            inputRef.current?.value,
        );
        setSubmissions((count) => count + 1);
      }}
      onReset={() => {
        setCopyResult(null);
        setMatchesDefault(null);
        setSubmitted(null);
        setSubmissions(0);
      }}
    >
      <Field>
        <FieldLabel htmlFor={id}>{chinese ? "密码" : "Password"}</FieldLabel>
        <SensitiveInput
          ref={inputRef}
          id={id}
          name="password"
          required
          defaultValue={defaultPassword}
          onChange={() => {
            setCopyResult(null);
            setMatchesDefault(null);
          }}
          autoComplete="current-password"
          aria-describedby={`${id}-description`}
          onCopySuccess={(value) => {
            setCopyResult(
              value === inputRef.current?.value ? "matched" : "mismatched",
            );
            setMatchesDefault(value === defaultPassword);
          }}
          onCopyError={() => {
            setCopyResult("failed");
            setMatchesDefault(null);
          }}
          labels={
            chinese
              ? {
                  reveal: "点击显示",
                  instruction: "点击或按 Enter 显示内容",
                  hidden: "内容已隐藏",
                  show: "显示密码",
                  hide: "隐藏密码",
                  copy: "复制密码",
                  pending: "正在复制密码…",
                  copied: "已复制密码",
                  failed: "复制失败，请重试",
                }
              : undefined
          }
        />
        <FieldDescription id={`${id}-description`}>
          {chinese
            ? "点击遮罩或按 Enter 显示后编辑。修改并复制，再重置并复制，核对是否恢复初始默认值"
            : "Click the mask or press Enter to reveal and edit. Copy an edit, then reset and copy again to verify the original default is restored."}
        </FieldDescription>
      </Field>
      <div className="flex gap-2">
        <Button type="submit">{chinese ? "提交" : "Submit"}</Button>
        <Button type="reset" variant="outline">
          {chinese ? "重置" : "Reset"}
        </Button>
      </div>
      <output
        className="grid gap-1 text-muted-foreground text-sm"
        aria-live="polite"
      >
        <span>{stateLabels[copyResult ?? "idle"]}</span>
        {matchesDefault !== null && (
          <span>
            {matchesDefault
              ? stateLabels.defaultMatched
              : stateLabels.defaultMismatched}
          </span>
        )}
        <span>
          {chinese ? `提交次数：${submissions}` : `Submissions: ${submissions}`}
          {submitted !== null &&
            (submitted
              ? stateLabels.submittedMatched
              : stateLabels.submittedMismatched)}
        </span>
      </output>
    </form>
  );
}
```

## Installation

```bash
bunx --bun shadcn@latest add @sui/sensitive-input
```

Install with the shadcn CLI or use the shared `@workspace/ui` package. Follow the [installation guide](/docs/installation) to configure the registry, load styles, and choose import aliases.

## Usage

```tsx
import { SensitiveInput } from "@workspace/ui/components/sensitive-input";

<SensitiveInput
  aria-label="Password"
  autoComplete="current-password"
  defaultValue="example-password"
/>;
```

A nonempty hidden value is covered by a fixed `••••••••` mask, regardless of its length. The native input stays mounted underneath, but editing begins after revealing the value. Hovering or focusing the control shows “Click to reveal” without changing its layout.

Click the mask, or focus its reveal button and press Enter or Space, to reveal the actual input and focus it. The revealed input uses `type="text"`; its eye button hides the value again. Escape returns to the mask and focuses the reveal button. All reveal, hide, and copy actions are `type="button"` controls and do not submit their containing form.

An empty value has no mask and can be edited directly. The first input requests revealing the value. When focus leaves the entire group, a nonempty value is masked again. Moving focus between the input and its internal copy or eye buttons keeps the current state.

## Copying the current value

`copyable` defaults to `true`. The built-in button reads the native input's actual `value` when clicked, including uncontrolled edits and the value restored by a form reset. It does not copy a stale initial value. Copying works while the value is hidden or read-only and does not change visibility.

The copy control is a text tab above the top-right edge, shown on hover or focus within the group. Its width is reserved for pending, copied, and failed feedback so state changes do not shift the layout. The copy button remains available for empty values and can copy an empty string.

The button reports a pending state while the clipboard write is in progress and prevents repeated attempts. It only reports success after the write completes. Successful feedback resets after `resetDelay` milliseconds, with a default of `1500`. A failed attempt provides generic feedback and can be retried; feedback never includes the sensitive value.

`onCopySuccess` receives the successfully written value. `onCopyError` receives an `Error` when writing fails. Use the callbacks for state updates without rendering or logging the sensitive value. For example, compare against the current input through its native ref and display only whether the values match:

```tsx
import { SensitiveInput } from "@workspace/ui/components/sensitive-input";
import { useRef, useState } from "react";

export function CopyVerification() {
  const inputRef = useRef<HTMLInputElement>(null);
  const [matches, setMatches] = useState<boolean | null>(null);
  const [failed, setFailed] = useState(false);

  return (
    <>
      <SensitiveInput
        ref={inputRef}
        aria-label="API key"
        defaultValue="example-api-key"
        onCopySuccess={(value) => {
          setFailed(false);
          setMatches(value === inputRef.current?.value);
        }}
        onCopyError={() => {
          setFailed(true);
          setMatches(null);
        }}
      />
      <output aria-live="polite">
        {failed && "Copy failed. Try again."}
        {!failed && matches === null && "Not copied yet"}
        {!failed && matches !== null && (
          matches
            ? "Copied value matches the current input."
            : "Copied value does not match the current input."
        )}
      </output>
    </>
  );
}
```

The input's native `onCopy` remains a clipboard event handler for selection-based copying. The built-in button uses `onCopySuccess` for its asynchronous result instead.

## Controlled state

Control the input value with native `value` and `onChange`. Visibility is independent: use `visible` and `onVisibleChange`, or set `defaultVisible` for an uncontrolled input.

Controlled `visible` remains authoritative. Clicks, keyboard actions, first input into an empty field, and leaving the group request changes through `onVisibleChange`; update `visible` in that callback to accept them. Copying always reads the current native value and never requests a visibility change.

This example verifies the copied value through `ref`, exposes the native `onCopy` event count, and keeps all sensitive content out of its feedback.

### Example: sensitive-input-controlled

```tsx
"use client";

import { Button } from "@workspace/ui/components/button";
import {
  Field,
  FieldDescription,
  FieldLabel,
} from "@workspace/ui/components/field";
import { SensitiveInput } from "@workspace/ui/components/sensitive-input";
import { useId, useRef, useState } from "react";
import type { ExampleProps } from "../types";

export default function SensitiveInputControlled({ locale }: ExampleProps) {
  const id = useId();
  const inputRef = useRef<HTMLInputElement>(null);
  const [value, setValue] = useState("example-api-key");
  const [visible, setVisible] = useState(false);
  const [copyMatchesInput, setCopyMatchesInput] = useState<boolean | null>(
    null,
  );
  const [copyFailed, setCopyFailed] = useState(false);
  const [nativeCopyEvents, setNativeCopyEvents] = useState(0);
  const chinese = locale === "zh-CN";
  const stateLabels = chinese
    ? {
        idle: "尚未复制",
        failed: "复制失败，请重试",
        matched: "复制值与当前输入匹配",
        mismatched: "复制值与当前输入不匹配",
      }
    : {
        idle: "Not copied yet",
        failed: "Copy failed. Try again.",
        matched: "Copied value matches the current input.",
        mismatched: "Copied value does not match the current input.",
      };

  let copyFeedback = stateLabels.idle;
  if (copyFailed) copyFeedback = stateLabels.failed;
  else if (copyMatchesInput !== null)
    copyFeedback = copyMatchesInput
      ? stateLabels.matched
      : stateLabels.mismatched;

  return (
    <div className="grid w-full max-w-sm gap-4">
      <Field>
        <FieldLabel htmlFor={id}>{chinese ? "API 密钥" : "API key"}</FieldLabel>
        <SensitiveInput
          ref={inputRef}
          id={id}
          value={value}
          onChange={(event) => {
            setValue(event.target.value);
            setCopyMatchesInput(null);
            setCopyFailed(false);
          }}
          visible={visible}
          onVisibleChange={setVisible}
          onCopy={() => setNativeCopyEvents((count) => count + 1)}
          onCopySuccess={(copiedValue) => {
            setCopyFailed(false);
            setCopyMatchesInput(copiedValue === inputRef.current?.value);
          }}
          onCopyError={() => {
            setCopyFailed(true);
            setCopyMatchesInput(null);
          }}
          resetDelay={1500}
          autoComplete="off"
          aria-describedby={`${id}-description`}
          labels={
            chinese
              ? {
                  reveal: "点击显示",
                  instruction: "点击或按 Enter 显示内容",
                  hidden: "内容已隐藏",
                  show: "显示密钥",
                  hide: "隐藏密钥",
                  copy: "复制密钥",
                  pending: "正在复制密钥…",
                  copied: "已复制密钥",
                  failed: "复制失败，请重试",
                }
              : undefined
          }
        />
        <FieldDescription id={`${id}-description`}>
          {chinese
            ? "点击遮罩或按 Enter 显示。Escape 或离开输入组会请求隐藏；受控 visible 决定最终状态。选中显示的文字后复制，可观察原生 onCopy 事件"
            : "Click the mask or press Enter to reveal. Escape or leaving the group requests hiding; controlled visible remains authoritative. Select revealed text to exercise native onCopy."}
        </FieldDescription>
      </Field>
      <Button type="button" variant="outline" onClick={() => setVisible(false)}>
        {chinese ? "从外部隐藏" : "Hide from outside"}
      </Button>
      <output
        className="grid gap-1 text-muted-foreground text-sm"
        aria-live="polite"
      >
        <span>{copyFeedback}</span>
        <span>
          {chinese
            ? `原生复制事件：${nativeCopyEvents}`
            : `Native copy events: ${nativeCopyEvents}`}
        </span>
      </output>
    </div>
  );
}
```

## Read-only and disabled

`readOnly` prevents editing while keeping pointer and keyboard revealing, hiding, and copying available. `disabled` prohibits input, revealing, hiding, and copying. Set `copyable={false}` to remove only the built-in copy button; revealing, editing, and native input events remain available.

The example also includes an empty field: type into it directly, then move focus outside the group to see its nonempty value become masked.

### Example: sensitive-input-states

```tsx
"use client";

import {
  Field,
  FieldDescription,
  FieldLabel,
} from "@workspace/ui/components/field";
import { SensitiveInput } from "@workspace/ui/components/sensitive-input";
import { useId, useRef, useState } from "react";
import type { ExampleProps } from "../types";

export default function SensitiveInputStates({ locale }: ExampleProps) {
  const id = useId();
  const readOnlyRef = useRef<HTMLInputElement>(null);
  const [copyResult, setCopyResult] = useState<
    "matched" | "mismatched" | "failed" | null
  >(null);
  const chinese = locale === "zh-CN";
  const stateLabels = chinese
    ? {
        idle: "尚未复制",
        failed: "复制失败，请重试",
        matched: "复制值与只读输入匹配",
        mismatched: "复制值与只读输入不匹配",
      }
    : {
        idle: "Not copied yet",
        failed: "Copy failed. Try again.",
        matched: "Copied value matches the read-only input.",
        mismatched: "Copied value does not match the read-only input.",
      };
  const labels = chinese
    ? {
        reveal: "点击显示",
        instruction: "点击或按 Enter 显示内容",
        hidden: "内容已隐藏",
        show: "显示内容",
        hide: "隐藏内容",
        copy: "复制内容",
        pending: "正在复制内容…",
        copied: "已复制内容",
        failed: "复制失败，请重试",
      }
    : undefined;

  return (
    <div className="grid w-full max-w-sm gap-5">
      <Field>
        <FieldLabel htmlFor={`${id}-empty`}>
          {chinese ? "空值" : "Empty value"}
        </FieldLabel>
        <SensitiveInput
          id={`${id}-empty`}
          defaultValue=""
          placeholder={chinese ? "输入内容" : "Enter a value"}
          labels={labels}
        />
        <FieldDescription>
          {chinese
            ? "空值没有遮罩，可直接编辑；首次输入后显示内容，离开控件后重新遮罩。空值也可复制"
            : "An empty value can be edited directly. Typing reveals the value; leaving the group masks it again. Empty values can also be copied."}
        </FieldDescription>
      </Field>
      <Field>
        <FieldLabel htmlFor={`${id}-readonly`}>
          {chinese ? "只读密钥" : "Read-only key"}
        </FieldLabel>
        <SensitiveInput
          ref={readOnlyRef}
          id={`${id}-readonly`}
          defaultValue="read-only-example"
          readOnly
          labels={labels}
          onCopySuccess={(value) =>
            setCopyResult(
              value === readOnlyRef.current?.value ? "matched" : "mismatched",
            )
          }
          onCopyError={() => setCopyResult("failed")}
        />
        <FieldDescription>
          {chinese
            ? "只读内容可以点击或用键盘揭示，也可以复制，但不能编辑"
            : "Read-only values can be revealed by pointer or keyboard and copied, but cannot be edited."}
        </FieldDescription>
        <output className="text-muted-foreground text-sm" aria-live="polite">
          {stateLabels[copyResult ?? "idle"]}
        </output>
      </Field>
      <Field>
        <FieldLabel htmlFor={`${id}-disabled`}>
          {chinese ? "禁用" : "Disabled"}
        </FieldLabel>
        <SensitiveInput
          id={`${id}-disabled`}
          defaultValue="disabled-example"
          disabled
          labels={labels}
        />
        <FieldDescription>
          {chinese
            ? "输入、显隐和复制操作均被禁用"
            : "Input, visibility, and copy actions are disabled."}
        </FieldDescription>
      </Field>
      <Field>
        <FieldLabel htmlFor={`${id}-no-copy`}>
          {chinese ? "不提供复制按钮" : "Without a copy button"}
        </FieldLabel>
        <SensitiveInput
          id={`${id}-no-copy`}
          defaultValue="manual-entry-example"
          copyable={false}
          labels={labels}
        />
        <FieldDescription>
          {chinese
            ? "copyable=false 隐藏内置复制按钮，保留编辑与显隐"
            : "copyable=false hides the built-in copy action while retaining editing and visibility."}
        </FieldDescription>
      </Field>
    </div>
  );
}
```

## Composition and forms

Sensitive Input composes `InputGroup`, `InputGroupInput`, `InputGroupAddon`, and `InputGroupButton`. Use the normal `Field`, `FieldLabel`, and `FieldDescription` components around it; the visible label should reference the input's `id`.

`className` styles the group; `inputClassName` styles the input. A `ref` points to the native input, and native attributes such as `name`, `form`, `required`, `autoComplete`, and `aria-describedby` pass through to it.

The first example is an uncontrolled form with explicit Submit and Reset actions. Copying and toggling visibility leave its submission count unchanged. Submit verifies `FormData` against the current native input without displaying the value; Reset restores `defaultValue` and clears feedback. After editing and copying, reset and copy again: the separate boolean feedback verifies whether the copied value matches the original default. Neither feedback area displays the value. In controlled mode, reset the application-owned value in your form's `onReset` handler.

## Accessibility

Provide a visible associated label or an `aria-label` for the input. The mask has a native reveal button that can be reached with Tab and activated with Enter or Space. Instructions explain how to reveal the value, and a status message announces when it is hidden. The underlying masked input stays mounted for native refs and form data.

The revealed input accepts normal keyboard editing. Escape returns focus to the reveal button; leaving the group requests masking, while internal focus movement preserves the state. Visibility actions reference the input with `aria-controls`, and the eye icon is decorative. The copy button references the same input and reports its pending state with `aria-busy`; copy feedback is announced through a status region.

Localize the visible reveal hint with `labels.reveal`, its keyboard instructions with `labels.instruction`, and the hidden-state announcement with `labels.hidden`. Use `labels.show` and `labels.hide` for action names, and `labels.copy`, `labels.pending`, `labels.copied`, and `labels.failed` for copy feedback. Failure labels should explain a useful next step without including sensitive data.

## API Reference

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `visible` | `boolean` | — | Controlled visibility. |
| `defaultVisible` | `boolean` | `false` | Initial uncontrolled visibility. |
| `onVisibleChange` | `(visible: boolean) => void` | — | Called when pointer, keyboard, first input, or group blur requests a visibility change. |
| `copyable` | `boolean` | `true` | Include the built-in copy action. |
| `onCopySuccess` | `(value: string) => void` | — | Called after the current value is successfully written. |
| `onCopyError` | `(error: Error) => void` | — | Called when the clipboard write fails. |
| `resetDelay` | `number` | `1500` | Successful copy feedback duration in milliseconds. |
| `labels` | `{ reveal?: string; instruction?: string; hidden?: string; show?: string; hide?: string; copy?: string; pending?: string; copied?: string; failed?: string }` | English labels | Reveal hint, keyboard instructions, hidden announcement, action names, and copy feedback. |
| `className` | `string` | — | Additional group classes. |
| `inputClassName` | `string` | — | Additional input classes. |
| `...props` | Native input props, excluding `type` | — | Includes `value`, `defaultValue`, `onChange`, native `onCopy`, `ref`, `disabled`, and `readOnly`. |

The underlying input behavior follows the [Base UI Input API](https://base-ui.com/react/components/input#api-reference).

- [Documentation](https://base-ui.com/react/components/input)
- [API reference](https://base-ui.com/react/components/input#api-reference)
