设置面板
New偏好设置表单组件族:带发丝分隔行的带标题设置分组、文字自动反差的十六进制颜色输入胶囊、文本框、预设选择按钮,以及头部幽灵操作按钮。
Light theme
Accent color
Background
UI font
Translucent sidebar
Let the desktop tint the sidebar
Contrast
60
TSXcomponents/previews/blocks/settings-panel.preview.tsx
"use client";
import { Import, Palette } from "lucide-react";
import { useState } from "react";
import { RangeSlider } from "@/components/motion/range-slider";
import {
SettingsColorField,
SettingsGhostButton,
SettingsGroup,
SettingsRow,
SettingsSelectButton,
SettingsTextField,
} from "@/components/motion/settings-panel";
import { Switch } from "@/components/motion/switch";
export function SettingsPanelPreview() {
const [accent, setAccent] = useState("#339CFF");
const [background, setBackground] = useState("#FFFFFF");
const [font, setFont] = useState("");
const [translucent, setTranslucent] = useState(true);
const [contrast, setContrast] = useState(60);
return (
<div className="flex w-full items-center justify-center rounded-xl border border-border bg-neutral-100 px-4 py-10 dark:bg-neutral-900">
<div className="w-full max-w-xl">
<SettingsGroup
title="Light theme"
actions={
<>
<SettingsGhostButton>
<Import className="h-3.5 w-3.5" />
Import
</SettingsGhostButton>
<SettingsSelectButton icon={<Palette className="h-3.5 w-3.5" />}>
Default
</SettingsSelectButton>
</>
}
>
<SettingsRow label="Accent color">
<SettingsColorField value={accent} onChange={setAccent} aria-label="Accent color" />
</SettingsRow>
<SettingsRow label="Background">
<SettingsColorField
value={background}
onChange={setBackground}
aria-label="Background color"
/>
</SettingsRow>
<SettingsRow label="UI font">
<SettingsTextField
value={font}
onChange={setFont}
placeholder="-apple-system, Inter…"
aria-label="UI font"
/>
</SettingsRow>
<SettingsRow label="Translucent sidebar" description="Let the desktop tint the sidebar">
<Switch checked={translucent} onCheckedChange={setTranslucent} />
</SettingsRow>
<SettingsRow label="Contrast">
<div className="flex w-[160px] items-center gap-2">
<RangeSlider
value={contrast}
onValueChange={setContrast}
className="w-24"
aria-label="Contrast"
/>
<span className="w-8 shrink-0 text-right text-sm text-muted-foreground tabular-nums">
{contrast}
</span>
</div>
</SettingsRow>
</SettingsGroup>
</div>
</div>
);
}
TSXcomponents/motion/settings-panel/index.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/settings-panel
import { ChevronDown } from "lucide-react";
import { motion } from "motion/react";
import { type ReactNode, useEffect, useState } from "react";
import { isLightHexColor, normalizeHexColor } from "@/lib/color";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface SettingsGroupProps {
/** Card keeps the preference shell; quiet becomes a flat hairline group. */
variant?: "card" | "quiet";
title?: ReactNode;
/** Header-right slot, e.g. a `SettingsGhostButton` plus a `SettingsSelectButton`. */
actions?: ReactNode;
className?: string;
children?: ReactNode;
}
/**
* Card shell for a group of preference rows. Renders a header row (title
* left, `actions` right) only when either is passed; `children` — typically
* a stack of `SettingsRow`s — sit in a region whose `[&>*+*]` selector draws
* a hairline divider between adjacent rows without each row needing to know
* about its neighbors.
*/
export function SettingsGroup({
variant = "card",
title,
actions,
className,
children,
}: SettingsGroupProps) {
const quiet = variant === "quiet";
return (
<div
data-slot="settings-group"
data-variant={variant}
className={cn(
quiet &&
"border-[var(--wb-border-subtle)] border-x-0 border-y-[0.5px] bg-transparent [&_[data-slot=settings-row]]:px-0",
!quiet &&
"rounded-[20px] border border-[var(--wb-border)] bg-[var(--wb-surface-raised)]",
className,
)}
>
{title || actions ? (
<div
className={cn(
"flex items-center justify-between gap-3 pb-1",
quiet ? "px-0 pt-3" : "px-4 pt-4",
)}
>
{title ? <div className="text-[13px] font-medium">{title}</div> : <span />}
{actions ? <div className="flex items-center gap-1">{actions}</div> : null}
</div>
) : null}
<div
data-slot="settings-group-rows"
className="[&>*+*]:border-[var(--wb-border-subtle)] [&>*+*]:border-t-[0.5px]"
>
{children}
</div>
</div>
);
}
export interface SettingsRowProps {
label: ReactNode;
/** Muted one-liner rendered under `label`. */
description?: ReactNode;
className?: string;
/** Trailing control slot, e.g. a `SettingsColorField` or a `Switch`. */
children?: ReactNode;
}
/** One preference line inside a `SettingsGroup` — label (+ optional description) on the left, a control slot on the right. */
export function SettingsRow({ label, description, className, children }: SettingsRowProps) {
return (
<div
data-slot="settings-row"
className={cn(
"flex min-h-[52px] items-center justify-between gap-3 px-4 py-2",
className,
)}
>
<div className="min-w-0">
<div className="text-[13px] font-medium">{label}</div>
{description ? <div className="text-xs text-muted-foreground">{description}</div> : null}
</div>
{children ? <div className="shrink-0">{children}</div> : null}
</div>
);
}
export interface SettingsColorFieldProps {
/** Current color as `#RRGGBB` (or `#RGB`); the pill background and text contrast follow it. */
value: string;
onChange?: (hex: string) => void;
"aria-label"?: string;
className?: string;
}
/**
* Hex-color pill — a decorative ring dot plus an editable mono hex code, the
* pill's own background painted in `value` with a smooth color transition.
* Text and ring contrast auto-flip (dark ink on light swatches, white ink on
* dark ones) via `isLightColor`. Typing edits a local draft so partial input
* doesn't get clobbered by the controlled `value`; Enter or blur commits —
* a valid hex calls `onChange`, an invalid one snaps the draft back to the
* last committed `value`.
*/
export function SettingsColorField({
value,
onChange,
"aria-label": ariaLabel,
className,
}: SettingsColorFieldProps) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
const light = isLightHexColor(value);
const textColor = light ? "#101010" : "#FFFFFF";
const commit = () => {
const normalized = normalizeHexColor(draft);
if (normalized) {
setDraft(normalized);
if (normalized !== value.toUpperCase()) onChange?.(normalized);
} else {
setDraft(value);
}
};
return (
<motion.div
animate={{ backgroundColor: value }}
transition={{ duration: 0.3, ease: EASE_OUT }}
className={cn("flex h-7 w-[136px] items-center gap-2 rounded-lg px-2", className)}
>
<span
aria-hidden
className={cn(
"h-3.5 w-3.5 shrink-0 rounded-full border bg-transparent",
light ? "border-black/15" : "border-white/40",
)}
/>
<input
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commit();
}
}}
aria-label={ariaLabel}
spellCheck={false}
style={{ color: textColor }}
className="w-full bg-transparent font-mono text-xs uppercase outline-none"
/>
</motion.div>
);
}
export interface SettingsTextFieldProps {
value: string;
onChange?: (next: string) => void;
placeholder?: string;
"aria-label"?: string;
className?: string;
}
/** Right-aligned lightweight text input, e.g. for a custom UI-font stack. */
export function SettingsTextField({
value,
onChange,
placeholder,
"aria-label": ariaLabel,
className,
}: SettingsTextFieldProps) {
return (
<input
data-slot="settings-text-field"
value={value}
onChange={(event) => onChange?.(event.target.value)}
placeholder={placeholder}
aria-label={ariaLabel}
className={cn(
"h-7 w-[176px] truncate rounded-lg border border-[var(--wb-control-hairline)] bg-[var(--wb-inset-faint)] px-2 text-sm text-muted-foreground outline-none focus:text-foreground",
className,
)}
/>
);
}
export interface SettingsSelectButtonProps {
/** 14px leading icon. */
icon?: ReactNode;
onClick?: () => void;
children?: ReactNode;
className?: string;
"aria-label"?: string;
}
/**
* Preset-picker-styled trigger button — icon, label, trailing chevron. It
* doesn't render a dropdown itself; wire `onClick` to whatever popover/select
* the caller already has (see the preview for a stateless demo usage).
*/
export function SettingsSelectButton({
icon,
onClick,
children,
className,
"aria-label": ariaLabel,
}: SettingsSelectButtonProps) {
return (
<button
data-slot="settings-select"
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={cn(
"flex h-7 items-center gap-1.5 rounded-lg border border-[var(--wb-control-hairline)] bg-[var(--wb-inset-faint)] px-3 text-sm",
className,
)}
>
{icon ? <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">{icon}</span> : null}
{children}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
);
}
export interface SettingsGhostButtonProps {
onClick?: () => void;
children?: ReactNode;
className?: string;
}
/** Borderless header action, e.g. "Import" or "Copy" beside a `SettingsGroup` title. */
export function SettingsGhostButton({ onClick, children, className }: SettingsGhostButtonProps) {
return (
<button
data-slot="settings-ghost"
type="button"
onClick={onClick}
className={cn(
"flex h-7 items-center gap-1 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-[var(--wb-hover-subtle)] hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
安装
用 shadcn CLI 添加,或手动复制源码。
$ bunx --bun shadcn add @uilab/settings-panel
Needs the theme tokens once. Already ran
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx lucide-react motion tailwind-mergeAdd util files
TSXlib/color.ts
export type RgbColor = { r: number; g: number; b: number };
/** Parses `#RGB` / `#RRGGBB` into 0–255 channels; `null` on anything else. */
export function parseHexColor(input: string): RgbColor | null {
const hex = input.trim().replace(/^#/, "");
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
const [r, g, b] = hex.split("");
return {
r: Number.parseInt(r + r, 16),
g: Number.parseInt(g + g, 16),
b: Number.parseInt(b + b, 16),
};
}
if (/^[0-9a-fA-F]{6}$/.test(hex)) {
return {
r: Number.parseInt(hex.slice(0, 2), 16),
g: Number.parseInt(hex.slice(2, 4), 16),
b: Number.parseInt(hex.slice(4, 6), 16),
};
}
return null;
}
/** Normalizes to an uppercase `#RRGGBB`; `null` when input is not valid hex. */
export function normalizeHexColor(input: string): string | null {
const rgb = parseHexColor(input);
if (!rgb) return null;
const toHex = (channel: number) => channel.toString(16).padStart(2, "0");
return `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`.toUpperCase();
}
/** YIQ perceived brightness — values at or above 150 read as a light swatch. */
export function isLightHexColor(hex: string): boolean {
const rgb = parseHexColor(hex);
if (!rgb) return false;
return (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000 >= 150;
}
/** WCAG relative luminance (0..1); invalid hex resolves to 0. */
export function relativeLuminance(hex: string): number {
const rgb = parseHexColor(hex);
if (!rgb) return 0;
const lin = (c: number) => {
const s = c / 255;
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * lin(rgb.r) + 0.7152 * lin(rgb.g) + 0.0722 * lin(rgb.b);
}
/** WCAG contrast ratio, 1..21; invalid input resolves to 1 (no contrast). */
export function contrastRatio(hex1: string, hex2: string): number {
const a = relativeLuminance(hex1);
const b = relativeLuminance(hex2);
const [hi, lo] = a >= b ? [a, b] : [b, a];
return (hi + 0.05) / (lo + 0.05);
}
/** Mixes `top` at `weightPercent` (0..100) over `bottom`; returns `#RRGGBB` — the equivalent opaque color behind a translucent glass surface. */
export function mixHex(top: string, bottom: string, weightPercent: number): string {
const t = parseHexColor(top);
const b = parseHexColor(bottom);
if (!t || !b) return normalizeHexColor(bottom) ?? "#000000";
const w = Math.max(0, Math.min(100, weightPercent)) / 100;
const ch = (x: number, y: number) => Math.round(x * w + y * (1 - w));
const toHex = (n: number) => n.toString(16).padStart(2, "0");
return `#${toHex(ch(t.r, b.r))}${toHex(ch(t.g, b.g))}${toHex(ch(t.b, b.b))}`.toUpperCase();
}
TSXlib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
TSXlib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
TSXcomponents/motion/settings-panel/index.tsx
"use client";
// ui-lab-ten.vercel.app/components/blocks/settings-panel
import { ChevronDown } from "lucide-react";
import { motion } from "motion/react";
import { type ReactNode, useEffect, useState } from "react";
import { isLightHexColor, normalizeHexColor } from "@/lib/color";
import { EASE_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
export interface SettingsGroupProps {
/** Card keeps the preference shell; quiet becomes a flat hairline group. */
variant?: "card" | "quiet";
title?: ReactNode;
/** Header-right slot, e.g. a `SettingsGhostButton` plus a `SettingsSelectButton`. */
actions?: ReactNode;
className?: string;
children?: ReactNode;
}
/**
* Card shell for a group of preference rows. Renders a header row (title
* left, `actions` right) only when either is passed; `children` — typically
* a stack of `SettingsRow`s — sit in a region whose `[&>*+*]` selector draws
* a hairline divider between adjacent rows without each row needing to know
* about its neighbors.
*/
export function SettingsGroup({
variant = "card",
title,
actions,
className,
children,
}: SettingsGroupProps) {
const quiet = variant === "quiet";
return (
<div
data-slot="settings-group"
data-variant={variant}
className={cn(
quiet &&
"border-[var(--wb-border-subtle)] border-x-0 border-y-[0.5px] bg-transparent [&_[data-slot=settings-row]]:px-0",
!quiet &&
"rounded-[20px] border border-[var(--wb-border)] bg-[var(--wb-surface-raised)]",
className,
)}
>
{title || actions ? (
<div
className={cn(
"flex items-center justify-between gap-3 pb-1",
quiet ? "px-0 pt-3" : "px-4 pt-4",
)}
>
{title ? <div className="text-[13px] font-medium">{title}</div> : <span />}
{actions ? <div className="flex items-center gap-1">{actions}</div> : null}
</div>
) : null}
<div
data-slot="settings-group-rows"
className="[&>*+*]:border-[var(--wb-border-subtle)] [&>*+*]:border-t-[0.5px]"
>
{children}
</div>
</div>
);
}
export interface SettingsRowProps {
label: ReactNode;
/** Muted one-liner rendered under `label`. */
description?: ReactNode;
className?: string;
/** Trailing control slot, e.g. a `SettingsColorField` or a `Switch`. */
children?: ReactNode;
}
/** One preference line inside a `SettingsGroup` — label (+ optional description) on the left, a control slot on the right. */
export function SettingsRow({ label, description, className, children }: SettingsRowProps) {
return (
<div
data-slot="settings-row"
className={cn(
"flex min-h-[52px] items-center justify-between gap-3 px-4 py-2",
className,
)}
>
<div className="min-w-0">
<div className="text-[13px] font-medium">{label}</div>
{description ? <div className="text-xs text-muted-foreground">{description}</div> : null}
</div>
{children ? <div className="shrink-0">{children}</div> : null}
</div>
);
}
export interface SettingsColorFieldProps {
/** Current color as `#RRGGBB` (or `#RGB`); the pill background and text contrast follow it. */
value: string;
onChange?: (hex: string) => void;
"aria-label"?: string;
className?: string;
}
/**
* Hex-color pill — a decorative ring dot plus an editable mono hex code, the
* pill's own background painted in `value` with a smooth color transition.
* Text and ring contrast auto-flip (dark ink on light swatches, white ink on
* dark ones) via `isLightColor`. Typing edits a local draft so partial input
* doesn't get clobbered by the controlled `value`; Enter or blur commits —
* a valid hex calls `onChange`, an invalid one snaps the draft back to the
* last committed `value`.
*/
export function SettingsColorField({
value,
onChange,
"aria-label": ariaLabel,
className,
}: SettingsColorFieldProps) {
const [draft, setDraft] = useState(value);
useEffect(() => {
setDraft(value);
}, [value]);
const light = isLightHexColor(value);
const textColor = light ? "#101010" : "#FFFFFF";
const commit = () => {
const normalized = normalizeHexColor(draft);
if (normalized) {
setDraft(normalized);
if (normalized !== value.toUpperCase()) onChange?.(normalized);
} else {
setDraft(value);
}
};
return (
<motion.div
animate={{ backgroundColor: value }}
transition={{ duration: 0.3, ease: EASE_OUT }}
className={cn("flex h-7 w-[136px] items-center gap-2 rounded-lg px-2", className)}
>
<span
aria-hidden
className={cn(
"h-3.5 w-3.5 shrink-0 rounded-full border bg-transparent",
light ? "border-black/15" : "border-white/40",
)}
/>
<input
value={draft}
onChange={(event) => setDraft(event.target.value)}
onBlur={commit}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commit();
}
}}
aria-label={ariaLabel}
spellCheck={false}
style={{ color: textColor }}
className="w-full bg-transparent font-mono text-xs uppercase outline-none"
/>
</motion.div>
);
}
export interface SettingsTextFieldProps {
value: string;
onChange?: (next: string) => void;
placeholder?: string;
"aria-label"?: string;
className?: string;
}
/** Right-aligned lightweight text input, e.g. for a custom UI-font stack. */
export function SettingsTextField({
value,
onChange,
placeholder,
"aria-label": ariaLabel,
className,
}: SettingsTextFieldProps) {
return (
<input
data-slot="settings-text-field"
value={value}
onChange={(event) => onChange?.(event.target.value)}
placeholder={placeholder}
aria-label={ariaLabel}
className={cn(
"h-7 w-[176px] truncate rounded-lg border border-[var(--wb-control-hairline)] bg-[var(--wb-inset-faint)] px-2 text-sm text-muted-foreground outline-none focus:text-foreground",
className,
)}
/>
);
}
export interface SettingsSelectButtonProps {
/** 14px leading icon. */
icon?: ReactNode;
onClick?: () => void;
children?: ReactNode;
className?: string;
"aria-label"?: string;
}
/**
* Preset-picker-styled trigger button — icon, label, trailing chevron. It
* doesn't render a dropdown itself; wire `onClick` to whatever popover/select
* the caller already has (see the preview for a stateless demo usage).
*/
export function SettingsSelectButton({
icon,
onClick,
children,
className,
"aria-label": ariaLabel,
}: SettingsSelectButtonProps) {
return (
<button
data-slot="settings-select"
type="button"
onClick={onClick}
aria-label={ariaLabel}
className={cn(
"flex h-7 items-center gap-1.5 rounded-lg border border-[var(--wb-control-hairline)] bg-[var(--wb-inset-faint)] px-3 text-sm",
className,
)}
>
{icon ? <span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">{icon}</span> : null}
{children}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</button>
);
}
export interface SettingsGhostButtonProps {
onClick?: () => void;
children?: ReactNode;
className?: string;
}
/** Borderless header action, e.g. "Import" or "Copy" beside a `SettingsGroup` title. */
export function SettingsGhostButton({ onClick, children, className }: SettingsGhostButtonProps) {
return (
<button
data-slot="settings-ghost"
type="button"
onClick={onClick}
className={cn(
"flex h-7 items-center gap-1 rounded-lg px-2 text-sm text-muted-foreground transition-colors hover:bg-[var(--wb-hover-subtle)] hover:text-foreground",
className,
)}
>
{children}
</button>
);
}
TSXcomponents/motion/range-slider.tsx
"use client";
import {
motion,
useMotionTemplate,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import {
type KeyboardEvent,
type PointerEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
// Smooth glide for the thumb/fill — critically damped, no overshoot, so the
// handle follows the pointer butterily and eases between snapped steps.
const SPRING_GLIDE = { stiffness: 700, damping: 50, mass: 0.5 } as const;
// Bouncy grab feedback for the thumb scale only.
const SPRING_BOUNCY = { type: "spring", stiffness: 500, damping: 14, mass: 0.7 } as const;
export interface RangeSliderProps {
value?: number;
defaultValue?: number;
onValueChange?: (value: number) => void;
min?: number;
max?: number;
step?: number;
/** Render a tick dot at each step. */
showTicks?: boolean;
disabled?: boolean;
className?: string;
"aria-label"?: string;
}
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
export function RangeSlider({
value,
defaultValue = 0,
onValueChange,
min = 0,
max = 100,
step = 1,
showTicks = true,
disabled = false,
className,
"aria-label": ariaLabel,
}: RangeSliderProps) {
const reduce = useReducedMotion();
const trackRef = useRef<HTMLDivElement>(null);
const [internal, setInternal] = useState(defaultValue);
const [active, setActive] = useState(false);
const controlled = value !== undefined;
const current = clamp(controlled ? value : internal, min, max);
const percent = ((current - min) / (max - min)) * 100;
// Spring-smoothed position drives both the thumb and the fill.
const target = useMotionValue(percent);
useEffect(() => {
target.set(percent);
}, [percent, target]);
const smooth = useSpring(target, SPRING_GLIDE);
const pos = reduce ? target : smooth;
const left = useMotionTemplate`${pos}%`;
// Self-offset the thumb from 0% (flush left) to -100% (flush right) of its
// own width so it stays fully inside the track at both ends — no clip, no gap.
const thumbX = useTransform(pos, (p) => `${-p}%`);
const steps = Math.floor((max - min) / step);
const ticks =
showTicks && steps > 0 && steps <= 50
? Array.from({ length: steps + 1 }, (_, i) => min + i * step)
: [];
const commit = useCallback(
(next: number) => {
const snapped = clamp(Math.round((next - min) / step) * step + min, min, max);
if (!controlled) setInternal(snapped);
onValueChange?.(snapped);
},
[controlled, onValueChange, min, max, step],
);
const valueFromX = useCallback(
(clientX: number) => {
const rect = trackRef.current?.getBoundingClientRect();
if (!rect) return current;
const ratio = clamp((clientX - rect.left) / rect.width, 0, 1);
return min + ratio * (max - min);
},
[current, min, max],
);
const onPointerDown = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (disabled) return;
event.currentTarget.setPointerCapture(event.pointerId);
setActive(true);
commit(valueFromX(event.clientX));
},
[disabled, commit, valueFromX],
);
const onPointerMove = useCallback(
(event: PointerEvent<HTMLDivElement>) => {
if (!active || disabled) return;
commit(valueFromX(event.clientX));
},
[active, disabled, commit, valueFromX],
);
const endDrag = useCallback((event: PointerEvent<HTMLDivElement>) => {
event.currentTarget.releasePointerCapture?.(event.pointerId);
setActive(false);
}, []);
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
const map: Record<string, number> = {
ArrowRight: current + step,
ArrowUp: current + step,
ArrowLeft: current - step,
ArrowDown: current - step,
Home: min,
End: max,
};
if (event.key in map) {
event.preventDefault();
commit(map[event.key]);
}
},
[disabled, current, step, min, max, commit],
);
return (
<div
ref={trackRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
className={cn(
"relative flex h-10 w-full touch-none select-none items-center overflow-hidden rounded-lg bg-muted",
disabled ? "pointer-events-none opacity-50" : "cursor-grab active:cursor-grabbing",
className,
)}
>
{/* fill — runs from the left edge to the thumb, consistent tone */}
<motion.div
className="absolute inset-y-0 left-0 bg-foreground/15"
style={{ width: left }}
/>
{/* ticks — slight inset so the end dots don't clip */}
<div className="pointer-events-none absolute inset-x-2 inset-y-0">
{ticks.map((t) => {
const tp = ((t - min) / (max - min)) * 100;
return (
<span
key={t}
className="absolute top-1/2 size-1 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/25"
style={{ left: `${tp}%` }}
/>
);
})}
</div>
{/* vertical bar thumb — contained at both ends via thumbX */}
<motion.div
role="slider"
tabIndex={disabled ? -1 : 0}
aria-label={ariaLabel}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={current}
aria-disabled={disabled || undefined}
onKeyDown={onKeyDown}
animate={reduce ? undefined : { scaleY: active ? 1.35 : 1 }}
transition={SPRING_BOUNCY}
className="absolute top-1/2 h-5 w-1.5 rounded-sm bg-foreground shadow-sm outline-none ring-foreground/30 focus-visible:ring-4"
style={{ left, x: thumbX, y: "-50%" }}
/>
</div>
);
}
TSXcomponents/motion/switch.tsx
"use client";
import { animate, motion, MotionConfig, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { cn } from "@/lib/utils";
// Heavy, deliberate thumb — high mass keeps the travel weighty without wobble.
const THUMB_SPRING = { type: "spring", stiffness: 800, damping: 80, mass: 4 } as const;
export interface SwitchProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
label?: string;
className?: string;
}
export function Switch({ checked, onCheckedChange, disabled, label, className }: SwitchProps) {
const id = useId();
const thumbRef = useRef<HTMLDivElement>(null);
const reduce = useReducedMotion();
const [isPressed, setIsPressed] = useState(false);
const [isPointer, setIsPointer] = useState(false);
// Disabled shake feedback when pressed.
useEffect(() => {
if (!thumbRef.current || reduce) return;
if (disabled && isPressed) {
animate(
thumbRef.current,
{ x: [0, -2, 2, -1, 0] },
{ delay: 0.2, duration: 0.6 },
);
}
}, [disabled, isPressed, reduce]);
const squish = !disabled && isPointer && isPressed && !reduce;
return (
<MotionConfig transition={reduce ? { duration: 0 } : THUMB_SPRING}>
<span className={cn("inline-flex items-center gap-3", className)}>
<motion.button
id={id}
type="button"
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
onPointerDown={(e) => {
setIsPressed(true);
setIsPointer(e.type.startsWith("pointer"));
}}
onPointerUp={() => setIsPressed(false)}
onPointerLeave={() => setIsPressed(false)}
initial={false}
data-state={checked ? "checked" : "unchecked"}
className={cn(
"group peer inline-flex h-7 w-12 shrink-0 cursor-pointer items-center px-1 rounded-full outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
checked ? "justify-end bg-primary" : "justify-start bg-muted-foreground/60",
)}
>
<motion.div
ref={thumbRef}
layout
animate={{ scale: squish ? 0.9 : 1 }}
className="pointer-events-none block h-5 w-5 rounded-full bg-background shadow-md"
>
{/* Stretch toward the destination while active. */}
<div
className={cn(
"size-5",
squish && (checked ? "ml-1" : "mr-1"),
)}
/>
</motion.div>
</motion.button>
{label ? (
<label htmlFor={id} className="cursor-pointer text-sm text-foreground">
{label}
</label>
) : null}
</span>
</MotionConfig>
);
}
API 参考
SettingsGroup
variant?"card" | "quiet"Card keeps the preference shell; quiet becomes a flat hairline group.
cardtitle?ReactNode—actions?ReactNodeHeader-right slot, e.g. a `SettingsGhostButton` plus a `SettingsSelectButton`.
—className?string—SettingsRow
labelReactNode—description?ReactNodeMuted one-liner rendered under `label`.
—className?string—children?ReactNodeTrailing control slot, e.g. a `SettingsColorField` or a `Switch`.
—SettingsColorField
valuestringCurrent color as `#RRGGBB` (or `#RGB`); the pill background and text contrast follow it.
—onChange?((hex: string) => void)—aria-label?string—className?string—SettingsTextField
valuestring—onChange?((next: string) => void)—placeholder?string—aria-label?string—className?string—SettingsSelectButton
icon?ReactNode14px leading icon.
—onClick?(() => void)—className?string—aria-label?string—SettingsGhostButton
onClick?(() => void)—className?string—Keep in mind
Some components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.