玻璃多行输入

textarea · glass-apple · 灵感来源:visionOS / iOS 26 浮层输入(延续玻璃输入框的凹陷材质)

最多 200 字,换行会保留20 / 200
多行不限高:拖右下角改高度
必填项,不能留空
disabled 态:可见但不可改

复用卡

Autosizing Textarea · 玻璃多行输入 · 多行 / Glass

规格 · 术语提示词(给 AI 用)

做一个 Autosizing Textarea(玻璃多行输入):
- label 在框上方;校验契约与单行输入一致(aria-describedby + aria-invalid + role=alert)
- autosize:height:auto 后读 scrollHeight,按 maxRows 截断,超限才切内部滚动
- 计数按字符数显示 n / maxLength,与提示行共用 aria-describedby
- 开 autosize 时撤掉手动 resize 手柄;否则保留 resize-y
用这些 CSS 变量:--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-bg --glass-border --glass-highlight --glass-inset --glass-recess --text-2xs --text-base --text-xs
灵感参考:visionOS / iOS 26 浮层输入(延续玻璃输入框的凹陷材质)

参数(CSS 变量)

--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-bg --glass-border --glass-highlight --glass-inset --glass-recess --text-2xs --text-base --text-xs

必缺状态

焦点 / 禁用 / 错误

整页场景

安装(一条链接)

调研笔记

玻璃多行输入

  • 与玻璃输入框同一套外壳:--glass-bg + 发丝边 + blur + inset,聚焦交给外壳的 focus-within
  • 单行 Input 的全部校验契约复用:label / hint / errorMessage / required / showCount / aria-describedby,错误文案带 role="alert"

autosize:先 height:auto 再读 scrollHeight,与 maxRows × 行高 + 上下内边距 取小;超过上限才把 overflow-y 切成 auto,没超过时保持 hidden 以免出现无用滚动条。开了 autosize 就撤掉手动 resize 手柄,两种长高方式不并存。rows 在 autosize 下默认 1,否则默认 3——否则浏览器的最小行数会把"随内容长"锁死在 3 行。

多行才有的取舍:

  • resize-y 保留手动手柄(默认),但页面固定视口、正文内滚,用户拉高的空间来自自己——不会把页面撑出滚动条
  • 换行保留(white-space: pre-wrap 是 textarea 原生行为),计数按字符数,不按行数
  • 回车即换行,提交只能靠按钮或 ⌘/Ctrl+Enter(未绑,留给消费方)

实现

textarea/glass-apple
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 GlassAppleTextarea = 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-xl
              bg-[var(--glass-bg)] border backdrop-blur-xl
              shadow-[var(--glass-inset)]
              transition-[border-color,background-color] duration-[var(--dur-fast)]
              focus-within:bg-[var(--glass-highlight)]
              ${field.invalid
                ? 'border-[var(--color-warning-fg)]'
                : 'border-[var(--glass-border)] focus-within:border-[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>
    );
  },
);
GlassAppleTextarea.displayName = 'GlassAppleTextarea';

元信息

标识
textarea/glass-apple
状态
done
最后提交
2026-11-01
风格轴
glass-apple
依赖 token
--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-bg --glass-border --glass-highlight --glass-inset --glass-recess --text-2xs --text-base --text-xs
在工作台打开 源码与 notes 都在 src/components/textarea/glass-apple/