极简勾选

choice · minimal · 灵感来源:APG Checkbox / Radio Group + iOS 设置勾选

通知方式

已选:email · 「短信」是禁用项

部署环境

当前:staging · 原生方向键切换

已停用

复用卡

Checkbox & Radio Group (APG) · 极简勾选 · 勾选 / Minimal

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

做一个 Checkbox & Radio Group (APG)(极简勾选):
- 原生 checkbox / radio + fieldset / legend;单选组同 name,方向键与空格交给浏览器
- input sr-only,focus-visible 环用 peer 画在可视框上,读屏与键盘都不丢
- values 数组同时兼容多选与单选;组级 fieldset[disabled] 与单项禁用互不干扰
- 未选白底 + 发丝边 + shadow-sm;选中 accent 实底 + 反色勾 / 点,去掉阴影
用这些 CSS 变量:--color-accent --color-bg --color-divider --color-surface --color-text --color-text-muted --shadow-sm --text-xs
灵感参考:APG Checkbox / Radio Group + iOS 设置勾选

参数(CSS 变量)

--color-accent --color-bg --color-divider --color-surface --color-text --color-text-muted --shadow-sm --text-xs

必缺状态

焦点 / 禁用

整页场景

(尚未接进整页场景)

安装(一条链接)

调研笔记

极简勾选

拆解

  • kind 决定方框或圆圈;两种都走同一套 label 行结构
  • 原生 checkbox / radio + fieldset / legend;单选组同 name,方向键由浏览器处理
  • input sr-only,focus-visible 环画在可视框上
  • values 数组:多选多值、单选 0 / 1 值;组级与单项都可禁用

材质

  • 未选:白底 + 发丝边 + --shadow-sm
  • 选中:accent 实底 + 反色勾 / 点,去掉阴影
  • 没有模糊与高光;状态只由边框与填充表达

边界

  • 不做 indeterminate
  • 单选框不接管方向键,交给原生
  • 行高与文字用 text-sm,与极简输入框同一档

变量:--color-surface / --color-divider / --color-accent / --shadow-sm。

实现

choice/minimal
import { useId } from 'react';
import { Check } from 'lucide-react';
import { Icon } from '~/components/Icon';

export interface ChoiceOption {
  value: string;
  label: string;
  disabled?: boolean;
}

interface Props {
  kind: 'checkbox' | 'radio';
  options: ChoiceOption[];
  values: string[];
  onChange?: (values: string[]) => void;
  label?: string;
  disabled?: boolean;
  className?: string;
}

/**
 * 极简勾选:复选框与单选框共用一套行结构,只有可视框形状不同。
 * 用原生 input 保留键盘与读屏行为;焦点环画在可视框上(input 本身 sr-only)。
 */
export function MinimalChoice({
  kind,
  options,
  values,
  onChange,
  label,
  disabled = false,
  className = '',
}: Props) {
  const groupId = useId();

  const toggle = (value: string) => {
    if (kind === 'radio') {
      onChange?.([value]);
      return;
    }
    const next = new Set(values);
    if (next.has(value)) next.delete(value);
    else next.add(value);
    onChange?.([...next]);
  };

  return (
    <fieldset
      disabled={disabled || undefined}
      aria-disabled={disabled || undefined}
      className={`border-0 p-0 m-0 min-w-0 ${disabled ? 'opacity-60' : ''} ${className}`}
    >
      {label && (
        <legend className="text-xs uppercase tracking-[0.18em] text-[var(--color-text-muted)] mb-2.5">
          {label}
        </legend>
      )}
      <div className="space-y-2.5">
        {options.map((option) => {
          const isOn = values.includes(option.value);
          const isDisabled = disabled || option.disabled;
          return (
            <label
              key={option.value}
              className={`flex items-center gap-3 text-sm ${
                isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'
              }`}
            >
              <input
                type={kind}
                name={groupId}
                value={option.value}
                checked={isOn}
                disabled={isDisabled}
                onChange={() => toggle(option.value)}
                className="peer sr-only"
              />
              <span
                aria-hidden="true"
                className={`
                  grid place-items-center shrink-0 size-5
                  ${kind === 'checkbox' ? 'rounded-md' : 'rounded-full'}
                  ${isOn
                    ? 'bg-[var(--color-accent)] border border-[var(--color-accent)] text-[var(--color-bg)]'
                    : 'bg-[var(--color-surface)] border border-[var(--color-divider)] shadow-[var(--shadow-sm)]'}
                  peer-focus-visible:outline peer-focus-visible:outline-2
                  peer-focus-visible:outline-offset-2 peer-focus-visible:outline-[var(--color-accent)]
                `}
              >
                {isOn && kind === 'checkbox' && (
                  <Icon icon={Check} size={12} strokeWidth={3} />
                )}
                {isOn && kind === 'radio' && (
                  <span className="size-2 rounded-full bg-current" />
                )}
              </span>
              <span>{option.label}</span>
            </label>
          );
        })}
      </div>
    </fieldset>
  );
}

元信息

标识
choice/minimal
状态
done
最后提交
2026-11-01
风格轴
minimal
依赖 token
--color-accent --color-bg --color-divider --color-surface --color-text --color-text-muted --shadow-sm --text-xs
在工作台打开 源码与 notes 都在 src/components/choice/minimal/