본문으로 건너뛰기
컴포넌트

비밀번호 필드

보기와 숨기기를 전환할 수 있는 비밀번호 입력 필드예요.

  • #arc
  • #component
  • #input
  • #password
  • #form
  • #security
  • #toggle

설치

이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.

터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/password-field.json

password-field.module.csscss
/* No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead. */
.field { display: grid; min-width: 0; }
.field label { margin-bottom: var(--space-2); font-size: var(--text-sm); font-weight: var(--label-weight); }
/* Focus fills the shell with the accent and error with danger, exactly like Input; the shell never changes size. The toggle sits with an even inset on top, bottom and right. */
.shell { display: flex; min-height: var(--control-height-md); align-items: center; border: 1px solid var(--border-strong); border-radius: var(--radius-control); padding: 0 calc((var(--control-height-md) - 2px - 30px) / 2) 0 var(--space-3); background: var(--surface); transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
@media (hover: hover) and (pointer: fine) { .shell:hover:not(:has(.input:focus-visible, .input[aria-invalid="true"], .input:disabled)) { border-color: var(--foreground); } }
.shell:has(.input:focus-visible) { border-color: var(--accent); background: var(--accent); }
.shell:has(.input[aria-invalid="true"]) { border-color: var(--danger); background: var(--danger); }
.shell:has(.input:disabled) { opacity: .5; background: var(--surface-muted); cursor: not-allowed; }
.shell:has(.input:focus-visible) .input, .shell:has(.input[aria-invalid="true"]) .input { color: var(--accent-foreground); caret-color: var(--accent-foreground); }
.shell:has(.input:focus-visible) .input::placeholder, .shell:has(.input[aria-invalid="true"]) .input::placeholder { color: var(--accent-foreground-muted); }
.shell:has(.input:focus-visible) button, .shell:has(.input[aria-invalid="true"]) button { color: var(--accent-foreground); }
@media (hover: hover) and (pointer: fine) { .shell:has(.input:focus-visible) button:hover, .shell:has(.input[aria-invalid="true"]) button:hover { background: color-mix(in oklab, var(--accent-foreground) 16%, transparent); color: var(--accent-foreground); } }
.input { width: 100%; min-width: 0; border: 0; background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); line-height: var(--leading-body); outline: 0; }
.input::placeholder { color: var(--text-muted); }
/* Switching between dots and characters resolves in place instead of snapping. Swapping the keyframe name restarts it. */
.input[data-reveal="shown"] { animation: resolve-shown var(--duration-standard) var(--ease-enter); }
.input[data-reveal="hidden"] { animation: resolve-hidden var(--duration-standard) var(--ease-enter); }
@keyframes resolve-shown { from { opacity: .35; filter: blur(2px); } }
@keyframes resolve-hidden { from { opacity: .35; filter: blur(2px); } }
.shell button { display: grid; width: 30px; height: 30px; flex: 0 0 auto; place-items: center; border: 0; border-radius: var(--radius-control); background: transparent; color: var(--text-muted); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-spring) var(--ease-spring); }
@media (hover: hover) and (pointer: fine) { .shell button:hover { background: var(--surface-muted); color: var(--foreground); } }
.shell button:active { transform: scale(.96); transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.shell button[aria-pressed="true"] { color: var(--foreground); }
.shell:has(.input:focus-visible) button[aria-pressed="true"], .shell:has(.input[aria-invalid="true"]) button[aria-pressed="true"] { color: var(--accent-foreground); }
/* The row's space is padding inside the clipped slot, so the height animates from a true zero. */
.messageSlot { display: block; overflow: hidden; }
.hint { display: block; padding-top: var(--space-2); color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
/* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */
.words { position: relative; display: block; }
.word { display: inline-block; white-space: pre; }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
@media (prefers-reduced-motion: reduce) { .shell, .shell button { transition: none; } .shell button:active { transform: none; } .input[data-reveal] { animation: none; } }
password-field.tsxtsx
"use client";
import { forwardRef, useEffect, useId, useRef, useState } from "react";
import type { InputHTMLAttributes } from "react";
import { AnimatePresence, motion } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./password-field.module.css";
import { playUiSound } from "../sound/play";
/** The toggle's names. Both have Korean defaults; pass your own wording to change them. */
export interface PasswordFieldMessages { showPassword: string; hidePassword: string }
export const defaultPasswordFieldMessages: PasswordFieldMessages = { showPassword: "비밀번호 보기", hidePassword: "비밀번호 숨기기" };
export interface PasswordFieldProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
  label: string;
  description?: string;
  /** Translated or reworded toggle names. */
  messages?: Partial<PasswordFieldMessages>;
}
/** One eye that a slash draws across, cutting the outline beneath it, instead of swapping two icons. */
function EyeMorph({ slashed }: { slashed: boolean }) {
  const reduced = useReducedMotion(); const maskId = `eye-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
  const slash = { pathLength: slashed ? 1 : 0, opacity: slashed ? 1 : 0 };
  const transition = reduced ? { duration: 0 } : { pathLength: { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.standard] }, opacity: { duration: motionTokens.duration.instant } };
  return <svg width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
    <mask id={maskId} maskUnits="userSpaceOnUse" x="0" y="0" width="24" height="24"><rect width="24" height="24" fill="white" stroke="none" /><motion.path d="M2 2l20 20" stroke="black" strokeWidth={5} initial={false} animate={slash} transition={transition} /></mask>
    <g mask={`url(#${maskId})`}><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" /><circle cx="12" cy="12" r="3" /></g>
    <motion.path d="M2 2l20 20" initial={false} animate={slash} transition={transition} />
  </svg>;
}
/** Changed words rise in and unblur while unchanged words hold still. Assistive tech reads the plain copy. */
function MotionText({ text }: { text: string }) {
  const reduced = useReducedMotion();
  const words = text.split(" ");
  return <><span className={styles.srOnly}>{text}</span><span className={styles.words} aria-hidden="true"><AnimatePresence initial={false} mode="popLayout">{words.map((word, index) => <motion.span key={`${index}:${word}`} className={styles.word}
    initial={reduced ? { opacity: 0 } : { opacity: 0, y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
    exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: "-0.35em", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.exit] } }}
    transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{index < words.length - 1 ? `${word} ` : word}</motion.span>)}</AnimatePresence></span></>;
}

