
Tutorial: Creando un Componente Slider Personalizado en React
Artículos••por Luis
Introducción
Requisitos Previos
- Conocimientos básicos de React
- Familiaridad con TypeScript
- Un proyecto React configurado (podemos usar el que creamos en nuestro tutorial: Next Tutorial.)
Paso 1: Configuración Inicial
"use client";
import React, { useState, useRef, useCallback, useMemo, memo, useEffect } from "react";
- useState: Para gestionar el estado local del componente
- useRef: Para mantener una referencia al elemento DOM del slider
- useCallback: Para memorizar funciones y evitar recreaciones innecesarias
- useMemo: Para memorizar valores calculados
- memo: Para evitar renderizados innecesarios del componente
- useEffect: Para manejar efectos secundarios como la inicialización
Paso 2: Definición de Tipos
// Definición de tipos para el componente
// StepPoint representa un punto de paso en el slider, con un valor numérico y un indicador de si es un valor extremo
interface StepPoint {
value: number;
isExtreme: boolean;
}
// SliderProps define todas las propiedades que puede recibir nuestro componente Slider
interface SliderProps {
/** El valor máximo del slider */
value: number;
/** Indica si el slider debe soportar valores negativos */
negative?: boolean;
/** El tamaño del paso entre valores */
steps: number;
/** Ancho opcional del slider */
width?: string;
/** Función de callback cuando el valor cambia */
onChange?: (value: number) => void;
/** Valor inicial del slider */
defaultValue?: number;
/** Indica si el slider está deshabilitado */
disabled?: boolean;
/** Clase CSS personalizada para el contenedor del slider */
className?: string;
/** Función para formatear el valor mostrado */
formatValue?: (value: number) => string;
/** Modo de visibilidad del tooltip: 'always' | 'onInteraction' */
showTooltip?: 'always' | 'onInteraction';
}
- StepPoint: Define la estructura de cada punto de paso en el slider, con un valor numérico y un indicador de si es un valor extremo (mínimo o máximo).
- SliderProps: Define todas las propiedades que puede recibir nuestro componente Slider, con comentarios JSDoc para documentar cada propiedad.
Paso 3: Configuración de Props por Defecto
// Valores predeterminados para las propiedades opcionales
const defaultProps: Partial<SliderProps> = {
negative: false,
disabled: false,
showTooltip: 'onInteraction',
formatValue: (value: number) => value.toString(),
};
Paso 4: Implementación del Componente Principal
// Componente Slider principal
// Utilizamos memo para evitar renderizados innecesarios cuando las props no cambian
const Slider = memo(({
value,
negative = defaultProps.negative,
steps: initialSteps,
onChange,
defaultValue,
disabled = defaultProps.disabled,
className = "",
formatValue = defaultProps.formatValue,
showTooltip = defaultProps.showTooltip,
}: SliderProps) => {
// Component implementation
});
Paso 5: Validación de Props
// Validación de props para asegurar que los valores sean válidos
if (value <= 0) {
throw new Error("El valor del slider debe ser mayor que 0");
}
if (initialSteps <= 0) {
throw new Error("Los pasos del slider deben ser mayores que 0");
}
if (initialSteps > value) {
console.warn("Los pasos del slider no pueden ser mayores que el valor. Usando el valor como pasos.");
}
// Aseguramos que los pasos no sean mayores que el valor
const steps = Math.min(initialSteps, value);
Paso 6: Estado y Referencias
// Estado del componente
// mounted: indica si el componente está montado (para evitar problemas de hidratación)
// position: posición actual del thumb en porcentaje (0-100)
// currentValue: valor numérico actual del slider
// isDragging: indica si el usuario está arrastrando el thumb
// sliderRef: referencia al elemento DOM del slider
const [mounted, setMounted] = useState(false);
const [position, setPosition] = useState(0);
const [currentValue, setCurrentValue] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const sliderRef = useRef<HTMLDivElement>(null);
- mounted: Un estado booleano que indica si el componente está montado. Esto es útil para evitar problemas de hidratación en Next.js.
- position: La posición actual del thumb del slider en porcentaje (0-100).
- currentValue: El valor numérico actual del slider.
- isDragging: Un estado booleano que indica si el usuario está arrastrando el thumb del slider.
- sliderRef: Una referencia al elemento DOM del slider, que utilizamos para calcular posiciones relativas.
Paso 7: Inicialización del Estado
- Si negative es true, el rango va de -value a value, por lo que el valor 0 está en el medio (50%).
- Si negative es false, el rango va de 0 a value, por lo que el valor 0 está al principio (0%).
Paso 8: Cálculo de Puntos de Paso
// Cálculo de los puntos de paso
// Utilizamos useMemo para evitar recálculos innecesarios
const stepPoints = useMemo(() => {
const points: StepPoint[] = [];
if (negative) {
// Para valores negativos, generamos puntos desde -value hasta value
const totalSteps = Math.floor((value * 2) / steps);
// Punto mínimo (-value)
points.push({ value: -value, isExtreme: true });
// Puntos negativos intermedios
for (let i = 1; i < totalSteps / 2; i++) {
points.push({ value: -value + i * steps, isExtreme: false });
}
// Punto cero
points.push({ value: 0, isExtreme: true });
// Puntos positivos intermedios
for (let i = 1; i < totalSteps / 2; i++) {
points.push({ value: i * steps, isExtreme: false });
}
// Punto máximo (value)
points.push({ value: value, isExtreme: true });
} else {
// Para valores positivos, generamos puntos desde 0 hasta value
const totalSteps = Math.floor(value / steps);
// Punto mínimo (0)
points.push({ value: 0, isExtreme: true });
// Puntos intermedios
for (let i = 1; i < totalSteps; i++) {
points.push({ value: i * steps, isExtreme: false });
}
// Punto máximo (value)
points.push({ value: value, isExtreme: true });
}
return points;
}, [value, steps, negative]);
- Si negative es true, generamos puntos desde -value hasta value, con 0 en el medio.
- Si negative es false, generamos puntos desde 0 hasta value.
Paso 9: Lógica de Actualización de Posición
// Función para actualizar la posición del slider
// Se llama cuando el usuario interactúa con el slider
const updatePosition = useCallback((clientX: number) => {
// Si el slider está deshabilitado o no tenemos referencia al elemento DOM, no hacemos nada
if (!sliderRef.current || disabled) return;
// Calculamos la posición relativa del cursor dentro del slider
const rect = sliderRef.current.getBoundingClientRect();
const x = clientX - rect.left;
const rawPosition = (x / rect.width) * 100;
// Aseguramos que la posición esté entre 0 y 100
const clampedPosition = Math.max(0, Math.min(100, rawPosition));
// Calculamos el valor numérico correspondiente a esta posición
let newValue: number;
if (negative) {
// Para valores negativos, el rango va de -value a value
const range = value * 2;
newValue = -value + range * (clampedPosition / 100);
} else {
// Para valores positivos, el rango va de 0 a value
newValue = value * (clampedPosition / 100);
}
// Redondeamos el valor al paso más cercano
let roundedValue = Math.round(newValue / steps) * steps;
// Aseguramos que el valor esté dentro del rango permitido
if (negative) {
roundedValue = Math.max(-value, Math.min(value, roundedValue));
} else {
roundedValue = Math.max(0, Math.min(value, roundedValue));
}
// Calculamos la posición final del thumb basada en el valor redondeado
let newPosition: number;
if (negative) {
newPosition = ((roundedValue + value) / (value * 2)) * 100;
} else {
newPosition = (roundedValue / value) * 100;
}
// Actualizamos el estado y llamamos al callback onChange si está definido
setPosition(newPosition);
setCurrentValue(roundedValue);
onChange?.(roundedValue);
}, [negative, value, steps, disabled, onChange]);
- Primero, verificamos si el slider está deshabilitado o si no tenemos una referencia al elemento DOM.
- Calculamos la posición relativa del cursor dentro del slider.
- Convertimos esta posición a un porcentaje (0-100).
- Calculamos el valor numérico correspondiente a esta posición, teniendo en cuenta si el slider soporta valores negativos o no.
- Redondeamos el valor al paso más cercano.
- Aseguramos que el valor esté dentro del rango permitido.
- Calculamos la posición final del thumb basándose en el valor redondeado.
- Actualizamos el estado y llamamos al callback onChange si está definido.
Paso 10: Manejadores de Eventos
// Manejadores de eventos de mouse
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (disabled) return;
setIsDragging(true);
updatePosition(e.clientX);
}, [disabled, updatePosition]);
const handleMouseMove = useCallback((e: React.MouseEvent) => {
if (!isDragging || disabled) return;
updatePosition(e.clientX);
}, [isDragging, disabled, updatePosition]);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
// Manejadores de eventos táctiles
const handleTouchStart = useCallback((e: React.TouchEvent) => {
if (disabled) return;
setIsDragging(true);
updatePosition(e.touches[0].clientX);
}, [disabled, updatePosition]);
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (!isDragging || disabled) return;
updatePosition(e.touches[0].clientX);
}, [isDragging, disabled, updatePosition]);
const handleTouchEnd = useCallback(() => {
setIsDragging(false);
}, []);
// Manejador de eventos de teclado
// Permite navegar por los valores usando las teclas de flecha
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (disabled) return;
const step = steps;
let newValue = currentValue;
switch (e.key) {
case "ArrowRight":
case "ArrowUp":
// Incrementar el valor
newValue = Math.min(value, currentValue + step);
break;
case "ArrowLeft":
case "ArrowDown":
// Decrementar el valor
newValue = Math.max(negative ? -value : 0, currentValue - step);
break;
default:
return;
}
// Calculamos la nueva posición basada en el nuevo valor
const newPosition = negative
? ((newValue + value) / (value * 2)) * 100
: (newValue / value) * 100;
// Actualizamos el estado y llamamos al callback onChange
setPosition(newPosition);
setCurrentValue(newValue);
onChange?.(newValue);
}, [currentValue, value, steps, negative, disabled, onChange]);
- Eventos de Mouse:
- handleMouseDown: Se llama cuando el usuario hace clic en el thumb del slider. Activa el modo de arrastre y actualiza la posición.
- handleMouseMove: Se llama cuando el usuario mueve el mouse mientras arrastra el thumb. Actualiza la posición si el modo de arrastre está activo.
- handleMouseUp: Se llama cuando el usuario suelta el botón del mouse. Desactiva el modo de arrastre.
- Eventos de Touch:
- handleTouchStart: Similar a handleMouseDown, pero para eventos táctiles.
- handleTouchMove: Similar a handleMouseMove, pero para eventos táctiles.
- handleTouchEnd: Similar a handleMouseUp, pero para eventos táctiles.
- Eventos de Teclado:
- handleKeyDown: Se llama cuando el usuario presiona una tecla mientras el slider tiene el foco. Permite navegar por los valores usando las teclas de flecha.
Paso 11: Función para Ajustar el Valor
// Función para incrementar o decrementar el valor
// Se llama cuando el usuario hace clic en los botones de flecha
const adjustValue = useCallback((increment: boolean) => {
if (disabled) return;
const step = steps;
let newValue = currentValue;
if (increment) {
// Incrementar el valor
newValue = Math.min(value, currentValue + step);
} else {
// Decrementar el valor
newValue = Math.max(negative ? -value : 0, currentValue - step);
}
// Calculamos la nueva posición basada en el nuevo valor
const newPosition = negative
? ((newValue + value) / (value * 2)) * 100
: (newValue / value) * 100;
// Actualizamos el estado y llamamos al callback onChange
setPosition(newPosition);
setCurrentValue(newValue);
onChange?.(newValue);
}, [currentValue, value, steps, negative, disabled, onChange]);
Paso 12: Renderizado del Componente
// Renderizado del componente
return (
// Contenedor principal del slider
// Utilizamos clases de Tailwind CSS para el estilo
<div className={`w-[calc(100%-40px)] h-[30px] overflow-visible my-6 ${className}`}>
<div className="relative w-full">
{/* Botón de flecha izquierda para decrementar el valor */}
<button
type="button"
className={`absolute left-0 top-1/2 transform -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 z-30 ${
disabled || (negative ? currentValue <= -value : currentValue <= 0) ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
}`}
onClick={() => adjustValue(false)}
disabled={disabled || (negative ? currentValue <= -value : currentValue <= 0)}
aria-label="Decrease value"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-700" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M12.707 5.293a1 1 0 010 1.414L9.414 10l3.293 3.293a1 1 0 01-1.414 1.414l-4-4a1 1 0 010-1.414l4-4a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</button>
{/* Contenedor del slider */}
<div
ref={sliderRef}
className="w-[calc(100%-72px)] h-[30px] overflow-visible mx-auto my-0 relative"
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onKeyDown={handleKeyDown}
>
{/* Pista del slider */}
<div
className={`w-full h-1.5 transform -translate-y-1/2 overflow-visible mx-0 my-[15px] rounded-sm bg-gray-400 z-20 border border-white absolute transition-colors duration-200 ${
disabled ? 'opacity-50' : ''
}`}
/>
{/* Marcas de paso */}
{mounted && stepPoints.map((point, index) => {
// Calculamos la posición de cada marca basada en su valor
const linePosition = negative
? ((point.value + value) / (value * 2)) * 100
: (point.value / value) * 100;
return (
<div
key={index}
className={`absolute h-${
point.isExtreme ? "[24px]" : "[12px]"
} w-[1px] bg-gray-400 transition-opacity duration-200 ${
disabled ? 'opacity-50' : ''
}`}
style={{
left: `${linePosition}%`,
top: point.isExtreme ? "3px" : "9px",
height: point.isExtreme ? "24px" : "12px",
transform: "translateX(-50%)",
zIndex: "10",
}}
/>
);
})}
{/* Tooltip que muestra el valor actual */}
{mounted && showTooltip && (
<div
className={`absolute bg-blue-500 text-white text-xs px-2 py-1 rounded transform -translate-x-1/2 -translate-y-full -mt-2 z-50 select-none transition-opacity duration-200 ${
showTooltip === 'always' || isDragging ? 'opacity-100' : 'opacity-0'
}`}
style={{ left: `${position}%` }}
>
{formatValue ? formatValue(currentValue) : currentValue.toString()}
<div className="absolute w-0 h-0 border-l-[6px] border-r-[6px] border-t-[6px] border-l-transparent border-r-transparent border-t-blue-500 left-1/2 transform -translate-x-1/2 bottom-[-6px]" />
</div>
)}
{/* Thumb del slider (el elemento que el usuario puede arrastrar) */}
{mounted && (
<div
className={`w-4 h-4 border-2 border-blue-300 z-50 overflow-hidden my-[7px] transform rounded-full bg-blue-400 absolute transition-all duration-200 ${
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-grab active:cursor-grabbing'
}`}
style={{
left: `${position}%`,
transform: `translateX(-50%) scale(${isDragging ? 1.2 : 1})`
}}
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
/>
)}
{/* Etiquetas que muestran los valores mínimo y máximo */}
{mounted && (
<div className="numbers w-[calc(100%+12px)] h-5 mt-[30px] ml-[-6px] absolute flex justify-between text-black text-xs">
{negative ? (
<>
<span>-{value}</span>
<span>0</span>
<span>{value}</span>
</>
) : (
<>
<span>0</span>
<span>{value}</span>
</>
)}
</div>
)}
</div>
{/* Botón de flecha derecha para incrementar el valor */}
<button
type="button"
className={`absolute right-0 top-1/2 transform -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full bg-gray-200 hover:bg-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 z-30 ${
disabled || currentValue >= value ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
}`}
onClick={() => adjustValue(true)}
disabled={disabled || currentValue >= value}
aria-label="Increase value"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-gray-700" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clipRule="evenodd" />
</svg>
</button>
</div>
</div>
);
- Contenedor Principal: Un div que contiene todo el componente, con clases para el ancho, alto y márgenes.
- Contenedor Relativo: Un div con posición relativa que contiene los botones de flecha y el slider.
- Botón de Flecha Izquierda: Un botón para decrementar el valor del slider. Se deshabilita cuando el valor actual es el mínimo.
- Contenedor del Slider: Un div que contiene la pista del slider, las marcas de paso, el tooltip, el thumb y las etiquetas.
- Pista del Slider: Un div que representa la pista del slider.
- Marcas de Paso: Se renderizan dinámicamente basándose en stepPoints.
- Tooltip: Muestra el valor actual y se puede configurar para estar siempre visible o solo durante la interacción.
- Thumb del Slider: El elemento que el usuario puede arrastrar para cambiar el valor.
- Etiquetas: Muestran los valores mínimo y máximo del slider.
- Botón de Flecha Derecha: Un botón para incrementar el valor del slider. Se deshabilita cuando el valor actual es el máximo.
Paso 13: Exportación del Componente
// Establecemos el nombre del componente para mejorar la depuración
Slider.displayName = 'Slider';
export default Slider;
Uso del Componente
import Slider from './components/slider.tsx/Slider';
function App() {
const handleChange = (value) => {
console.log('Slider value changed:', value);
};
return (
<div className="p-8">
<h1 className="text-2xl font-bold mb-4">Mi Slider Personalizado</h1>
<Slider
value={100}
steps={10}
negative={true}
onChange={handleChange}
defaultValue={0}
showTooltip="always"
formatValue={(value) => `${value}%`}
/>
</div>
);
}
Explicación Detallada de la Estructura HTML
- Estructura General:
- Utilizamos un contenedor principal con ancho calculado (w-[calc(100%-40px)]) para dejar espacio para los botones de flecha.
- El contenedor tiene overflow-visible para permitir que el tooltip y las etiquetas se muestren fuera del contenedor.
- Botones de Flecha:
- Los botones de flecha están posicionados absolutamente en los extremos del slider.
- Utilizamos SVG para los iconos de flecha, lo que proporciona una mejor calidad visual y escalabilidad.
- Los botones se deshabilitan cuando el valor actual es el mínimo o máximo, respectivamente.
- Contenedor del Slider:
- El contenedor del slider tiene un ancho calculado (w-[calc(100%-72px)]) para dejar espacio para los botones de flecha.
- Tiene overflow-visible para permitir que el tooltip y las etiquetas se muestren fuera del contenedor.
- Los eventos de mouse, touch y teclado se manejan en este contenedor.
- Pista del Slider:
- La pista del slider es un div con altura fija y bordes redondeados.
- Tiene un borde blanco para mejorar la visibilidad.
- Marcas de Paso:
- Las marcas de paso se renderizan dinámicamente basándose en stepPoints.
- Las marcas extremas (mínimo, cero y máximo) son más altas que las marcas intermedias.
- La posición de cada marca se calcula basándose en su valor.
- Tooltip:
- El tooltip muestra el valor actual del slider.
- Tiene un fondo azul y texto blanco para destacar.
- Incluye una flecha que apunta al thumb del slider.
- La opacidad del tooltip se controla basándose en showTooltip y isDragging.
- Thumb del Slider:
- El thumb es un div circular con un borde y un fondo.
- Su posición se controla mediante la propiedad left en el estilo.
- Se escala ligeramente cuando el usuario lo arrastra para proporcionar retroalimentación visual.
- Los eventos de mouse y touch se manejan en el thumb.
- Etiquetas:
- Las etiquetas muestran los valores mínimo y máximo del slider.
- Si el slider soporta valores negativos, también se muestra el valor cero en el medio.
Características del Componente
- Soporte para valores negativos: El componente puede mostrar un rango que incluye valores negativos, lo que es útil para aplicaciones que requieren seleccionar valores tanto positivos como negativos.
- Pasos personalizables: El componente permite definir el tamaño de los pasos entre valores, lo que es útil para aplicaciones que requieren una selección precisa.
- Tooltips: El componente muestra el valor actual en un tooltip que puede estar siempre visible o solo durante la interacción, lo que mejora la experiencia del usuario.
- Accesibilidad: El componente es compatible con navegación por teclado y lectores de pantalla, lo que lo hace accesible para todos los usuarios.
- Diseño responsivo: El componente se adapta al ancho del contenedor, lo que lo hace ideal para aplicaciones responsivas.
- Personalización visual: El componente acepta clases CSS personalizadas para estilizar el componente según las necesidades del usuario.
- Soporte para dispositivos táctiles: El componente funciona correctamente en dispositivos móviles, lo que lo hace ideal para aplicaciones multiplataforma.
Conclusión
-- ¿Quieres recibir mi newsletter? --
No te pierdas de aprender:
Mi newsletter mensual viene con una dosis de inspiración, recursos para descargar, consejos de desarrollo rápidos y los mismos recursos que aprendo.