slider · neumorphic · 灵感来源:Neumorphism(轨道压入、旋钮浮起,同一组同色双阴影)
音量
亮度
步进 · 每档 20
禁用
轨道压入、旋钮浮起,共用一组同色双阴影
Neumorphic Range · 拟态滑块 · 滑块 / Neumorphic
做一个 Neumorphic Range(拟态滑块):
- 轨道压入(inset 双阴影)、旋钮浮起(外双阴影),方向相反
- 不引入第二种颜色,进度靠明度区分
- 不做按压缩放,避免压掉高度感
- 行为与另外两支完全一致,只换材质
用这些 CSS 变量:--base-ink --color-text --color-text-muted --neuro-bg --neuro-dark --neuro-light
灵感参考:Neumorphism(轨道压入、旋钮浮起,同一组同色双阴影)--base-ink --color-text --color-text-muted --neuro-bg --neuro-dark --neuro-light
焦点 / 禁用
(尚未接进整页场景)
/r/slider--neumorphic.json
免外壳的 markdown 版:/llms/components/slider--neumorphic.md--neuro-bg 底 + 内阴影双色(暗侧左上、亮侧右下)--neuro-bg 底 + 外阴影双色,方向与轨道相反——一压一浮才成立--color-text 填充:拟态不引入第二种颜色,进度靠明度区分opacity-40,不重画阴影变量:--neuro-bg / --neuro-dark / --neuro-light / --color-text。
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>
);
}
src/components/slider/neumorphic/