Exposure Slider
Exposure Slider é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
SmoothUI
MIT
forms
O que é
Exposure Slider é um componente de formulário para React e Tailwind CSS, da biblioteca SmoothUI, com licença MIT. Copia e cola no teu projeto.
Como usar
Abre o componente no catálogo, carrega em copiar, e cola no teu projeto. O prompt copiado leva o código e as regras para o teu agente o reproduzir sem alterar nada.
Cinco cópias grátis por mês. Sem cartão.
Precisa de
npm i motion
Licença
Este componente vem de SmoothUI e é redistribuído sob a licença MIT, que permite uso comercial. O aviso de copyright viaja com o código.
Exposure Slider
"use client";
import { cn } from "@/lib/utils";
import {
type MotionValue,
motion,
useMotionValue,
useReducedMotion,
useSpring,
useTransform,
} from "motion/react";
import { useCallback, useRef } from "react";
export interface ExposureSliderProps {
/** Accent color for the active notch and progress ring (CSS color value) */
accentColor?: string;
/** Additional CSS classes */
className?: string;
/** Initial value */
defaultValue?: number;
/** Maximum value */
max?: number;
/** Minimum value */
min?: number;
/** Callback fired when the value changes */
onChange?: (value: number) => void;
/** Show the circular progress indicator with the current value */
showIndicator?: boolean;
/** Step size between values */
step?: number;
}
const NOTCH_WIDTH = 13; // px per notch (3px notch + 10px gap)
const SPRING_CONFIG = { damping: 30, mass: 0.5, stiffness: 300 };
const DEFAULT_ACCENT = "var(--color-brand, oklch(0.65 0.25 12))";
const ExposureSlider = ({
min = -20,
max = 20,
step = 1,
defaultValue = 0,
onChange,
showIndicator = true,
accentColor,
className,
}: ExposureSliderProps) => {
const shouldReduceMotion = useReducedMotion();
const containerRef = useRef<HTMLDivElement>(null);
const isDragging = useRef(false);
const count = Math.floor((max - min) / step) + 1;
const centerIndex = Math.floor((defaultValue - min) / step);
// Raw drag offset and spring-smoothed version
const rawX = useMotionValue(0);
const x = shouldReduceMotion ? rawX : useSpring(rawX, SPRING_CONFIG);
// Current value derived from offset
const currentValue = useTransform(x, (latest) => {
const indexOffset = Math.round(-latest / NOTCH_WIDTH);
const val = Math.max(
min,
Math.min(max, (centerIndex + indexOffset) * step + min)
);
return val;
});
// Track value for circle and display
const displayValue = useTransform(currentValue, (v) => Math.round(v));
const normalizedValue = useTransform(currentValue, [min, max], [-1, 1]);
const snapToNearest = useCallback(() => {
const current = rawX.get();
const snapped = Math.round(current / NOTCH_WIDTH) * NOTCH_WIDTH;
rawX.set(snapped);
}, [rawX]);
const handlePointerDown = useCallback(
(e: React.PointerEvent) => {
isDragging.current = true;
const startX = e.clientX;
const startOffset = rawX.get();
const handleMove = (moveEvent: PointerEvent) => {
const delta = moveEvent.clientX - startX;
const newOffset = startOffset + delta;
// Clamp to bounds
const maxOffset = (count - 1 - centerIndex) * NOTCH_WIDTH;
const minOffset = -centerIndex * NOTCH_WIDTH;
rawX.set(Math.max(-maxOffset, Math.min(-minOffset, newOffset)));
// Fire onChange
const indexOffset = Math.round(-rawX.get() / NOTCH_WIDTH);
const val = Math.max(
min,
Math.min(max, (centerIndex + indexOffset) * step + min)
);
onChange?.(Math.round(val));
};
const handleUp = () => {
isDragging.current = false;
snapToNearest();
// Fire final onChange
const indexOffset = Math.round(-rawX.get() / NOTCH_WIDTH);
const val = Math.max(
min,
Math.min(max, (centerIndex + indexOffset) * step + min)
);
onChange?.(Math.round(val));
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", handleUp);
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", handleUp);
},
[rawX, centerIndex, count, min, max, step, onChange, snapToNearest]
);
const items = Array.from({ length: count }, (_, i) => i);
return (
<div
className={cn(
"flex w-full max-w-[500px] flex-col items-center gap-6 text-foreground",
className
)}
style={
{ "--es-accent": accentColor ?? DEFAULT_ACCENT } as React.CSSProperties
}
>
{/* Progress circle */}
{showIndicator ? (
<ProgressCircle
displayValue={displayValue}
normalizedValue={normalizedValue}
/>
) : null}
{/* Ticker slider */}
<div
className="relative flex h-10 w-full items-center justify-center"
style={{
maskImage:
"linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%)",
WebkitMaskImage:
"linear-gradient(to right, transparent 0%, black 20%, black 80%, transparent 100%)",
}}
>
<div
className="relative h-full w-full cursor-grab select-none active:cursor-grabbing"
onPointerDown={handlePointerDown}
ref={containerRef}
style={{
padding: `0 calc(50% - ${NOTCH_WIDTH / 2}px)`,
touchAction: "pan-y",
}}
>
<motion.ul
className="relative m-0 flex h-full list-none items-center p-0"
style={{ marginLeft: -centerIndex * NOTCH_WIDTH, x }}
>
{items.map((i) => (
<Notch centerIndex={centerIndex} index={i} key={i} x={x} />
))}
</motion.ul>
</div>
</div>
</div>
);
};
/** Individual notch mark in the ticker */
const Notch = ({
index,
centerIndex,
x,
}: {
index: number;
centerIndex: number;
x: MotionValue<number>;
}) => {
// Distance from center in notch units
const distance = useTransform(x, (latest) => {
const currentCenter = centerIndex + -latest / NOTCH_WIDTH;
return Math.abs(index - currentCenter);
});
const opacity = useTransform(distance, [0, 1, 3], [1, 0.6, 0.3]);
const clipTop = useTransform(distance, [0, 1, 2], [0, 30, 50]);
const clipPath = useTransform(clipTop, (v) => `inset(${v}% 0px 0px)`);
const isCenter = useTransform(distance, (d) => d < 0.5);
const bg = useTransform(isCenter, (center) =>
center ? "var(--es-accent)" : "currentColor"
);
return (
<li
className="relative flex-shrink-0 flex-grow-0"
style={{ height: "fit-content" }}
>
<div style={{ padding: "0 5px" }}>
<motion.div
className="rounded-sm"
style={{
backgroundColor: bg,
clipPath,
height: 40,
opacity,
width: 3,
willChange: "clip-path, opacity",
}}
/>
</div>
</li>
);
};
/** SVG progress ring showing positive/negative value */
const ProgressCircle = ({
normalizedValue,
displayValue,
}: {
normalizedValue: MotionValue<number>;
displayValue: MotionValue<number>;
}) => {
// Positive arc (right side, 0 to 1)
const positiveDash = useTransform(normalizedValue, (v) =>
v > 0 ? `${v} ${1 - v}` : "0 1"
);
// Negative arc (left side, mirrored)
const negativeDash = useTransform(normalizedValue, (v) =>
v < 0 ? `${-v} ${1 + v}` : "0 1"
);
// Color: accent when non-zero, foreground when zero
const color = useTransform(normalizedValue, (v) =>
Math.abs(v) > 0.01 ? "var(--es-accent)" : "currentColor"
);
return (
<div className="relative flex h-[75px] w-[75px] items-center justify-center">
<svg className="absolute inset-0 h-full w-full" viewBox="0 0 100 100">
{/* Background ring */}
<circle
cx="50"
cy="50"
fill="currentColor"
fillOpacity={0.067}
r="48"
stroke="currentColor"
strokeOpacity={0.3}
strokeWidth="3"
/>
{/* Positive indicator */}
<motion.circle
cx="50"
cy="50"
fill="none"
pathLength={1}
r="48"
stroke="var(--es-accent)"
strokeDasharray={positiveDash}
strokeDashoffset={0}
strokeWidth="3"
style={{
transform: "rotate(-90deg)",
transformBox: "fill-box",
transformOrigin: "50% 50%",
}}
/>
{/* Negative indicator */}
<motion.circle
cx="50"
cy="50"
fill="none"
pathLength={1}
r="48"
stroke="var(--es-accent)"
strokeDasharray={negativeDash}
strokeDashoffset={0}
strokeWidth="3"
style={{
transform: "scaleX(-1) rotate(-90deg)",
transformBox: "fill-box",
transformOrigin: "50% 50%",
}}
/>
</svg>
<motion.span className="absolute font-semibold text-lg" style={{ color }}>
{displayValue}
</motion.span>
</div>
);
};
export default ExposureSlider;