
¿Alguna vez has necesitado un input de rango doble (esos sliders que permiten seleccionar un intervalo con dos manecillas) pero no querías instalar librerías externas o usar los clásicos input type="range"?
En este artículo vamos a construir, paso a paso, un componente de Next.js + React + Tailwind + TypeScript que permite seleccionar un rango de valores con dos controles deslizantes. Y lo mejor de todo: sin depender de UI Libraries externas, solo con HTML, CSS (Tailwind) y un poco de JavaScript.
Al final tendrás un componente 100% personalizable y reusable en cualquier proyecto.
Visualmente se parece a un range slider, pero en lugar de usar input type="range", lo construimos desde cero.
El Componente Completo
Aquí está el código completo para referencia (más adelante lo explicamos línea por línea):
import { useState, useRef } from "react";
interface DoubleRangeSliderProps {
min?: number;
max?: number;
step?: number;
defaultValues?: [number, number];
onChange?: (values: [number, number]) => void;
}
export default function DoubleRangeSlider({
min = 0,
max = 100,
step = 1,
defaultValues = [20, 50],
onChange,
}: DoubleRangeSliderProps) {
const [minValue, setMinValue] = useState<number>(defaultValues[0]);
const [maxValue, setMaxValue] = useState<number>(defaultValues[1]);
const sliderRef = useRef<HTMLDivElement>(null);
// 🔹 Notificar cambios al padre
const notifyChange = (newMin: number, newMax: number) => {
if (onChange) onChange([newMin, newMax]);
};
const handleMouseDown = (e: React.MouseEvent, isMin: boolean) => {
e.preventDefault();
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const offsetX = moveEvent.clientX - rect.left;
let newPercentage = (offsetX / rect.width) * (max - min) + min;
let newValue = Math.round(newPercentage / step) * step;
// Clamping
newValue = Math.max(min, Math.min(max, newValue));
if (isMin) {
if (newValue >= maxValue - step) {
setMinValue(maxValue - step);
notifyChange(maxValue - step, maxValue);
return;
}
setMinValue(newValue);
notifyChange(newValue, maxValue);
} else {
if (newValue <= minValue + step) {
setMaxValue(minValue + step);
notifyChange(minValue, minValue + step);
return;
}
setMaxValue(newValue);
notifyChange(minValue, newValue);
}
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
};
// 🔹 Cambios en inputs numéricos
const handleMinInput = (e: React.ChangeEvent) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value >= min && value <= maxValue - step) {
setMinValue(value);
notifyChange(value, maxValue);
}
};
const handleMaxInput = (e: React.ChangeEvent) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value <= max && value >= minValue + step) {
setMaxValue(value);
notifyChange(minValue, value);
}
};
return (
<>
{/* Inputs numéricos */}
<div className="flex mb-4">
<div className="w-1/2 mr-2">
<input
type="number"
min={min}
max={max}
step={step}
value={minValue}
onChange={handleMinInput}
className="w-full border border-gray-300 bg-white rounded px-2 py-1 text-gray-700 focus:outline-none"
placeholder="Desde"
/>
</div>
<div className="w-1/2 ml-2">
<input
type="number"
min={min}
max={max}
step={step}
value={maxValue}
onChange={handleMaxInput}
className="w-full border border-gray-300 bg-white rounded px-2 py-1 text-gray-700 focus:outline-none"
placeholder="Hasta"
/>
</div>
</div>
{/* Slider */}
<div className="relative h-10" ref={sliderRef}>
{/* Handle mínimo */}
<div
onMouseDown={(e) => handleMouseDown(e, true)}
className="rounded-full h-5 w-5 bg-main absolute cursor-pointer z-20"
style={{
left: `calc(${((minValue - min) / (max - min)) * 100}% - 10px)`,
top: "50%",
transform: "translateY(-50%)",
}}
></div>
{/* Handle máximo */}
<div
onMouseDown={(e) => handleMouseDown(e, false)}
className="rounded-full h-5 w-5 bg-main absolute cursor-pointer z-20"
style={{
left: `calc(${((maxValue - min) / (max - min)) * 100}% - 10px)`,
top: "50%",
transform: "translateY(-50%)",
}}
></div>
{/* Línea base */}
<div className="absolute top-1/2 left-0 w-full h-2 bg-gray-200 rounded -translate-y-1/2"></div>
{/* Rango seleccionado */}
<div
className="absolute top-1/2 h-2 bg-second rounded -translate-y-1/2 z-0"
style={{
left: `${((minValue - min) / (max - min)) * 100}%`,
width: `${((maxValue - minValue) / (max - min)) * 100}%`,
}}
/>
</div>
</>
);
}
Explicación Paso a Paso
Ahora sí, vamos al detalle.
1. Estados Iniciales
const [minValue, setMinValue] = useState<number>(defaultValues[0]);
const [maxValue, setMaxValue] = useState<number>(defaultValues[1]);
Usamos dos estados:
- minValue: valor inicial del rango.
- maxValue: valor final del rango.
Ambos son números y toman sus valores iniciales de los props: 20 y 50.
2. Referencias con useRef
const sliderRef = useRef<HTMLDivElement>(null);
Con useRef guardamos referencias a:
- El contenedor del slider (sliderRef).
Esto nos permitirá calcular posiciones con getBoundingClientRect() y manipular directamente la posición de los "handles".
3. Función para arrastrar (handleMouseDown)
Esta es la parte más compleja e interesante del componente.
const handleMouseDown = (e: React.MouseEvent, min:boolean) => {
e.preventDefault();
La función se activa al hacer clic en un "handle". Recibe un booleano (min) que indica si estamos moviendo el control mínimo o máximo.
Dentro definimos un listener para mousemove:
const handleMouseMove = (moveEvent: MouseEvent) => {
if (!sliderRef.current) return;
const rect = sliderRef.current.getBoundingClientRect();
const offsetX = moveEvent.clientX - rect.left;
let newPercentage = (offsetX / rect.width) * 100;
newPercentage = Math.max(0, Math.min(100, newPercentage));
Aquí:
- rect.width: ancho del slider.
- moveEvent.clientX - rect.left: posición actual del mouse relativa al inicio del slider.
- newPercentage: convertimos esa posición en un porcentaje (0 a 100).
- Usamos Math.max y Math.min para evitar que se salga de los bordes.
Evitando que se crucen los controles
if(min){
if(Math.round(newPercentage) > (maxValue - 2)){
setMinValue(maxValue - 1)
return
}
setMinValue(Math.round(newPercentage));
}else{
if(Math.round(newPercentage) < (minValue + 2)){
setMaxValue(minValue + 1)
return
}
setMaxValue(Math.round(newPercentage));
}
Reglas:
- El mínimo nunca puede sobrepasar al máximo (dejamos un margen de 1).
- El máximo nunca puede ser menor que el mínimo.
- Esto hace que el rango siempre sea válido.
Listeners de soltar el mouse
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
Aquí enganchamos y removemos eventos del documento. Esto permite que aunque arrastres el mouse fuera del área del slider, el "drag" siga funcionando.
4. Inputs Numéricos
También podemos modificar el rango con inputs numéricos:
const handleInitialInputChange = (e: React.ChangeEvent) => {
const value = parseInt(e.target.value);
if (!isNaN(value)){
if(value >= 0 && value <= (maxValue - 1)) setMinValue(value)
}
};
Reglas:
- El inicial debe estar entre 0 y maxValue - 1.
Y lo mismo para el final:
const handleFinalInputChange = (e: React.ChangeEvent) => {
const value = parseInt(e.target.value);
if (!isNaN(value)) {
if((minValue + 1) >= 50 && value <= 100) setMaxValue(value);
}
};
Acabamos de construir un slider de rango doble en React + Tailwind + TypeScript sin depender de librerías externas, ahora puedes reutilizar este componente en formularios, filtros de productos, configuradores o cualquier aplicación donde se necesite un rango de valores.
Ejemplo de Uso
import DoubleRangeSlider from "@/components/DoubleRangeSlider";
export default function DemoPage() {
return (
<div className="p-10">
<h1 className="text-xl font-bold mb-4">Filtro de precios</h1>
<DoubleRangeSlider
min={0}
max={1000}
step={10}
defaultValues={[200, 800]}
onChange={(values) => console.log("Nuevo rango:", values)}
/>
</div>
);
}
Esto renderiza un slider de 0 a 1000, con saltos de 10 en 10, y valores iniciales de 200 a 800. Cada vez que se mueve, imprime en consola el nuevo rango.
Al implementar el componente debes ver algo así:
Publicar un comentario