拟态滑块

slider · neumorphic · 灵感来源:Neumorphism(轨道压入、旋钮浮起,同一组同色双阴影)

音量

60%

亮度

35%

步进 · 每档 20

40

禁用

70%

轨道压入、旋钮浮起,共用一组同色双阴影

复用卡

Neumorphic Range · 拟态滑块 · 滑块 / Neumorphic

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

做一个 Neumorphic Range(拟态滑块):
- 轨道压入(inset 双阴影)、旋钮浮起(外双阴影),方向相反
- 不引入第二种颜色,进度靠明度区分
- 不做按压缩放,避免压掉高度感
- 行为与另外两支完全一致,只换材质
用这些 CSS 变量:--base-ink --color-text --color-text-muted --neuro-bg --neuro-dark --neuro-light
灵感参考:Neumorphism(轨道压入、旋钮浮起,同一组同色双阴影)

参数(CSS 变量)

--base-ink --color-text --color-text-muted --neuro-bg --neuro-dark --neuro-light

必缺状态

焦点 / 禁用

整页场景

(尚未接进整页场景)

安装(一条链接)

调研笔记

拟态滑块

拆解

  • 轨道是压入的槽:--neuro-bg 底 + 内阴影双色(暗侧左上、亮侧右下)
  • 旋钮是浮起的球:--neuro-bg 底 + 外阴影双色,方向与轨道相反——一压一浮才成立
  • 已走部分用 --color-text 填充:拟态不引入第二种颜色,进度靠明度区分
  • 8px 轨道 + 20px 旋钮,比例与开关的拟态版一致

实现取舍

  • 行为与另外两支完全一致(指针 1:1、APG 键盘、受控 / 非受控)
  • 不做按压缩放:旋钮已经在"浮起"状态,再缩会把高度感压没了
  • 禁用态整体 opacity-40,不重画阴影

变量:--neuro-bg / --neuro-dark / --neuro-light / --color-text。

实现

slider/neumorphic
import { useRef, useState, type KeyboardEvent, type PointerEvent } from 'react';

interface Props {
  value?: number;
  defaultValue?: number;
  min?: number;
  max?: number;
  step?: number;
  onChange?: (value: number) => void;
  disabled?: boolean;
  className?: string;
  showValue?: boolean;
  unit?: string;
  'aria-label'?: string;
}

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

const THUMB = 20;

export function NeumorphicSlider({
  value,
  defaultValue,
  min = 0,
  max = 100,
  step = 1,
  onChange,
  disabled,
  className = '',
  showValue,
  unit = '',
  ...rest
}: Props) {
  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(defaultValue ?? min);
  const current = clamp(isControlled ? value : internal, min, max);
  const pct = max === min ? 0 : ((current - min) / (max - min)) * 100;
  const travelRef = useRef<HTMLDivElement>(null);

  const commit = (next: number) => {
    const snapped = clamp(min + Math.round((next - min) / step) * step, min, max);
    if (!isControlled) setInternal(snapped);
    if (snapped !== current) onChange?.(snapped);
  };

  const fromPointer = (clientX: number) => {
    const el = travelRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    if (rect.width === 0) return;
    commit(min + ((clientX - rect.left) / rect.width) * (max - min));
  };

  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
    if (disabled) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    fromPointer(e.clientX);
  };

  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
    if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return;
    fromPointer(e.clientX);
  };

  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (disabled) return;
    const big = step * 10;
    const targets: Record<string, number> = {
      ArrowRight: current + step,
      ArrowUp: current + step,
      ArrowLeft: current - step,
      ArrowDown: current - step,
      PageUp: current + big,
      PageDown: current - big,
      Home: min,
      End: max,
    };
    const next = targets[e.key];
    if (next === undefined) return;
    e.preventDefault();
    commit(next);
  };

  return (
    <div className={`flex items-center gap-3 ${className}`}>
      <div
        className={`
          relative h-6 flex-1 select-none
          ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
        `}
      >
        <div className="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-lg bg-[var(--neuro-bg)] shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light)]" />

        <div
          ref={travelRef}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          className="absolute inset-y-0 touch-none"
          style={{ left: THUMB / 2, right: THUMB / 2 }}
        >
          <div
            className="absolute top-1/2 left-0 h-2 -translate-y-1/2 rounded-lg bg-[var(--color-text)]"
            style={{ width: `${pct}%` }}
          />
          <div
            role="slider"
            tabIndex={disabled ? -1 : 0}
            aria-valuemin={min}
            aria-valuemax={max}
            aria-valuenow={current}
            aria-orientation="horizontal"
            aria-disabled={disabled || undefined}
            onKeyDown={onKeyDown}
            className="absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-[var(--neuro-bg)] shadow-[3px_3px_7px_var(--neuro-dark),-3px_-3px_7px_var(--neuro-light)]"
            style={{ left: `${pct}%` }}
            {...rest}
          />
        </div>
      </div>

      {showValue && (
        <span className="w-10 shrink-0 text-right text-xs font-mono text-[var(--color-text-muted)]">
          {current}{unit}
        </span>
      )}
    </div>
  );
}

元信息

标识
slider/neumorphic
状态
done
最后提交
2026-10-31
风格轴
neumorphic
依赖 token
--base-ink --color-text --color-text-muted --neuro-bg --neuro-dark --neuro-light
在工作台打开 源码与 notes 都在 src/components/slider/neumorphic/