Slider

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview

Slider

범위 입력(range). 라벨·현재값·단위·reset 버튼을 한 줄에 정리. CSS 컨트롤 패널이나 설정 화면에서 즐겨 쓰는 입력.

Default (unit + reset)

가장 기본 — label / 현재값 / 단위 / 값이 다르면 reset 버튼 자동 노출.

Source Code
<Slider
  label="볼륨"
  value={volume}
  onChange={setVolume}
  unit="%"
  resetValue={50}
/>
60%
Live Preview

formatValue (소수·커스텀)

formatValue로 표시 형식 커스터마이즈. unit은 무시됨.

Source Code
<Slider
  label="투명도"
  value={opacity}
  onChange={setOpacity}
  min={0} max={1} step={0.05}
  formatValue={(v) => v.toFixed(2)}
/>
0.80
Live Preview

Sizes

sm · md 두 단계. 좁은 컨트롤 패널에선 sm 사용.

SM
<Slider size="sm" ... />
180°
Live Preview
MD (default)
<Slider ... />
180°
Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

Slidertypescript
'use client';

/**
 * Slider — 범위 입력 (range).
 *
 * - label / value(표시) / unit / reset 버튼을 한 줄로 정리
 * - formatValue 콜백으로 표시 형식 커스터마이즈 (지정 시 unit 무시)
 * - resetValue 지정 시 현재값이 다르면 reset 버튼 노출
 * - size: sm / md
 * - 트랙 채움은 linear-gradient(인라인 style)로 % 표시
 */

import { forwardRef, useId, type InputHTMLAttributes, type ReactNode } from 'react';
import { cn } from '@/lib/cn';

export type SliderSize = 'sm' | 'md';

export interface SliderProps
  extends Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'onChange' | 'type' | 'value'> {
  value: number;
  onChange: (value: number) => void;
  min?: number;
  max?: number;
  step?: number;
  size?: SliderSize;
  label?: ReactNode;
  description?: ReactNode;
  /** 값 옆에 표시할 단위 (px, %, deg 등). formatValue가 있으면 무시. */
  unit?: string;
  /** 값 표시 형식 커스터마이즈. */
  formatValue?: (value: number) => string;
  /** 지정 시, 현재값이 다르면 우상단에 reset 버튼 노출 */
  resetValue?: number;
}

export const Slider = forwardRef<HTMLInputElement, SliderProps>(
  (
    {
      value,
      onChange,
      min = 0,
      max = 100,
      step = 1,
      size = 'md',
      label,
      description,
      unit,
      formatValue,
      resetValue,
      disabled,
      className,
      id,
      ...rest
    },
    ref,
  ) => {
    const reactId = useId();
    const inputId = id ?? `slider-${reactId}`;
    const percent = max === min ? 0 : ((value - min) / (max - min)) * 100;
    const display = formatValue ? formatValue(value) : `${value}${unit ?? ''}`;
    const showReset = resetValue !== undefined && value !== resetValue;

    return (
      <div className={cn('w-full', disabled && 'opacity-60', className)}>
        {(label || resetValue !== undefined) && (
          <div className="mb-1.5 flex items-center justify-between gap-2">
            {label && (
              <label
                htmlFor={inputId}
                className="text-[11px] font-bold uppercase tracking-[0.06em] text-slate-700"
              >
                {label}
              </label>
            )}
            <div className="flex items-center gap-2">
              <span className="tabular-nums text-xs font-bold text-slate-900">
                {display}
              </span>
              {showReset && (
                <button
                  type="button"
                  onClick={() => onChange(resetValue as number)}
                  className="text-[10px] font-bold uppercase tracking-wider text-slate-400 transition-colors hover:text-violet-600"
                  aria-label="기본값으로 초기화"
                >
                  reset
                </button>
              )}
            </div>
          </div>
        )}
        <input
          ref={ref}
          id={inputId}
          type="range"
          value={value}
          min={min}
          max={max}
          step={step}
          disabled={disabled}
          onChange={(e) => onChange(Number(e.target.value))}
          style={{
            background: `linear-gradient(to right, #7c3aed 0%, #7c3aed ${percent}%, #e2e8f0 ${percent}%, #e2e8f0 100%)`,
          }}
          className={cn(
            'w-full cursor-pointer appearance-none rounded-full outline-none transition-all',
            size === 'sm' ? 'h-1' : 'h-1.5',
            '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-2 [&::-webkit-slider-thumb]:ring-violet-500 [&::-webkit-slider-thumb]:transition-transform hover:[&::-webkit-slider-thumb]:scale-110',
            '[&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-violet-500 [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:shadow-md [&::-moz-range-thumb]:transition-transform hover:[&::-moz-range-thumb]:scale-110',
            'focus-visible:ring-2 focus-visible:ring-violet-300 focus-visible:ring-offset-2 focus-visible:ring-offset-white',
            disabled && 'cursor-not-allowed',
          )}
          {...rest}
        />
        {description && (
          <p className="mt-1 text-[11px] text-slate-500">{description}</p>
        )}
      </div>
    );
  },
);

Slider.displayName = 'Slider';