textarea · neumorphic · 灵感来源:Neumorphism(同色凹槽,延续拟态输入框)
Autosizing Textarea · 拟态多行输入 · 多行 / Neumorphic
做一个 Autosizing Textarea(拟态多行输入):
- 同色凹槽:左上暗、右下亮两条 inset 阴影,没有边框
- focus 在凹槽外叠 2px accent 环
- autosize 同样按 maxRows 截断,凹槽不无限长
- error 态用 1px warning 描边,尺寸不变
用这些 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/textarea--neumorphic.json
免外壳的 markdown 版:/llms/components/textarea--neumorphic.md--neuro-bg + 两条 inset(左上暗、右下亮),没有边框与模糊;focus 在凹槽外再叠一圈 accent 环label / hint / errorMessage / required / showCount / autosize / maxRowsautosize:凹槽高度随内容长,超过 maxRows 转内部滚动——但凹槽本身不能无限长,否则拟态的"压下去"读不出来。
限制:与拟态单行同一前提,光从左上来;渐变或强纹理底上深度感会失效。
import { useRef, useState, useEffect, forwardRef, type TextareaHTMLAttributes } from 'react';
import { Field } from '~/components/Field';
interface Props extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
hint?: string;
loading?: boolean;
error?: boolean;
errorMessage?: string;
required?: boolean;
showCount?: boolean;
/** 随内容长高;超过 maxRows 转为内部滚动 */
autosize?: boolean;
maxRows?: number;
}
export const NeumorphicTextarea = forwardRef<HTMLTextAreaElement, Props>(
(
{
label,
hint,
loading = false,
error = false,
errorMessage,
required = false,
showCount = false,
autosize = false,
maxRows = 8,
className = '',
id,
value,
defaultValue,
onChange,
rows,
...rest
},
ref,
) => {
const localRef = useRef<HTMLTextAreaElement>(null);
const setRef = (node: HTMLTextAreaElement | null) => {
localRef.current = node;
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
};
const [inner, setInner] = useState(String(defaultValue ?? ''));
const rowCount = rows ?? (autosize ? 1 : 3);
const text = value !== undefined ? String(value) : inner;
useEffect(() => {
const el = localRef.current;
if (!el || !autosize) return;
const cs = getComputedStyle(el);
const line = parseFloat(cs.lineHeight) || 20;
const chrome =
parseFloat(cs.paddingTop) +
parseFloat(cs.paddingBottom) +
parseFloat(cs.borderTopWidth) +
parseFloat(cs.borderBottomWidth);
const max = Math.max(maxRows, rowCount) * line + chrome;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, max)}px`;
el.style.overflowY = el.scrollHeight > max ? 'auto' : 'hidden';
}, [text, autosize, maxRows, rowCount]);
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={`
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' : ''}
`}
>
<textarea
{...rest}
ref={setRef}
id={field.id}
rows={rowCount}
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={`
block w-full bg-transparent px-4 py-2.5
text-[var(--color-text)] leading-relaxed
placeholder:text-[var(--color-text-subtle)]
focus:outline-none
disabled:cursor-not-allowed
${autosize ? 'resize-none' : 'resize-y'}
`}
/>
</div>
)}
</Field>
);
},
);
NeumorphicTextarea.displayName = 'NeumorphicTextarea';
src/components/textarea/neumorphic/