input · neumorphic · 灵感来源:Neumorphism(同色凹槽:暗侧内投影 + 亮侧内高光)
Neumorphic Input · 拟态输入框 · 输入 / Neumorphic
做一个 Neumorphic Input(拟态输入框):
- 输入区是同色凹槽:左上暗、右下亮两条 inset 阴影
- 没有边框与模糊;focus 叠一圈 2px accent 环
- error 态用 1px warning 描边,尺寸不变
- disabled 态降透明度且不可聚焦
用这些 CSS 变量:--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --neuro-bg --neuro-dark --neuro-light --text-2xs --text-base --text-xs
灵感参考:Neumorphism(同色凹槽:暗侧内投影 + 亮侧内高光)--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --neuro-bg --neuro-dark --neuro-light --text-2xs --text-base --text-xs
焦点 / 禁用 / 错误
(尚未接进整页场景)
/r/input--neumorphic.json
免外壳的 markdown 版:/llms/components/input--neumorphic.md--neuro-bg 同色底 + 两条 inset 阴影(左上暗、右下亮),没有边框与模糊--neuro-bg / --neuro-light / --neuro-dark 整组换值,凹槽自动成立限制:凹槽依赖光从左上来;背景若是渐变或有强纹理,深度感会失效——和拟态按钮一样只在明确语境里用。
import { useRef, useState, forwardRef, type InputHTMLAttributes, type ReactNode } from 'react';
import { Eye, EyeOff, X } from 'lucide-react';
import { Icon } from '~/components/Icon';
import { Field } from '~/components/Field';
interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'> {
label?: string;
hint?: string;
/** 验证中:标签右侧转圈 */
loading?: boolean;
/** 错误态:描边转语义色 + aria-invalid(状态矩阵见 docs/standards/states.md) */
error?: boolean;
/** 错误文案:渲染在下方并接入 aria-describedby + role="alert" */
errorMessage?: string;
required?: boolean;
prefix?: ReactNode;
suffix?: ReactNode;
/** 有值时显示清除按钮 */
clearable?: boolean;
/** password 显隐切换 */
reveal?: boolean;
/** 字数计数:配合 maxLength 显示 n / max */
showCount?: boolean;
}
/** 清空需要绕过 React 的 value 追踪:直接写原生 setter 再派发 input */
const setNativeValue = (el: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
setter?.call(el, value);
el.dispatchEvent(new Event('input', { bubbles: true }));
};
export const NeumorphicInput = forwardRef<HTMLInputElement, Props>(
(
{
label,
hint,
loading = false,
error = false,
errorMessage,
required = false,
prefix,
suffix,
clearable = false,
reveal = false,
showCount = false,
className = '',
id,
value,
defaultValue,
onChange,
type = 'text',
...rest
},
ref,
) => {
const localRef = useRef<HTMLInputElement>(null);
const setRef = (node: HTMLInputElement | null) => {
localRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
};
const [inner, setInner] = useState(String(defaultValue ?? ''));
const [revealed, setRevealed] = useState(false);
const text = value !== undefined ? String(value) : inner;
return (
<Field
id={id}
label={label}
hint={hint}
error={error}
errorMessage={errorMessage}
required={required}
loading={loading}
counter={showCount ? `${text.length}${rest.maxLength ? ` / ${rest.maxLength}` : ''}` : undefined}
className={className}
>
{(field) => (
<div
className={`
flex items-center
rounded-lg
bg-[var(--neuro-bg)] border border-transparent
shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light)]
transition-[border-color,box-shadow] duration-[var(--dur-fast)]
${field.invalid
? 'border-[var(--color-warning-fg)]'
: 'focus-within:shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light),0_0_0_2px_var(--color-accent)]'}
${rest.disabled ? 'opacity-50' : ''}
`}
>
{prefix && (
<span className="pl-3 shrink-0 text-[var(--color-text-muted)] text-sm">{prefix}</span>
)}
<input
{...rest}
ref={setRef}
id={field.id}
type={reveal && type === 'password' && revealed ? 'text' : type}
value={value}
defaultValue={defaultValue}
required={required}
aria-required={field.required || undefined}
aria-invalid={field.invalid || undefined}
aria-describedby={field.describedBy}
onChange={(e) => {
setInner(e.target.value);
onChange?.(e);
}}
className={`
w-full bg-transparent py-2.5
${prefix ? 'pl-2' : 'pl-4'} ${suffix || clearable || reveal ? 'pr-1' : 'pr-4'}
text-[var(--color-text)]
placeholder:text-[var(--color-text-subtle)]
focus:outline-none
disabled:cursor-not-allowed
`}
/>
{clearable && text !== '' && (
<button
type="button"
onClick={() => localRef.current && setNativeValue(localRef.current, '')}
aria-label="清除输入"
className="press-feedback shrink-0 grid place-items-center size-6 rounded-full text-[var(--color-text-subtle)] hover:text-[var(--color-text)] cursor-pointer"
>
<Icon icon={X} size={13} />
</button>
)}
{reveal && type === 'password' && (
<button
type="button"
onClick={() => setRevealed((v) => !v)}
aria-label={revealed ? '隐藏密码' : '显示密码'}
aria-pressed={revealed}
className="press-feedback shrink-0 grid place-items-center size-6 rounded-full text-[var(--color-text-subtle)] hover:text-[var(--color-text)] cursor-pointer"
>
<Icon icon={revealed ? EyeOff : Eye} size={14} />
</button>
)}
{suffix && (
<span className="pr-3 shrink-0 text-[var(--color-text-muted)] text-sm">{suffix}</span>
)}
</div>
)}
</Field>
);
},
);
NeumorphicInput.displayName = 'NeumorphicInput';
src/components/input/neumorphic/