Password Input
A password field with a show/hide toggle. It stays a real password input until the user chooses to reveal it, so browsers and password managers recognise and fill it. Built on Input.
Installation
Add Password Input to your project with the CK-UI CLI. It copies the source into your project, so the code is yours to change.
npx ck-ui-cli@latest add password-input- Adds
PasswordInput.tsxField.tsxInput.tsxLabel.tsxto your CK-UI folder (src/components/ck-ui by default)- Installs
@radix-ui/react-label@radix-ui/react-slotreact-iconsif your project does not have them yet
First time with CK-UI in this project? Run npx ck-ui-cli@latest init once before adding components. The installation guide explains what it sets up.
Manual installationInstall the packages and copy the files yourself.
Install the following dependencies
Run these commands in your terminal to install the required packages
npm install react-icons @radix-ui/react-slot @radix-ui/react-labelAdd the Input component
PasswordInput imports ./Input, so add this file next to it
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import { useField, useFieldControlProps } from "./Field";
type InputSize = "sm" | "md" | "lg";
const sizes = {
root: { sm: "h-8", md: "h-9", lg: "h-10" },
wrapper: { sm: "gap-1.5 px-2", md: "gap-2 px-2.5", lg: "gap-2 px-3" },
icon: { sm: "size-3.5", md: "size-4", lg: "size-[18px]" },
affix: { sm: "px-2", md: "px-2.5", lg: "px-3" },
} as const;
interface InputContextValue {
size: InputSize;
invalid: boolean;
}
const InputContext = React.createContext<InputContextValue>({ size: "md", invalid: false });
export interface InputRootProps extends React.HTMLAttributes<HTMLDivElement> {
size?: InputSize;
/** Error state: red border and aria-invalid on the input. Also taken from Field.Root. */
invalid?: boolean;
/** @deprecated Use `invalid`. */
error?: boolean;
asChild?: boolean;
}
const Root = React.forwardRef<HTMLDivElement, InputRootProps>(({ className, children, size = "md", invalid, error, asChild = false, ...props }, ref) => {
const field = useField();
const isInvalid = Boolean(invalid ?? error ?? field?.invalid);
const Component = asChild ? Slot : "div";
return (
<InputContext.Provider value={{ size, invalid: isInvalid }}>
<Component
ref={ref}
data-invalid={isInvalid || undefined}
className={cn(
"group/input relative flex w-full overflow-hidden rounded-lg border shadow-x-small transition duration-base ease-standard",
sizes.root[size],
// Focus: strong border plus a soft halo outside it (field-focus-within), as in CK-UI's original design.
isInvalid
? "border-destructive outline-destructive/25 focus-within:border-destructive hover:bg-destructive-subtle focus-within:hover:bg-transparent"
: "border-input outline-accent focus-within:border-foreground hover:bg-muted focus-within:hover:bg-transparent",
"field-focus-within",
"has-[input:disabled]:pointer-events-none has-[input:disabled]:cursor-not-allowed has-[input:disabled]:bg-muted has-[input:disabled]:shadow-none",
className
)}
{...props}
>
{children}
</Component>
</InputContext.Provider>
);
});
Root.displayName = "Input.Root";
export interface InputWrapperProps extends React.HTMLAttributes<HTMLDivElement> {
asChild?: boolean;
}
// A <div>, not a <label>: affix text must not become part of the input's accessible name.
// Clicking anywhere in the wrapper still focuses the input.
const Wrapper = React.forwardRef<HTMLDivElement, InputWrapperProps>(({ className, asChild = false, onMouseDown, ...props }, ref) => {
const { size } = React.useContext(InputContext);
const Component = asChild ? Slot : "div";
const handleMouseDown = (event: React.MouseEvent<HTMLDivElement>) => {
onMouseDown?.(event);
if (event.defaultPrevented) return;
const target = event.target as HTMLElement;
if (target.closest("input, button, a, select, textarea")) return;
const input = event.currentTarget.querySelector("input");
if (input && !input.disabled) {
event.preventDefault();
input.focus();
}
};
return (
<Component
ref={ref}
onMouseDown={handleMouseDown}
className={cn("flex w-full cursor-text items-center transition-colors duration-base", sizes.wrapper[size], className)}
{...props}
/>
);
});
Wrapper.displayName = "Input.Wrapper";
export interface InputIconProps extends React.SVGAttributes<SVGElement> {
/** Icon component, e.g. `as={RiSearchLine}`. */
as?: React.ElementType;
}
const Icon = React.forwardRef<SVGElement, InputIconProps>(({ as: Component, className, ...props }, ref) => {
const { size, invalid } = React.useContext(InputContext);
if (!Component) return null;
return (
<Component
ref={ref}
aria-hidden="true"
className={cn(
"flex shrink-0 select-none items-center justify-center transition duration-base",
invalid ? "text-destructive" : "text-muted-foreground group-focus-within/input:text-foreground group-has-[input:not(:placeholder-shown)]/input:text-foreground",
"group-has-[input:disabled]/input:text-muted-foreground/60",
sizes.icon[size],
className
)}
{...props}
/>
);
});
Icon.displayName = "Input.Icon";
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(({ className, type = "text", ...props }, ref) => {
const { invalid } = React.useContext(InputContext);
const controlProps = useFieldControlProps(props, { invalid });
return (
<input
ref={ref}
type={type}
className={cn(
"w-full border-none bg-transparent p-0 size-p-sm text-foreground",
// The wrapper (Input.Root) shows focus; suppress the element's own outline and the forms-plugin ring.
"outline-none focus:outline-none focus:ring-0",
"placeholder:text-muted-foreground placeholder:transition-colors",
"disabled:cursor-not-allowed disabled:text-muted-foreground disabled:placeholder:text-muted-foreground/60",
className
)}
{...controlProps}
/>
);
});
Input.displayName = "Input.Input";
export interface InputAffixProps extends React.HTMLAttributes<HTMLDivElement> {
position?: "start" | "end";
}
const Affix = React.forwardRef<HTMLDivElement, InputAffixProps>(({ className, children, position = "start", ...props }, ref) => {
const { size, invalid } = React.useContext(InputContext);
return (
<div
ref={ref}
className={cn(
"flex shrink-0 select-none items-center justify-center size-p-sm transition duration-base",
invalid ? "text-destructive" : "text-muted-foreground group-has-[input:not(:placeholder-shown)]/input:text-foreground",
position === "start" ? "border-r border-input" : "border-l border-input",
sizes.affix[size],
className
)}
{...props}
>
{children}
</div>
);
});
Affix.displayName = "Input.Affix";
export { Root, Wrapper, Icon, Input, Affix };
Add the Field component
PasswordInput imports ./Field, so add this file next to it
"use client";
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@/lib/utils";
import * as Label from "./Label";
/*
* Field wires a label, description and message to one form control:
* Field.Label → <label for={controlId}>
* Field.Description → referenced by aria-describedby
* Field.Message → referenced by aria-describedby (error text when invalid)
* invalid / required / disabled → aria-invalid, required/aria-required, disabled on the control
* Controls (Input, Textarea, Select, Checkbox, Switch, Radio) read the context through
* useFieldControlProps. It is plain React state: it works with native forms and form libraries.
*/
type FieldContextValue = {
controlId: string;
labelId: string;
descriptionId: string;
messageId: string;
invalid: boolean;
required: boolean;
disabled: boolean;
hasDescription: boolean;
hasMessage: boolean;
setHasDescription: (value: boolean) => void;
setHasMessage: (value: boolean) => void;
};
const FieldContext = React.createContext<FieldContextValue | null>(null);
export function useField() {
return React.useContext(FieldContext);
}
/**
* Provided by Fieldset.Root. A native <fieldset disabled> already disables the controls inside it;
* this lets each Field.Root reflect that state (label styling, `disabled` on the control).
*/
export const FieldsetContext = React.createContext<{ disabled: boolean } | null>(null);
export interface FieldRootProps extends React.HTMLAttributes<HTMLDivElement> {
/** Id for the control; generated when omitted. */
id?: string;
invalid?: boolean;
required?: boolean;
disabled?: boolean;
}
const Root = React.forwardRef<HTMLDivElement, FieldRootProps>(({ id, invalid = false, required = false, disabled: disabledProp = false, className, children, ...props }, ref) => {
const fieldset = React.useContext(FieldsetContext);
const disabled = disabledProp || Boolean(fieldset?.disabled);
const generatedId = React.useId();
const controlId = id ?? `field-${generatedId}`;
const [hasDescription, setHasDescription] = React.useState(false);
const [hasMessage, setHasMessage] = React.useState(false);
const value = React.useMemo<FieldContextValue>(
() => ({
controlId,
labelId: `${controlId}-label`,
descriptionId: `${controlId}-description`,
messageId: `${controlId}-message`,
invalid,
required,
disabled,
hasDescription,
hasMessage,
setHasDescription,
setHasMessage,
}),
[controlId, invalid, required, disabled, hasDescription, hasMessage]
);
return (
<FieldContext.Provider value={value}>
<div
ref={ref}
className={cn("flex flex-col gap-1.5", className)}
data-invalid={invalid || undefined}
data-disabled={disabled || undefined}
{...props}
>
{children}
</div>
</FieldContext.Provider>
);
});
Root.displayName = "Field.Root";
type FieldLabelProps = React.ComponentPropsWithoutRef<typeof Label.Root>;
const FieldLabel = React.forwardRef<React.ElementRef<typeof Label.Root>, FieldLabelProps>(({ children, ...props }, ref) => {
const field = useField();
return (
<Label.Root ref={ref} id={field?.labelId} htmlFor={field?.controlId} disabled={field?.disabled} {...props}>
{children}
{field?.required && <Label.Asterisk />}
</Label.Root>
);
});
FieldLabel.displayName = "Field.Label";
const Description = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(({ className, ...props }, ref) => {
const field = useField();
const setHasDescription = field?.setHasDescription;
React.useEffect(() => {
if (!setHasDescription) return;
setHasDescription(true);
return () => setHasDescription(false);
}, [setHasDescription]);
return <p ref={ref} id={field?.descriptionId} className={cn("size-p-xs text-muted-foreground", className)} {...props} />;
});
Description.displayName = "Field.Description";
export interface FieldMessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
/** Only render while the field is invalid (default true). */
showWhenValid?: boolean;
}
const Message = React.forwardRef<HTMLParagraphElement, FieldMessageProps>(({ className, showWhenValid = false, children, ...props }, ref) => {
const field = useField();
const visible = Boolean(children) && (showWhenValid || !field || field.invalid);
const setHasMessage = field?.setHasMessage;
React.useEffect(() => {
if (!setHasMessage || !visible) return;
setHasMessage(true);
return () => setHasMessage(false);
}, [setHasMessage, visible]);
if (!visible) return null;
return (
<p
ref={ref}
id={field?.messageId}
aria-live="polite"
className={cn("size-p-xs", field?.invalid ? "text-destructive-strong" : "text-muted-foreground", className)}
{...props}
>
{children}
</p>
);
});
Message.displayName = "Field.Message";
type ControlAttributes = {
id?: string;
"aria-describedby"?: string;
"aria-invalid"?: React.AriaAttributes["aria-invalid"];
"aria-required"?: React.AriaAttributes["aria-required"];
required?: boolean;
disabled?: boolean;
};
/**
* Merges Field context into a control's props. Explicit props win; describedby ids are combined.
*/
export function useFieldControlProps<P extends ControlAttributes>(props: P, options: { invalid?: boolean } = {}): P & ControlAttributes {
const field = useField();
const invalid = Boolean(options.invalid || field?.invalid || props["aria-invalid"] === true || props["aria-invalid"] === "true");
const describedBy = [props["aria-describedby"], field?.hasDescription ? field.descriptionId : undefined, field?.hasMessage ? field.messageId : undefined]
.filter(Boolean)
.join(" ");
return {
...props,
id: props.id ?? field?.controlId,
"aria-describedby": describedBy || undefined,
"aria-invalid": invalid || undefined,
required: props.required ?? (field?.required || undefined),
disabled: props.disabled ?? (field?.disabled || undefined),
};
}
/** Applies Field wiring to any custom control element (single child). */
const Control = React.forwardRef<HTMLElement, React.HTMLAttributes<HTMLElement> & ControlAttributes>((props, ref) => {
const controlProps = useFieldControlProps(props);
return <Slot ref={ref} {...controlProps} />;
});
Control.displayName = "Field.Control";
export { Root, FieldLabel as Label, Description, Message, Control };
Add the Label component
PasswordInput imports ./Label, so add this file next to it
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
export interface LabelRootProps extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> {
/** Dims the label. The control itself must also be disabled. */
disabled?: boolean;
}
const Root = React.forwardRef<React.ElementRef<typeof LabelPrimitive.Root>, LabelRootProps>(({ className, disabled, ...props }, ref) => {
return (
<LabelPrimitive.Root
ref={ref}
data-disabled={disabled || undefined}
className={cn(
"group flex cursor-pointer items-center gap-px size-p-sm text-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground/60",
className
)}
{...props}
/>
);
});
Root.displayName = "Label.Root";
// Visual required marker. Screen readers get "required" from the control (required / aria-required).
const Asterisk = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, children, ...props }, ref) => (
<span ref={ref} aria-hidden="true" className={cn("text-primary-strong group-data-[disabled]:text-muted-foreground/60", className)} {...props}>
{children || "*"}
</span>
));
Asterisk.displayName = "Label.Asterisk";
const Sub = React.forwardRef<HTMLSpanElement, React.HTMLAttributes<HTMLSpanElement>>(({ className, ...props }, ref) => (
<span ref={ref} className={cn("size-p-xs text-muted-foreground group-data-[disabled]:text-muted-foreground/60", className)} {...props} />
));
Sub.displayName = "Label.Sub";
export { Root, Asterisk, Sub };
Create or update your PasswordInput component
Copy this code to create your PasswordInput component
"use client";
import * as React from "react";
import { RiEyeLine, RiEyeOffLine } from "react-icons/ri";
import { cn } from "@/lib/utils";
import { useField } from "./Field";
import * as Input from "./Input";
/*
* A password field built on Input. It stays a real <input type="password"> until the user presses the toggle
* (aria-pressed, fixed label "Show password"), so browsers and password managers treat it as a password.
* It is hidden again when its form is submitted. Pass autoComplete="new-password" for sign-up / change forms.
*/
export interface PasswordInputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "size"> {
size?: "sm" | "md" | "lg";
invalid?: boolean;
/** Password shown as text (controlled). */
visible?: boolean;
defaultVisible?: boolean;
onVisibleChange?: (visible: boolean) => void;
/** Accessible name of the toggle. It does not change with the state (aria-pressed carries that). */
toggleLabel?: string;
/** Classes for the outer Input.Root. */
className?: string;
inputClassName?: string;
}
const PasswordInput = React.forwardRef<HTMLInputElement, PasswordInputProps>(
(
{
size = "md",
invalid,
visible: visibleProp,
defaultVisible = false,
onVisibleChange,
toggleLabel = "Show password",
className,
inputClassName,
id,
autoComplete = "current-password",
disabled,
...props
},
forwardedRef
) => {
const field = useField();
const generatedId = React.useId();
const inputId = id ?? field?.controlId ?? `password-${generatedId}`;
const [uncontrolled, setUncontrolled] = React.useState(defaultVisible);
const visible = visibleProp ?? uncontrolled;
const inputRef = React.useRef<HTMLInputElement | null>(null);
const isDisabled = disabled ?? field?.disabled ?? false;
const setVisible = React.useCallback(
(next: boolean) => {
if (visibleProp === undefined) setUncontrolled(next);
onVisibleChange?.(next);
},
[visibleProp, onVisibleChange]
);
// Hide again before the form submits, so the value is never left on screen and managers see a password field.
React.useEffect(() => {
const form = inputRef.current?.form;
if (!form) return;
const onSubmit = () => setVisible(false);
form.addEventListener("submit", onSubmit);
return () => form.removeEventListener("submit", onSubmit);
}, [setVisible]);
return (
<Input.Root size={size} invalid={invalid} className={className}>
<Input.Wrapper className="pr-1">
<Input.Input
ref={(node) => {
inputRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
id={inputId}
type={visible ? "text" : "password"}
autoComplete={autoComplete}
autoCapitalize="off"
autoCorrect="off"
spellCheck={false}
disabled={disabled}
className={inputClassName}
{...props}
/>
<button
type="button"
aria-label={toggleLabel}
aria-pressed={visible}
aria-controls={inputId}
disabled={isDisabled}
onClick={() => setVisible(!visible)}
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors duration-fast ease-standard",
"hover:bg-muted hover:text-foreground focus-ring disabled:pointer-events-none disabled:opacity-50"
)}
>
{visible ? <RiEyeOffLine aria-hidden="true" className="size-4" /> : <RiEyeLine aria-hidden="true" className="size-4" />}
</button>
</Input.Wrapper>
</Input.Root>
);
}
);
PasswordInput.displayName = "PasswordInput.Root";
export { PasswordInput as Root };
Test Your Component
- 1. Add the component to a test page
- 2. Check all props and variants work as expected
- 3. Verify responsive behavior
- 4. Test with different content/data
- 5. Ensure accessibility features work correctly
New Password
Use autoComplete="new-password" on sign-up and change-password forms so password managers offer a generated password. Describe requirements in Field.Description.
At least 12 characters. A passphrase of a few unrelated words works well.
Validation
Field.Root invalid shows the message and sets aria-invalid.
Disabled
A disabled Field disables the input and the toggle.
Managed by your organization's owner.
Accessibility
- The field is
type="password"while hidden, withautocomplete(defaultcurrent-password), spellcheck and autocapitalize off. - The toggle is a button named "Show password" with
aria-pressedandaria-controls; its name does not change, the pressed state tells whether the password is visible. - The password is hidden again when its form is submitted.
- No strength meter: CK-UI does not ship password-scoring heuristics. Describe your actual rules in the description.
API Reference
Import with import * as PasswordInput from "@/components/ui/PasswordInput". Other input attributes (name, value, onChange, minLength…) go to the input; ref points at it.
PasswordInput.Root
| Prop | Type | Default | Description |
|---|---|---|---|
autoComplete | "string" | "current-password" | Use "new-password" when creating a password. |
visible / defaultVisible | "boolean" | "false" | Password shown as text. |
onVisibleChange | "(visible: boolean) => void" | "undefined" | Called when toggled. |
toggleLabel | "string" | "Show password" | Accessible name of the toggle. |
size | "sm""md""lg" | "md" | Height. |
invalid / disabled / required | "boolean" | "from Field" | State; also read from Field.Root. |
className | "string" | "undefined" | Classes for the outer Input.Root. |
inputClassName | "string" | "undefined" | Classes for the input. |
Used in these blocks
Block families with at least one block built on this component. Each page previews its blocks and lists the components and packages they use.