All components

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.

Open in the catalogue

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

index.tsx
"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;

Similar components