shadimage

Slider

Native accessible range control with RawkitUI label and value treatment.

components/ui/slider.tsx

import * as React from 'react'
import { cn } from '@/lib/utils'

export interface SliderProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'onChange' | 'type' | 'size'> {
  value?: number
  defaultValue?: number
  onValueChange?: (value: number) => void
  label?: React.ReactNode
  description?: React.ReactNode
  showValue?: boolean
  showPin?: boolean
  showTicks?: boolean
  size?: 'sm' | 'md' | 'lg'
  variant?: 'yellow' | 'orange' | 'mint' | 'violet' | 'pink' | 'sky' | 'lime' | 'gradient' | 'sunset' | 'neon'
  thumbShape?: 'circle' | 'square'
}

const variantFills = {
  yellow: 'bg-rk-primary text-rk-ink',
  orange: 'bg-rk-secondary text-rk-ink',
  mint: 'bg-rk-success text-rk-ink',
  violet: 'bg-rk-accent text-rk-ink',
  pink: 'bg-rk-pink text-rk-ink',
  sky: 'bg-rk-sky text-rk-ink',
  lime: 'bg-[#A3E635] text-rk-ink',
  gradient: 'bg-gradient-to-r from-[#FB923C] via-[#F87171] to-[#DC2626] text-white',
  sunset: 'bg-gradient-to-r from-rk-primary via-rk-secondary to-[#F87171] text-rk-ink',
  neon: 'bg-gradient-to-r from-[#A3E635] via-[#34D399] to-[#38BDF8] text-rk-ink',
}

const trackSizeClasses = {
  sm: 'h-5',
  md: 'h-7',
  lg: 'h-9',
}

const thumbSizeClasses = {
  sm: 'h-6 w-6 text-[10px]',
  md: 'h-8 w-8 text-xs',
  lg: 'h-10 w-10 text-sm',
}

export const Slider = React.forwardRef<HTMLInputElement, SliderProps>(
  (
    {
      value: controlledValue,
      defaultValue = 50,
      onValueChange,
      label,
      description,
      showValue = false,
      showPin = false,
      showTicks = false,
      size = 'md',
      variant = 'orange',
      thumbShape = 'circle',
      min = 0,
      max = 100,
      step = 1,
      disabled = false,
      className,
      ...props
    },
    ref
  ) => {
    const [internalValue, setInternalValue] = React.useState(defaultValue)
    const [isFocused, setIsFocused] = React.useState(false)

    const currentValue = controlledValue ?? internalValue
    const numMin = Number(min)
    const numMax = Number(max)
    const numStep = Number(step)
    const safeMax = numMax > numMin ? numMax : 100
    const percentage = Math.min(100, Math.max(0, ((currentValue - numMin) / (safeMax - numMin)) * 100))

    const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      const val = Number(e.target.value)
      if (controlledValue === undefined) {
        setInternalValue(val)
      }
      onValueChange?.(val)
    }

    const ticksCount = showTicks ? Math.min(11, Math.floor((safeMax - numMin) / numStep) + 1) : 0
    const ticks = showTicks ? Array.from({ length: ticksCount }) : []

    return (
      <div className={cn('w-full space-y-2.5 font-sans', disabled && 'opacity-60 cursor-not-allowed', className)}>
        {/* Header with Label, Description & Value Tag */}
        {(label || description || showValue) && (
          <div className="flex items-center justify-between gap-3">
            <div>
              {label && (
                <span className="font-display text-sm font-extrabold text-rk-ink tracking-tight">
                  {label}
                </span>
              )}
              {description && (
                <p className="text-xs font-semibold text-rk-ink/60 leading-none mt-0.5">
                  {description}
                </p>
              )}
            </div>
            {showValue && (
              <span className="inline-flex items-center justify-center rounded-full border-2 border-rk-ink bg-rk-primary px-2.5 py-0.5 font-mono text-xs font-black text-rk-ink rk-shadow-xs">
                {currentValue}
              </span>
            )}
          </div>
        )}

        {/* Interactive Track Area */}
        <div className="relative w-full py-4 select-none">
          {/* Invisible Native Input on Top */}
          <input
            ref={ref}
            type="range"
            min={min}
            max={max}
            step={step}
            value={currentValue}
            disabled={disabled}
            onChange={handleInputChange}
            onFocus={() => setIsFocused(true)}
            onBlur={() => setIsFocused(false)}
            className="absolute inset-0 w-full h-full opacity-0 z-30 cursor-pointer disabled:cursor-not-allowed"
            {...props}
          />

          {/* Custom Visual Track Container */}
          <div className="relative w-full flex items-center">
            {/* Background Track */}
            <div
              className={cn(
                'relative w-full overflow-hidden bg-white p-1 rk-border rk-shadow-sm rounded-full',
                trackSizeClasses[size]
              )}
            >
              {/* Active Filled Progress Track (Instant update with zero drag lag) */}
              <div
                className={cn('h-full rounded-full', variantFills[variant])}
                style={{ width: `${percentage}%` }}
              />

              {/* Optional Step Ticks */}
              {showTicks && ticks.length > 0 && (
                <div className="absolute inset-0 flex items-center justify-between px-2 pointer-events-none z-10">
                  {ticks.map((_, i) => (
                    <div key={i} className="h-1.5 w-1.5 rounded-full bg-rk-ink/40" />
                  ))}
                </div>
              )}
            </div>

            {/* Custom Thumb Handle (Vertically centered over track) */}
            <div
              className={cn(
                'absolute top-1/2 -translate-y-1/2 -translate-x-1/2 flex items-center justify-center font-mono font-black border-3 border-rk-ink rk-shadow-sm pointer-events-none z-20',
                isFocused && 'ring-4 ring-rk-ink/25',
                thumbShape === 'circle' ? 'rounded-full' : 'rounded-xl',
                thumbSizeClasses[size],
                variantFills[variant]
              )}
              style={{ left: `${percentage}%` }}
            >
              {/* Floating Tooltip Pin */}
              {showPin && (
                <div className="absolute -top-9 left-1/2 -translate-x-1/2 rounded-md border-2 border-rk-ink bg-rk-ink px-2 py-0.5 font-mono text-[11px] font-black text-rk-primary rk-shadow-xs whitespace-nowrap">
                  {currentValue}
                  <div className="absolute -bottom-1 left-1/2 -translate-x-1/2 h-2 w-2 rotate-45 border-b-2 border-r-2 border-rk-ink bg-rk-ink" />
                </div>
              )}
            </div>
          </div>
        </div>
      </div>
    )
  }
)
Slider.displayName = 'Slider'

Interaction
carousel

DependenciesBundle weight · light

clsxtailwind-merge