Deslizador
Una entrada de rango para seleccionar valores numéricos a lo largo de una pista, proporcionando una forma visual e interactiva de elegir un valor dentro de los límites min/max definidos.
Casos de uso
- Ajusta el volumen, brillo u opacidad en herramientas multimedia y de diseño
- Establece filtros de rango de precios en listados de productos de comercio electrónico
- Controla el nivel de zoom en mapas o visores de imágenes
- Selecciona una duración o valor de tiempo de espera en paneles de configuración
- Elige el tamaño de fuente o espaciado en un personalizador de tema
- Filtra resultados de búsqueda por distancia o calificación
- Establece umbrales de progreso para paneles e informes
Implementación más simple
"use client";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
}
export function Slider({ value, onChange, min = 0, max = 100 }: SliderProps) {
return (
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
/>
);
}Un deslizador de rango mínimo utilizando el nativo <input type="range">. La clase appearance-none elimina el estilo predeterminado del navegador, y accent-blue-600 colorea el pulgar y la porción rellena de la pista en navegadores compatibles. El callback onChange convierte el valor de cadena a un número antes de pasarlo al componente padre.
Variaciones
Con etiquetas Min/Max
"use client";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
label?: string;
}
export function Slider({ value, onChange, min = 0, max = 100, label }: SliderProps) {
return (
<div className="w-full">
{label && (
<span className="mb-1 block text-sm font-medium text-gray-700">{label}</span>
)}
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
/>
<div className="mt-1 flex justify-between text-xs text-gray-500">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
}Los valores min y max se muestran debajo de la pista del deslizador utilizando flex justify-between. Esto proporciona al usuario un contexto claro sobre los límites del rango sin necesidad de adivinar.
Con marcas de paso
"use client";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
}
export function Slider({ value, onChange, min = 0, max = 100, step = 25 }: SliderProps) {
const marks = [];
for (let i = min; i <= max; i += step) {
marks.push(i);
}
return (
<div className="w-full">
<input
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
/>
<div className="relative mt-2 h-4">
{marks.map((mark) => {
const percent = ((mark - min) / (max - min)) * 100;
return (
<span
key={mark}
className="absolute -translate-x-1/2 text-xs text-gray-500"
style={{ left: `${percent}%` }}
>
{mark}
</span>
);
})}
</div>
</div>
);
}Las marcas de paso se generan a partir de la prop step y se posicionan absolutamente utilizando un desplazamiento de porcentaje. Cada marca se centra sobre su posición con -translate-x-1/2. El atributo step en la entrada asegura que el pulgar se ajuste a estos valores discretos.
Deslizador de rango de dual pulgar
"use client";
import { useState, useCallback } from "react";
interface RangeSliderProps {
min?: number;
max?: number;
values: [number, number];
onChange: (values: [number, number]) => void;
}
export function RangeSlider({ min = 0, max = 100, values, onChange }: RangeSliderProps) {
const [dragging, setDragging] = useState<"min" | "max" | null>(null);
const getPercent = useCallback(
(val: number) => ((val - min) / (max - min)) * 100,
[min, max]
);
const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newMin = Math.min(Number(e.target.value), values[1] - 1);
onChange([newMin, values[1]]);
};
const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newMax = Math.max(Number(e.target.value), values[0] + 1);
onChange([values[0], newMax]);
};
return (
<div className="relative w-full">
{/* Fondo de pista */}
<div className="h-2 w-full rounded-full bg-gray-200" />
{/* Rango activo */}
<div
className="absolute top-0 h-2 rounded-full bg-blue-600"
style={{
left: `${getPercent(values[0])}%`,
width: `${getPercent(values[1]) - getPercent(values[0])}%`,
}}
/>
{/* Pulgar mínimo */}
<input
type="range"
min={min}
max={max}
value={values[0]}
onChange={handleMinChange}
onMouseDown={() => setDragging("min")}
onMouseUp={() => setDragging(null)}
className="pointer-events-none absolute top-0 h-2 w-full appearance-none bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-2 [&::-webkit-slider-thumb]:ring-blue-600"
style={{ zIndex: dragging === "min" ? 30 : 20 }}
/>
{/* Pulgar máximo */}
<input
type="range"
min={min}
max={max}
value={values[1]}
onChange={handleMaxChange}
onMouseDown={() => setDragging("max")}
onMouseUp={() => setDragging(null)}
className="pointer-events-none absolute top-0 h-2 w-full appearance-none bg-transparent [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-2 [&::-webkit-slider-thumb]:ring-blue-600"
style={{ zIndex: dragging === "max" ? 30 : 20 }}
/>
{/* Etiquetas de valor */}
<div className="mt-4 flex justify-between text-sm text-gray-600">
<span>{values[0]}</span>
<span>{values[1]}</span>
</div>
</div>
);
}Dos entradas de rango superpuestas crean un deslizador de dual pulgar. Las entradas se establecen en pointer-events-none solo con los pulgares recibiendo pointer-events-auto, para que el usuario pueda arrastrar cada uno de forma independiente. El segmento de pista activa entre los pulgares es un div posicionado absolutamente con valores dinámicos left y width. El z-index se ajusta según qué pulgar se esté arrastrando para evitar que el pulgar incorrecto capture el evento cuando se superponen.
Con visualización de valor en vivo
"use client";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
label: string;
unit?: string;
}
export function Slider({ value, onChange, min = 0, max = 100, label, unit = "" }: SliderProps) {
return (
<div className="w-full">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium text-gray-700">{label}</span>
<span className="rounded bg-blue-50 px-2 py-0.5 text-sm font-semibold text-blue-700">
{value}{unit}
</span>
</div>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
/>
</div>
);
}El valor actual se muestra como una insignia en la esquina superior derecha, actualizándose en tiempo real mientras el usuario arrastra el pulgar. La prop unit (p. ej., "px", "%", "ms") añade un sufijo de unidad al valor mostrado para mayor claridad.
Deslizador vertical
"use client";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
height?: string;
}
export function VerticalSlider({ value, onChange, min = 0, max = 100, height = "12rem" }: SliderProps) {
return (
<div className="flex flex-col items-center gap-2" style={{ height }}>
<span className="text-xs text-gray-500">{max}</span>
<input
type="range"
min={min}
max={max}
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-gray-200 accent-blue-600"
style={{
writingMode: "vertical-lr",
direction: "rtl",
width: height,
}}
/>
<span className="text-xs text-gray-500">{min}</span>
</div>
);
}Las propiedades CSS writing-mode: vertical-lr y direction: rtl rotan la entrada de rango nativa a una orientación vertical. El direction: rtl asegura que el valor mínimo esté en la parte inferior y el máximo en la parte superior. Este enfoque evita transformaciones CSS que pueden romper las coordenadas de clic.
Implementación compleja
"use client";
import { forwardRef, useId, useCallback, useRef, useEffect, useState } from "react";
type SliderSize = "sm" | "md" | "lg";
interface SliderProps {
value: number;
onChange: (value: number) => void;
min?: number;
max?: number;
step?: number;
size?: SliderSize;
label?: string;
showValue?: boolean;
showTooltip?: boolean;
unit?: string;
disabled?: boolean;
marks?: { value: number; label?: string }[];
name?: string;
id?: string;
className?: string;
}
const sizeClasses: Record<SliderSize, { track: string; thumb: string }> = {
sm: {
track: "h-1",
thumb: "[&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5",
},
md: {
track: "h-2",
thumb: "[&::-webkit-slider-thumb]:h-5 [&::-webkit-slider-thumb]:w-5",
},
lg: {
track: "h-3",
thumb: "[&::-webkit-slider-thumb]:h-6 [&::-webkit-slider-thumb]:w-6",
},
};
export const Slider = forwardRef<HTMLInputElement, SliderProps>(function Slider(
{
value,
onChange,
min = 0,
max = 100,
step = 1,
size = "md",
label,
showValue = false,
showTooltip = false,
unit = "",
disabled = false,
marks,
name,
id: externalId,
className,
},
ref
) {
const generatedId = useId();
const sliderId = externalId ?? generatedId;
const s = sizeClasses[size];
const [isDragging, setIsDragging] = useState(false);
const trackRef = useRef<HTMLDivElement>(null);
const percent = ((value - min) / (max - min)) * 100;
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return;
onChange(Number(e.target.value));
},
[disabled, onChange]
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
let newValue = value;
switch (e.key) {
case "Home":
newValue = min;
break;
case "End":
newValue = max;
break;
default:
return;
}
e.preventDefault();
onChange(newValue);
},
[disabled, value, min, max, onChange]
);
return (
<div className={`w-full ${disabled ? "opacity-50" : ""} ${className ?? ""}`}>
{(label || showValue) && (
<div className="mb-2 flex items-center justify-between">
{label && (
<label htmlFor={sliderId} className="text-sm font-medium text-gray-700">
{label}
</label>
)}
{showValue && (
<output
htmlFor={sliderId}
className="rounded bg-blue-50 px-2 py-0.5 text-sm font-semibold text-blue-700"
>
{value}{unit}
</output>
)}
</div>
)}
<div ref={trackRef} className="relative">
{/* Relleno de pista personalizado */}
<div
className={`pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 rounded-full bg-blue-600 ${s.track}`}
style={{ width: `${percent}%` }}
/>
{/* Información sobre herramientas */}
{showTooltip && isDragging && (
<div
className="absolute -top-8 z-10 -translate-x-1/2 rounded bg-gray-900 px-2 py-0.5 text-xs text-white"
style={{ left: `${percent}%` }}
>
{value}{unit}
</div>
)}
<input
ref={ref}
id={sliderId}
type="range"
name={name}
min={min}
max={max}
step={step}
value={value}
disabled={disabled}
onChange={handleChange}
onKeyDown={handleKeyDown}
onMouseDown={() => setIsDragging(true)}
onMouseUp={() => setIsDragging(false)}
onTouchStart={() => setIsDragging(true)}
onTouchEnd={() => setIsDragging(false)}
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
aria-valuetext={`${value}${unit}`}
className={[
"relative w-full cursor-pointer appearance-none rounded-full bg-gray-200",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2",
"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:ring-2 [&::-webkit-slider-thumb]:ring-blue-600 [&::-webkit-slider-thumb]:transition-shadow",
"[&::-webkit-slider-thumb]:hover:shadow-lg [&::-webkit-slider-thumb]:active:ring-blue-700",
disabled ? "cursor-not-allowed" : "",
s.track,
s.thumb,
].join(" ")}
/>
</div>
{marks && marks.length > 0 && (
<div className="relative mt-2 h-5">
{marks.map((mark) => {
const markPercent = ((mark.value - min) / (max - min)) * 100;
return (
<button
key={mark.value}
type="button"
onClick={() => !disabled && onChange(mark.value)}
className="absolute -translate-x-1/2 text-xs text-gray-500 hover:text-gray-700"
style={{ left: `${markPercent}%` }}
>
{mark.label ?? mark.value}
</button>
);
})}
</div>
)}
</div>
);
});Aspectos clave:
- forwardRef -- permite que los componentes padre accedan a la entrada nativa para la gestión del enfoque o integración con bibliotecas de formularios.
- Relleno de pista personalizado -- un div posicionado absolutamente cubre la porción izquierda de la pista proporcional al valor actual, proporcionando un visual de pista rellena que funciona en todos los navegadores sin trucos de pseudo-elementos específicos del proveedor.
- Información sobre herramientas solo de arrastre -- la información sobre herramientas aparece solo mientras el usuario está arrastrando activamente (estado
isDragging), evitando desorden mientras proporciona retroalimentación precisa del valor durante la interacción. - Atributos de valor ARIA --
aria-valuenow,aria-valuemin,aria-valuemax, yaria-valuetextproporcionan a los lectores de pantalla contexto completo sobre el estado actual del deslizador. Elaria-valuetextincluye la unidad para anuncios legibles por humanos. - Compatibilidad con teclas Home/End -- el manejador de teclado añade Home (saltar a min) y End (saltar a max) además del manejo de teclas de flecha nativo del navegador, coincidiendo con el patrón de deslizador WAI-ARIA.
- Marcas clicables -- las etiquetas de marca se representan como botones que ajustan el deslizador a su valor al hacer clic, proporcionando acceso rápido a ajustes preestablecidos comunes sin arrastre preciso.
- Elemento
<output>-- la visualización de valor en vivo utiliza el elemento semántico<output>conhtmlForvinculándolo al deslizador, que los lectores de pantalla tratan como una región viva. - Compatibilidad con toque -- los oyentes
onTouchStartyonTouchEndrastrean el estado de arrastre en dispositivos móviles además de los eventos del ratón.
Tramapas
-
onChangedevuelve una cadena -- el eventoonChangede la entrada de rango nativa devuelvee.target.valuecomo una cadena. Siempre convierte conNumber()oparseFloat()antes de usar el valor en cálculos. -
Estilo de pulgar entre navegadores -- WebKit, Firefox y Edge utilizan diferentes pseudo-elementos para el pulgar del deslizador (
::-webkit-slider-thumb,::-moz-range-thumb). Los selectores arbitrarios de Tailwind solo apuntan a WebKit por defecto. Añade estilos específicos de Firefox si es necesario. -
Precisión de punto flotante de paso -- un
stepde0.1puede producir valores como0.30000000000000004debido a las matemáticas de punto flotante. Redondea los valores mostrados contoFixed()y restringe los valores almacenados a la precisión deseada. -
La orientación vertical no es estándar -- el truco CSS de
writing-modepara deslizadores verticales no funciona consistentemente en todos los navegadores. Considera una implementación completamente personalizada con controladores de eventos de ratón/toque para un comportamiento vertical confiable. -
Conflictos de índice z de dual pulgar -- cuando dos entradas de rango se superponen para un deslizador de rango, la entrada superior captura todos los eventos. Usa
pointer-events-noneen la pista de entrada ypointer-events-autosolo en el pulgar, mász-indexdinámico en el pulgar activo. -
Sin
onChangeal cambiar el valor programáticamente -- establecer el valor a través de state no dispara el eventochangenativo. Si necesitas sincronizar con oyentes externos, dispara un evento sintético después de actualizar. -
Estilo deshabilitado en entrada nativa -- la apariencia deshabilitada de la entrada de rango nativa varía mucho entre navegadores. Aplica tus propias clases
opacityycursor-not-alloweden el contenedor para una apariencia consistente.
Relacionado
- "Entrada -- Entradas de texto para entrada numérica directa junto con deslizadores"
- "Etiqueta -- Etiquetas accesibles para controles de deslizador"
- "Formularios -- Patrones de formulario y estrategias de componentes controlados"
- "Eventos -- Manejo de eventos para onChange e interacciones de teclado"