# 拟态开关（Neumorphic Switch）

- 类型 / 材质：`switch` / `neumorphic`（新拟态）
- 状态：done ｜ 最后提交：2026-10-31
- 灵感来源：Neumorphism（轨道压入、圆钮浮起，开启时圆钮点亮）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/switch/neumorphic` ｜ 条目：`/r/switch--neumorphic.json`
- 必缺状态：焦点 / 按压 / 禁用
- 整页场景：尚未接入

## 规格

- role=switch + aria-checked，Space / Enter 切换
- 轨道压入（inset 双阴影），圆钮同色浮起（外双阴影）
- 开启只点亮圆钮为 accent，轨道保持凹槽
- 轨道高度 ≥24px，按压有 0.97 缩放反馈

## 参数（CSS 变量）

`--color-accent` `--color-surface` `--color-text-muted` `--dur-fast` `--ease-standard` `--glass-border` `--neuro-bg` `--neuro-dark` `--neuro-light` `--shadow-sm`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `checked` | `boolean` | 可选 | — |
| `defaultChecked` | `boolean` | 可选 | — |
| `onChange` | `(checked: boolean) => void` | 可选 | — |
| `disabled` | `boolean` | 可选 | — |
| `size` | `'sm' | 'md'` | 可选 | — |

## 提示词（可直接粘给智能体）

```
复现「拟态开关」— Neumorphic Switch（开关 / Neumorphic）
灵感来源：Neumorphism（轨道压入、圆钮浮起，开启时圆钮点亮）
规格：
- role=switch + aria-checked，Space / Enter 切换
- 轨道压入（inset 双阴影），圆钮同色浮起（外双阴影）
- 开启只点亮圆钮为 accent，轨道保持凹槽
- 轨道高度 ≥24px，按压有 0.97 缩放反馈
参数（CSS 变量）：--color-accent --color-surface --color-text-muted --dur-fast --ease-standard --glass-border --neuro-bg --neuro-dark --neuro-light --shadow-sm
必缺状态：焦点 / 按压 / 禁用
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/switch/neumorphic · 标准 interaction /design#interaction · 调研笔记 /showcase/component/switch/neumorphic#notes
```

## 调研笔记

import { useState } from 'react';

interface Props {
  checked?: boolean;
  defaultChecked?: boolean;
  onChange?: (checked: boolean) => void;
  disabled?: boolean;
  size?: 'sm' | 'md';
  'aria-label'?: string;
}

// 轨道高度都 ≥24px：SC 2.5.8 要求指针目标至少 24×24 CSS 像素
const dims = {
  sm: { track: 'w-8 h-6', thumb: 'size-4', on: 'translateX(12px)' },
  md: { track: 'w-11 h-[26px]', thumb: 'size-[22px]', on: 'translateX(18px)' },
};

/**
 * 拟态开关：与玻璃开关同一份 props，
 * 轨道是压进去的槽，圆钮浮在上面；开启时圆钮填 accent，轨道保持凹槽。
 */
export function NeumorphicSwitch({
  checked,
  defaultChecked,
  onChange,
  disabled,
  size = 'md',
  ...rest
}: Props) {
  const isControlled = checked !== undefined;
  const [internal, setInternal] = useState(defaultChecked ?? false);
  const on = isControlled ? checked : internal;
  const d = dims[size];

  const toggle = () => {
    const next = !on;
    if (!isControlled) setInternal(next);
    onChange?.(next);
  };

  return (
    <button
      type="button"
      role="switch"
      aria-checked={on}
      disabled={disabled}
      onClick={toggle}
      className={`
        press-feedback
        relative inline-flex items-center shrink-0
        ${d.track} rounded-full p-[2px]
        bg-[var(--neuro-bg)]
        shadow-[inset_2px_2px_4px_var(--neuro-dark),inset_-2px_-2px_4px_var(--neuro-light)]
        cursor-pointer
        disabled:opacity-40 disabled:cursor-not-allowed
      `}
      {...rest}
    >
      <span
        aria-hidden="true"
        className={`
          ${d.thumb} rounded-full
          shadow-[2px_2px_5px_var(--neuro-dark),-2px_-2px_5px_var(--neuro-light)]
          transition-[transform,background-color] duration-[var(--dur-fast)] ease-[var(--ease-standard)]
          ${on ? 'bg-[var(--color-accent)]' : 'bg-[var(--neuro-bg)]'}
        `}
        style={{ transform: on ? d.on : 'translateX(0)' }}
      />
    </button>
  );
}