/** Helper and error copy: the row opens its height on a spring, then the words settle in. */
function FieldMessage({ id, text, className, alert }: { id?: string; text?: string; className?: string; alert?: boolean }) {
  return <AnimatePresence initial={false}>{text ? <MessageRow key="message" id={id} text={text} className={className} alert={alert} /> : null}</AnimatePresence>;
}

/** The row tracks the measured copy, so a longer message that wraps opens its next line instead of snapping. */
function MessageRow({ id, text, className, alert }: { id?: string; text: string; className?: string; alert?: boolean }) {
  const reduced = useReducedMotion();
  const copyRef = useRef<HTMLSpanElement>(null);
  const [height, setHeight] = useState<number | "auto">("auto");
  useEffect(() => {
    const node = copyRef.current;
    if (!node || typeof ResizeObserver === "undefined") return;
    const observer = new ResizeObserver(([entry]) => { if (entry) setHeight(entry.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight); });
    observer.observe(node);
    return () => observer.disconnect();
  }, []);
  return <motion.span className={styles.messageSlot} initial={{ height: 0, opacity: 0 }} animate={{ height, opacity: 1 }} exit={{ height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }} transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }}>
    <motion.span ref={copyRef} id={id} className={className} role={alert ? "alert" : undefined} initial={reduced ? false : { y: "0.35em", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ y: 0, filter: "blur(0px)" }} transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}><MotionText text={text} /></motion.span>
  </motion.span>;
}

export const PasswordField = forwardRef<HTMLInputElement, PasswordFieldProps>(function PasswordField({ label, description, messages: messagesProp, id, className, ...props }, ref) {
  const messages = { ...defaultPasswordFieldMessages, ...messagesProp };
  const generated = useId(); const controlId = id ?? generated; const [visible, setVisible] = useState(false); const [toggled, setToggled] = useState(false);
  const hintId = description ? `${controlId}-description` : undefined;
  // data-reveal only appears after the first toggle, so the value resolves on each change but never on mount.
  return <div className={styles.field}><label htmlFor={controlId}>{label}</label><div className={styles.shell}><input {...props} ref={ref} id={controlId} type={visible ? "text" : "password"} data-reveal={toggled ? (visible ? "shown" : "hidden") : undefined} aria-describedby={[props["aria-describedby"], hintId].filter(Boolean).join(" ") || undefined} className={[styles.input, className].filter(Boolean).join(" ")} /><button type="button" onClick={() => { playUiSound(visible ? "toggle-off" : "toggle-on"); setVisible(current => !current); setToggled(true); }} aria-label={visible ? messages.hidePassword : messages.showPassword} aria-pressed={visible}><EyeMorph slashed={visible} /></button></div><FieldMessage id={hintId} text={description} className={styles.hint} /></div>;
});

이 항목을 쓰는 조합