Exposure Slider
Exposure Slider is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
forms
What it is
Exposure Slider is a React and Tailwind CSS form component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i motion
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
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;