Components

Getting started

Components144

More

A password field with an eye that turns into eye-off, a four-step strength meter that fills and names itself, and a Caps Lock warning.

Used forSign-in formsSign-up and resetAccount settingsAPI secrets

Install with the shadcn CLI

$ npx shadcn@latest add https://hairlineui.com/r/password-input.json

Templates

Need the whole page?

43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.

All 43 templates

Where it’s used

  • Sign-in forms

    Show the password without losing the caret.

  • Sign-up and reset

    A meter that tells people how strong the new password is.

  • Account settings

    Change-password fields with a Caps Lock warning.

  • API secrets

    Masked tokens you can peek at.

What it handles for you

Most peek buttons take focus from the field, so the keyboard closes on phones and the caret jumps. This one never takes focus.

Instead of wiring upshow/hide togglestrength meterCaps Lock detection
  1. Peek without losing your place

    The toggle never takes focus, so the caret and the phone keyboard stay.

  2. A meter that fills

    Four segments fill left to right with a 50ms stagger; the word morphs through TextMorph.

  3. Bring your own scoring

    Pass a function (zxcvbn, your API) instead of the built-in length-and-variety score.

  4. Caps Lock note

    Read from the key events; it folds in under the field and away on blur.

Overview

Show/hide toggles usually swap icons, steal focus and move the caret. Here the eye turns into eye-off without leaving the field, the strength meter fills segment by segment as the word morphs from Weak to Strong, and a Caps Lock note folds in while you type with it on.

States

hidden
Dots, with the eye at the end.
shown
The eye turns into eye-off (rotate morph, 480ms); focus stays in the field.
strength
Segments fill (420ms each, 50ms apart) and turn red, amber or green; the word morphs.
caps lock
"Caps Lock is on" folds in where the hint goes.

Usage

usage.tsx
import { PasswordInput } from "@/components/ui/password-input";<PasswordInput label="Password" /><PasswordInput label="New password" strength autoComplete="new-password" />

Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.

Works with

Props

PropTypeDescription
strengthboolean | (value) => 0–4default falseShow the meter; pass a function to score it yourself.
strengthLabels[string, string, string, string]default ["Weak","Fair","Good","Strong"]Words for scores 1 to 4.
...Input propsInputPropslabel, hint, error, size, onValueChange and every native prop.

Accessibility and motion

  • Shares field.tsx with every text control: one surface, one focus ring, one way of showing hints and errors.
  • The meter is announced politely as it changes; the eye is a toggle button with aria-pressed.
  • scorePassword is exported if you want the same score elsewhere.

Requirements

React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

The CLI also installs Icon morph, Text morph, ui-interaction-motion.

Source

