Input
The input family: base input, prefix/suffix group, search, password, copy field, textarea, plus Label / FieldError.
/
Keep it secret — never commit it to a public repo.
'use client';
import { useState } from 'react';
import { CopyField, Field, FieldDescription, FieldError, Label, PasswordInput, SearchInput, Textarea } from '@/components/ui/input';
export default function InputDemo() {
const [q, setQ] = useState('');
const [pwd, setPwd] = useState('');
const tooShort = pwd.length > 0 && pwd.length < 8;
return (
<div className="flex w-full max-w-[380px] flex-col gap-5">
<SearchInput value={q} onValueChange={setQ} placeholder="Search notes, tags…" aria-label="Search" clearLabel="Clear" />
<Field>
<Label htmlFor="pwd" required>Password</Label>
<PasswordInput
id="pwd"
value={pwd}
onChange={(e) => setPwd(e.target.value)}
invalid={tooShort}
placeholder="At least 8 characters"
showLabel="Show password"
hideLabel="Hide password"
/>
<FieldError>{tooShort ? 'Password must be at least 8 characters' : null}</FieldError>
</Field>
<Field>
<Label htmlFor="key">API key</Label>
<CopyField id="key" value="mio_sk_live_7f3a9c21e84b40d6a1f5" mask copyLabel="Copy" copiedLabel="Copied" revealLabel="Reveal" concealLabel="Hide" />
<FieldDescription>Keep it secret — never commit it to a public repo.</FieldDescription>
</Field>
<Field>
<Label htmlFor="link">Share link</Label>
<CopyField id="link" value="https://mio.example/s/8kQ2xV" copyLabel="Copy" copiedLabel="Copied" />
</Field>
<Field>
<Label htmlFor="bio">Bio</Label>
<Textarea id="bio" autoResize placeholder="Tell us about yourself…" />
</Field>
</div>
);
}
Installation
'use client';
import { AnimatePresence, motion } from 'motion/react';
import { Check, Copy, Eye, EyeSlash, MagnifyingGlass, WarningCircle, X } from '@phosphor-icons/react';
import {
useEffect,
useRef,
useState,
type ButtonHTMLAttributes,
type HTMLAttributes,
type InputHTMLAttributes,
type KeyboardEvent,
type LabelHTMLAttributes,
type ReactNode,
type Ref,
type TextareaHTMLAttributes,
} from 'react';
import { twMerge } from 'tailwind-merge';
import { cva } from 'class-variance-authority';
import { SPRING_BOUNCY } from '@/lib/motion';
import { useReducedMotionPref } from '@/lib/use-reduced-motion';
/* ==========================================================================
字段外观:surface-3 底 + 内嵌发丝边(单线框),聚焦时 cds 焦点环
========================================================================== */
const fieldShell =
'bg-surface-3 text-text-primary shadow-[inset_0_0_0_1px_var(--cds-border)] ' +
'transition-[box-shadow,background-color] duration-[120ms] ease-cds ' +
'hover:shadow-[inset_0_0_0_1px_var(--cds-border-strong)]';
const fieldInvalid = 'shadow-[inset_0_0_0_1px_var(--cds-danger)] hover:shadow-[inset_0_0_0_1px_var(--cds-danger)]';
export const inputVariants = cva(
'cds-focus w-full min-w-0 rounded-lg px-2.5 font-normal placeholder:text-text-muted ' +
'disabled:cursor-not-allowed disabled:opacity-50 read-only:cursor-default ' +
fieldShell,
{
variants: {
size: {
sm: 'h-7 text-[13px]',
md: 'h-8 text-sm',
lg: 'h-10 px-3 text-[15px]',
},
},
defaultVariants: { size: 'md' },
},
);
type FieldSize = 'sm' | 'md' | 'lg';
export type InputProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'size'> & {
size?: FieldSize;
/** 校验失败:红色内框 + aria-invalid */
invalid?: boolean;
ref?: Ref<HTMLInputElement>;
};
/** 基础输入框:h-8 / rounded-lg / surface-3 / 内嵌发丝边 / 焦点环 */
export function Input({ className, size, invalid, ...props }: InputProps) {
return (
<input
aria-invalid={invalid || undefined}
className={twMerge(inputVariants({ size }), invalid && fieldInvalid, className)}
{...props}
/>
);
}
/* ==========================================================================
InputGroup:外壳承担边框与焦点环,内部 input 无边框;prefix / suffix 槽位
========================================================================== */
const groupSize: Record<FieldSize, string> = {
sm: 'h-7 text-[13px] [&_svg]:size-3.5',
md: 'h-8 text-sm [&_svg]:size-4',
lg: 'h-10 text-[15px] [&_svg]:size-[18px]',
};
export type InputGroupProps = Omit<InputProps, 'prefix'> & {
/** 左侧槽位:图标 / 文本(如 https://) */
prefix?: ReactNode;
/** 右侧槽位:按钮 / Kbd / 单位 */
suffix?: ReactNode;
/** 作用于内部 <input> 的类名(className 作用于外壳) */
inputClassName?: string;
};
export function InputGroup({ prefix, suffix, className, inputClassName, size = 'md', invalid, disabled, ...props }: InputGroupProps) {
return (
<div
data-disabled={disabled || undefined}
className={twMerge(
'group/field flex w-full min-w-0 items-center gap-1.5 rounded-lg px-2.5',
fieldShell,
groupSize[size],
invalid && fieldInvalid,
'focus-within:shadow-focus! data-disabled:cursor-not-allowed data-disabled:opacity-50',
className,
)}
>
{prefix != null && <span className="flex shrink-0 items-center text-text-muted">{prefix}</span>}
<input
aria-invalid={invalid || undefined}
disabled={disabled}
className={twMerge(
'h-full w-full min-w-0 flex-1 bg-transparent font-normal text-text-primary outline-none placeholder:text-text-muted disabled:cursor-not-allowed',
inputClassName,
)}
{...props}
/>
{suffix != null && <span className="-mr-1 flex shrink-0 items-center gap-0.5 text-text-muted">{suffix}</span>}
</div>
);
}
/** 输入框内的小图标按钮(无边框,单线框纪律) */
function FieldIconButton({ className, ...p }: ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
className={twMerge(
'cds-focus inline-flex size-6 items-center justify-center rounded-md text-text-muted transition-colors duration-[120ms] ease-cds hover:bg-ghost-hover hover:text-text-primary [&_svg]:size-4',
className,
)}
{...p}
/>
);
}
/* ==========================================================================
Kbd
========================================================================== */
export function Kbd({ className, ...p }: HTMLAttributes<HTMLElement>) {
return (
<kbd
className={twMerge(
'inline-flex h-5 min-w-5 items-center justify-center rounded-sm bg-ghost-selected px-1 font-sans text-[11px] font-medium leading-none text-text-secondary tabular-nums',
className,
)}
{...p}
/>
);
}
/* ==========================================================================
SearchInput:放大镜前缀;有值显示 ×;为空显示快捷键提示;按快捷键聚焦
========================================================================== */
function isEditable(t: EventTarget | null) {
const el = t as HTMLElement | null;
if (!el) return false;
return el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName);
}
export type SearchInputProps = Omit<InputGroupProps, 'prefix' | 'value' | 'defaultValue' | 'onChange' | 'type'> & {
value?: string;
defaultValue?: string;
onValueChange?: (v: string) => void;
/** Enter 提交(已做 IME 组字保护) */
onSearch?: (v: string) => void;
/** 全局聚焦快捷键,默认 "/";传 false 关闭 */
hotkey?: string | false;
clearLabel?: string;
};
export function SearchInput({
value: valueProp,
defaultValue = '',
onValueChange,
onSearch,
hotkey = '/',
clearLabel = 'Clear',
suffix,
onKeyDown,
ref: refProp,
...props
}: SearchInputProps) {
const [inner, setInner] = useState(defaultValue);
const value = valueProp ?? inner;
const innerRef = useRef<HTMLInputElement | null>(null);
const set = (v: string) => {
if (valueProp === undefined) setInner(v);
onValueChange?.(v);
};
useEffect(() => {
if (!hotkey) return;
const onKey = (e: globalThis.KeyboardEvent) => {
if (e.key !== hotkey || e.metaKey || e.ctrlKey || e.altKey || e.isComposing) return;
if (isEditable(e.target)) return;
e.preventDefault();
innerRef.current?.focus();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [hotkey]);
const setRef = (el: HTMLInputElement | null) => {
innerRef.current = el;
if (typeof refProp === 'function') refProp(el);
else if (refProp) (refProp as { current: HTMLInputElement | null }).current = el;
};
return (
<InputGroup
{...props}
ref={setRef}
type="search"
role="searchbox"
value={value}
onChange={(e) => set(e.target.value)}
onKeyDown={(e: KeyboardEvent<HTMLInputElement>) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (e.key === 'Enter') onSearch?.(value);
if (e.key === 'Escape') {
if (value) {
e.preventDefault();
e.stopPropagation();
set('');
} else e.currentTarget.blur();
}
}}
inputClassName={twMerge('[&::-webkit-search-cancel-button]:hidden', props.inputClassName)}
prefix={<MagnifyingGlass />}
suffix={
<>
{suffix}
{value ? (
<FieldIconButton
aria-label={clearLabel}
onClick={() => {
set('');
innerRef.current?.focus();
}}
>
<X />
</FieldIconButton>
) : hotkey ? (
<Kbd aria-hidden className="mr-1">
{hotkey}
</Kbd>
) : null}
</>
}
/>
);
}
/* ==========================================================================
PasswordInput:眼睛切换显隐
========================================================================== */
export type PasswordInputProps = Omit<InputGroupProps, 'type'> & {
showLabel?: string;
hideLabel?: string;
};
export function PasswordInput({ showLabel = 'Show password', hideLabel = 'Hide password', suffix, ...props }: PasswordInputProps) {
const [visible, setVisible] = useState(false);
return (
<InputGroup
autoComplete="current-password"
{...props}
type={visible ? 'text' : 'password'}
suffix={
<>
{suffix}
<FieldIconButton
aria-label={visible ? hideLabel : showLabel}
aria-pressed={visible}
disabled={props.disabled}
onClick={() => setVisible((v) => !v)}
>
{visible ? <EyeSlash /> : <Eye />}
</FieldIconButton>
</>
}
/>
);
}
/* ==========================================================================
CopyField:只读等宽值(API Key / 链接),可遮罩;复制后 BOUNCY 弹出对勾 1.5s
========================================================================== */
async function copyText(text: string) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
ta.remove();
return ok;
}
}
const defaultMask = (v: string) => {
const keep = Math.min(8, Math.floor(v.length / 3));
return v.slice(0, keep) + '•'.repeat(Math.max(8, Math.min(24, v.length - keep)));
};
export interface CopyFieldProps {
value: string;
/** true 使用默认遮罩(保留前缀 + •),或传自定义遮罩函数;带显隐切换 */
mask?: boolean | ((v: string) => string);
size?: FieldSize;
className?: string;
id?: string;
'aria-label'?: string;
copyLabel?: string;
copiedLabel?: string;
revealLabel?: string;
concealLabel?: string;
onCopy?: (v: string) => void;
}
export function CopyField({
value,
mask,
size = 'md',
className,
id,
'aria-label': ariaLabel,
copyLabel = 'Copy',
copiedLabel = 'Copied',
revealLabel = 'Reveal',
concealLabel = 'Hide',
onCopy,
}: CopyFieldProps) {
const [copied, setCopied] = useState(false);
const [revealed, setRevealed] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout>>(undefined);
const reduce = useReducedMotionPref();
useEffect(() => () => clearTimeout(timer.current), []);
const masked = mask && !revealed;
const display = masked ? (typeof mask === 'function' ? mask(value) : defaultMask(value)) : value;
const doCopy = async () => {
if (!(await copyText(value))) return;
onCopy?.(value);
setCopied(true);
clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1500);
};
const iconMotion = reduce
? { initial: false as const, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0 } }
: { initial: { scale: 0.4, opacity: 0 }, animate: { scale: 1, opacity: 1 }, exit: { scale: 0.4, opacity: 0 }, transition: SPRING_BOUNCY };
return (
<InputGroup
id={id}
aria-label={ariaLabel}
size={size}
readOnly
value={display}
onFocus={(e) => e.currentTarget.select()}
className={className}
inputClassName="font-mono text-[0.92em] tracking-tight text-text-secondary"
suffix={
<>
{mask && (
<FieldIconButton aria-label={revealed ? concealLabel : revealLabel} aria-pressed={revealed} onClick={() => setRevealed((r) => !r)}>
{revealed ? <EyeSlash /> : <Eye />}
</FieldIconButton>
)}
<FieldIconButton
aria-label={copied ? copiedLabel : copyLabel}
onClick={doCopy}
className={copied ? 'text-success hover:text-success' : undefined}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span key={copied ? 'ok' : 'copy'} className="inline-flex" {...iconMotion}>
{copied ? <Check weight="bold" /> : <Copy />}
</motion.span>
</AnimatePresence>
</FieldIconButton>
<span aria-live="polite" className="sr-only">
{copied ? copiedLabel : ''}
</span>
</>
}
/>
);
}
/* ==========================================================================
Textarea
========================================================================== */
export type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement> & {
invalid?: boolean;
/** 随内容自动增高(field-sizing: content) */
autoResize?: boolean;
ref?: Ref<HTMLTextAreaElement>;
};
export function Textarea({ className, invalid, autoResize, ...props }: TextareaProps) {
return (
<textarea
aria-invalid={invalid || undefined}
className={twMerge(
'cds-focus block min-h-20 w-full min-w-0 resize-y rounded-lg px-2.5 py-2 text-sm leading-relaxed font-normal placeholder:text-text-muted disabled:cursor-not-allowed disabled:opacity-50',
fieldShell,
invalid && fieldInvalid,
autoResize && 'resize-none [field-sizing:content] max-h-60',
className,
)}
{...props}
/>
);
}
/* ==========================================================================
Label / Field / FieldDescription / FieldError
========================================================================== */
export function Label({ className, required, children, ...p }: LabelHTMLAttributes<HTMLLabelElement> & { required?: boolean }) {
return (
<label className={twMerge('inline-flex items-center gap-1 text-[13px] font-medium text-text-primary select-none', className)} {...p}>
{children}
{required && (
<span aria-hidden className="text-danger">
*
</span>
)}
</label>
);
}
/** 纵向字段容器:Label + 控件 + 说明/错误 */
export function Field({ className, ...p }: HTMLAttributes<HTMLDivElement>) {
return <div className={twMerge('flex flex-col gap-1.5', className)} {...p} />;
}
export function FieldDescription({ className, ...p }: HTMLAttributes<HTMLParagraphElement>) {
return <p className={twMerge('text-[12px] leading-snug text-text-muted', className)} {...p} />;
}
/** 校验错误:无内容时不渲染;role="alert" 读屏即时播报 */
export function FieldError({ className, children, ...p }: HTMLAttributes<HTMLParagraphElement>) {
if (children == null || children === false || children === '') return null;
return (
<p role="alert" className={twMerge('flex items-center gap-1 text-[12px] leading-snug text-danger [&_svg]:size-3.5 [&_svg]:shrink-0', className)} {...p}>
<WarningCircle weight="fill" />
<span>{children}</span>
</p>
);
}
Usage
import { Field, Label, Input, InputGroup, SearchInput, PasswordInput, CopyField, Textarea, FieldError, Kbd } from "@/components/ui/input";
<SearchInput value={q} onValueChange={setQ} placeholder="Search…" /> // press / to focus
<Field>
<Label htmlFor="pwd" required>Password</Label>
<PasswordInput id="pwd" invalid={!!err} />
<FieldError>{err}</FieldError>
</Field>
<CopyField value="mio_sk_live_…" mask />
<InputGroup prefix="https://" suffix={<Kbd>⌘K</Kbd>} />| Prop | Type | Default | Notes |
|---|---|---|---|
Input size | sm md lg | md | 28 / 32 / 40px |
invalid | boolean | — | Red inset ring + aria-invalid |
InputGroup prefix / suffix | ReactNode | — | Leading / trailing slots |
SearchInput onValueChange / onSearch | (v) => void | — | Value change / Enter submit (IME-guarded) |
hotkey | string | false | "/" | Global focus hotkey |
CopyField mask | boolean | (v) => string | — | Mask with reveal toggle |
Textarea autoResize | boolean | false | Grow with content |