components/password-input.tsx
"use client";import * as React from "react";import { Eye, EyeOff } from "lucide-react";import { IconMorph } from "./icon-morph";import { Input, type InputProps } from "./input";import { TextMorph } from "./text-morph";import { EASE_OUT, INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * PASSWORD INPUT: show, measure, warn * *   show     the eye turns into eye-off (rotate morph) and the *            dots become letters; focus stays in the field *   measure  with `strength`, four segments fill left to right *            as the score climbs and the word morphs *            (Weak → Fair → Good → Strong) *   warn     Caps Lock on while typing folds a note in under it * ───────────────────────────────────────────────────────── */export type PasswordScore = 0 | 1 | 2 | 3 | 4;export interface PasswordInputProps extends Omit<InputProps, "type" | "trailing" | "clearable"> {  /** Show the four-segment meter. Pass a function to score it yourself (e.g. zxcvbn). */  strength?: boolean | ((value: string) => PasswordScore);  /** Words for scores 1 to 4. */  strengthLabels?: [string, string, string, string];}/** A plain score: length and variety. Swap in zxcvbn for real checks. */export function scorePassword(value: string): PasswordScore {  if (!value) return 0;  let score = 0;  if (value.length >= 8) score++;  if (value.length >= 12) score++;  if (/[a-z]/.test(value) && /[A-Z]/.test(value)) score++;  if (/\d/.test(value) && /[^A-Za-z0-9]/.test(value)) score++;  return Math.max(1, Math.min(4, score)) as PasswordScore;}const TONES = ["bg-red-500", "bg-amber-500", "bg-emerald-500", "bg-emerald-500"];const TEXT = ["text-red-600 dark:text-red-400", "text-amber-600 dark:text-amber-400", "text-emerald-600 dark:text-emerald-400", "text-emerald-600 dark:text-emerald-400"];export function PasswordInput({  strength,  strengthLabels = ["Weak", "Fair", "Good", "Strong"],  hint,  value,  defaultValue,  onValueChange,  onKeyDown,  onKeyUp,  onBlur,  size = "md",  ...props}: PasswordInputProps) {  const reduced = useInteractionReducedMotion();  const [shown, setShown] = React.useState(false);  const [text, setText] = React.useState(String(defaultValue ?? ""));  const [caps, setCaps] = React.useState(false);  const current = value !== undefined ? String(value) : text;  const score: PasswordScore = !strength || !current ? 0 : typeof strength === "function" ? strength(current) : scorePassword(current);  const readCaps = (e: React.KeyboardEvent<HTMLInputElement>) => setCaps(e.getModifierState?.("CapsLock") ?? false);  return (    <div className="flex w-full flex-col">      <Input        {...props}        size={size}        type={shown ? "text" : "password"}        autoComplete={props.autoComplete ?? "current-password"}        value={value}        defaultValue={defaultValue}        hint={caps ? "Caps Lock is on" : hint}        onValueChange={(v) => {          if (value === undefined) setText(v);          onValueChange?.(v);        }}        onKeyDown={(e) => {          readCaps(e);          onKeyDown?.(e);        }}        onKeyUp={(e) => {          readCaps(e);          onKeyUp?.(e);        }}        onBlur={(e) => {          setCaps(false);          onBlur?.(e);        }}        trailing={          <button            type="button"            aria-label={shown ? "Hide password" : "Show password"}            aria-pressed={shown}            // Keep focus (and the caret) in the field.            onPointerDown={(e) => e.preventDefault()}            onClick={() => setShown((s) => !s)}            className={`flex items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${size === "sm" ? "size-5" : "size-7"} ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}          >            <IconMorph size={14} mode="rotate" active={shown} from={<Eye />} to={<EyeOff />} />          </button>        }      />      {strength && (        <div className="flex items-center gap-3 px-3 pt-2" aria-live="polite">          <div className="flex flex-1 gap-1" aria-hidden="true">            {[1, 2, 3, 4].map((step) => (              <span key={step} className="relative h-1 flex-1 overflow-hidden rounded-full bg-muted">                <span                  className={`absolute inset-0 origin-left rounded-full ${TONES[Math.max(0, score - 1)]}`}                  style={{                    transform: score >= step ? "scaleX(1)" : "scaleX(0)",                    transition: reduced ? "none" : `transform 420ms ${EASE_OUT} ${score >= step ? (step - 1) * 50 : 0}ms, background-color 300ms`,                  }}                />              </span>            ))}          </div>          <span className={`w-12 text-right text-[12px] font-medium ${score ? TEXT[score - 1] : "text-muted-foreground"}`}>            <TextMorph>{score ? strengthLabels[score - 1] : " "}</TextMorph>          </span>        </div>      )}    </div>  );}export default PasswordInput;
components/field.tsx
"use client";import * as React from "react";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * FIELD: the column every text control sits in * *   label    above, with an optional aside on the right (a count) *   hint     under the control while all is well *   error    takes the hint's place: the hint folds away, the *            error rises in out of a blur, the control shakes once * * Shared by input, textarea, password, search and select so the * whole family has one surface, one focus ring and one error. * ───────────────────────────────────────────────────────── */export interface FieldProps {  id: string;  label?: React.ReactNode;  /** Right side of the label row, e.g. a character count. */  aside?: React.ReactNode;  hint?: React.ReactNode;  error?: React.ReactNode;  required?: boolean;  className?: string;  children: React.ReactNode;}export type FieldSize = "sm" | "md";/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */export function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {  const ring = invalid    ? "shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]"    : "shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]";  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? "rounded-full" : "rounded-[14px]"} ${ring} ${    disabled ? "pointer-events-none opacity-50" : ""  }`;}/** ids for the control's label, hint and error. */export function useFieldIds(id?: string) {  const auto = React.useId();  const base = id ?? auto;  return { id: base, hint: `${base}-hint`, error: `${base}-error` };}/** One short shake whenever `on` turns true (an error arrives). */export function useShake<T extends HTMLElement>(on: boolean) {  const ref = React.useRef<T>(null);  const reduced = useInteractionReducedMotion();  React.useEffect(() => {    if (!on || reduced) return;    ref.current?.animate(      [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }],      { duration: 380, easing: "ease-out" },    );  }, [on, reduced]);  return ref;}/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */export function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;  Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(el, value);  el.dispatchEvent(new Event("input", { bubbles: true }));}/** A line under the control that folds open and closed; keeps its last words while closing. */function Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {  const [kept, setKept] = React.useState(children);  if (show && children !== kept) setKept(children);  return (    <div      className="grid"      style={{ gridTemplateRows: show ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 320ms ${EASE_OUT}` }}    >      <div className="min-h-0 overflow-hidden">        <p          id={id}          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}          style={{            opacity: show ? 1 : 0,            filter: show ? "blur(0px)" : "blur(4px)",            transform: show ? "none" : "translateY(-4px)",            transition: reduced ? "none" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,          }}        >          {show ? children : kept}        </p>      </div>    </div>  );}export function Field({ id, label, aside, hint, error, required, className = "", children }: FieldProps) {  const reduced = useInteractionReducedMotion();  const ids = { hint: `${id}-hint`, error: `${id}-error` };  return (    <div className={`flex w-full flex-col ${className}`}>      {(label || aside) && (        <div className="flex items-baseline justify-between gap-3 px-3 pb-1.5">          {label && (            <label htmlFor={id} className="text-[13px] font-medium text-foreground">              {label}              {required && (                <span aria-hidden="true" className="ml-0.5 text-muted-foreground">                  *                </span>              )}            </label>          )}          {aside && <span className="text-[12px] tabular-nums text-muted-foreground">{aside}</span>}        </div>      )}      {children}      <Line show={!error && !!hint} id={ids.hint} tone="text-muted-foreground" reduced={reduced}>        {hint}      </Line>      <Line show={!!error} id={ids.error} tone="text-red-600 dark:text-red-400" reduced={reduced}>        {error}      </Line>    </div>  );}export default Field;
components/input.tsx
"use client";import * as React from "react";import { Field, fieldSurface, setNativeValue, useFieldIds, useShake, type FieldSize } from "./field";import { EASE_OUT, INTERACTION_FOCUS, interactionIcon, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * INPUT: a one-line field * *   rest     a hairline pill; hover darkens the line *   focus    the line turns to the ring with a 3px halo *   typing   a clear button folds in once there is text and *            folds away when it's gone (it hands focus back) *   error    the line turns red, the hint gives way to the *            error and the field shakes once * ───────────────────────────────────────────────────────── */export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {  label?: React.ReactNode;  hint?: React.ReactNode;  /** Shows red and replaces the hint. */  error?: React.ReactNode;  /** An icon or text before the value. */  leading?: React.ReactNode;  /** An icon, text or button after the value. */  trailing?: React.ReactNode;  /** A button that empties the field once it has text. */  clearable?: boolean;  /** 28px or 36px tall. */  size?: FieldSize;  /** Called with the new text on every change. */  onValueChange?: (value: string) => void;  ref?: React.Ref<HTMLInputElement>;  /** Class for the outer column. */  className?: string;  /** Class for the input element itself. */  inputClassName?: string;}function ClearIcon() {  return (    <svg viewBox="0 0 16 16" fill="none" className="size-3.5">      <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />    </svg>  );}export function Input({  label,  hint,  error,  leading,  trailing,  clearable,  size = "md",  onValueChange,  ref,  id,  className = "",  inputClassName = "",  value,  defaultValue,  onChange,  disabled,  required,  ...props}: InputProps) {  const reduced = useInteractionReducedMotion();  const ids = useFieldIds(id);  const inner = React.useRef<HTMLInputElement>(null);  const [text, setText] = React.useState(String(defaultValue ?? ""));  const current = value !== undefined ? String(value) : text;  const shakeRef = useShake<HTMLDivElement>(!!error);  const sm = size === "sm";  const showClear = !!clearable && current.length > 0 && !disabled;  const setRefs = (el: HTMLInputElement | null) => {    inner.current = el;    if (typeof ref === "function") ref(el);    else if (ref) (ref as React.RefObject<HTMLInputElement | null>).current = el;  };  const describedBy = [hint && !error ? ids.hint : null, error ? ids.error : null].filter(Boolean).join(" ") || undefined;  return (    <Field id={ids.id} label={label} hint={hint} error={error} required={required} className={className}>      <div ref={shakeRef} className={`${fieldSurface({ invalid: !!error, disabled })} ${sm ? "h-7 text-[12.5px]" : "h-9 text-[13.5px]"}`}>        {leading && <span className={`flex shrink-0 items-center text-muted-foreground ${sm ? "pl-2.5" : "pl-3.5"} [&_svg]:size-3.5`}>{leading}</span>}        <input          ref={setRefs}          id={ids.id}          value={value}          defaultValue={defaultValue}          disabled={disabled}          required={required}          aria-invalid={error ? true : undefined}          aria-describedby={describedBy}          onChange={(e) => {            if (value === undefined) setText(e.target.value);            onChange?.(e);            onValueChange?.(e.target.value);          }}          className={`h-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground/70 ${leading ? "pl-2" : sm ? "pl-3" : "pl-4"} ${trailing || clearable ? "pr-1" : sm ? "pr-3" : "pr-4"} ${inputClassName}`}          {...props}        />        {clearable && (          // The clear button folds its own width open, so the text never jumps.          <span            className="grid shrink-0"            style={{ gridTemplateColumns: showClear ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-columns 320ms ${EASE_OUT}` }}          >            <span className="min-w-0 overflow-hidden">              <button                type="button"                tabIndex={showClear ? 0 : -1}                aria-hidden={!showClear}                aria-label="Clear"                onClick={() => {                  if (!inner.current) return;                  setNativeValue(inner.current, "");                  inner.current.focus();                }}                className={`mr-1 flex items-center justify-center rounded-full text-muted-foreground cursor-pointer hover:bg-accent hover:text-foreground ${sm ? "size-5" : "size-7"} ${INTERACTION_FOCUS}`}                style={interactionIcon(showClear, reduced)}              >                <ClearIcon />              </button>            </span>          </span>        )}        {trailing && <span className={`flex shrink-0 items-center text-muted-foreground ${sm ? "pr-1" : "pr-1.5"} [&_svg]:size-3.5`}>{trailing}</span>}      </div>    </Field>  );}export default Input;

Registry item: /r/password-input.json