Slider
Um controle de entrada deslizante para selecionar valores numéricos ao longo de uma trilha, fornecendo uma maneira visual e interativa de escolher um valor dentro de um limite mínimo/máximo definido.
Busque em todas as páginas da documentação
Um controle de entrada deslizante para selecionar valores numéricos ao longo de uma trilha, fornecendo uma maneira visual e interativa de escolher um valor dentro de um limite mínimo/máximo definido.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
"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"
/>
);
}Um slider de intervalo mínimo usando o <input type="range"> nativo. A classe appearance-none remove a estilização padrão do navegador, e accent-blue-600 colore o polegar e a porção preenchida da trilha em navegadores compatíveis. O callback onChange converte o valor da string em um número antes de passá-lo para o componente pai.
"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>
);
}Os valores mínimo e máximo são exibidos abaixo da trilha do slider usando flex justify-between. Isso dá ao usuário um contexto claro sobre os limites do intervalo sem a necessidade de adivinhar.
"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>
);
}As marcas de passo são geradas a partir da prop step e posicionadas absolutamente usando um deslocamento percentual. Cada marca é centralizada sobre sua posição com -translate-x-1/2. O atributo step no input garante que o polegar "grude" nesses valores discretos.
"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">
{/* Track background */}
<div className="h-2 w-full rounded-full bg-gray-200" />
{/* Active range */}
<div
className="absolute top-0 h-2 rounded-full bg-blue-600"
style={{
left: `${getPercent(values[0])}%`,
width: `${getPercent(values[1]) - getPercent(values[0])}%`,
}}
/>
{/* Min thumb */}
<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 }}
/>
{/* Max thumb */}
<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 }}
/>
{/* Value labels */}
<div className="mt-4 flex justify-between text-sm text-gray-600">
<span>{values[0]}</span>
<span>{values[1]}</span>
</div>
</div>
);
}Dois inputs deslizantes sobrepostos criam um slider de dois polegares. Os inputs são definidos como pointer-events-none com apenas os polegares recebendo pointer-events-auto, para que o usuário possa arrastar cada um independentemente. O segmento de trilha ativo entre os polegares é um div posicionado absolutamente com valores dinâmicos de left e width. O z-index é ajustado com base em qual polegar está sendo arrastado para evitar que o polegar errado capture o evento quando eles se sobrepõem.
"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>
);
}O valor atual é exibido como um selo no canto superior direito, atualizando em tempo real conforme o usuário arrasta o polegar. A prop unit (por exemplo, "px", "%", "ms") anexa um sufixo de unidade ao valor exibido para clareza.
"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>
);
}As propriedades CSS writing-mode: vertical-lr e direction: rtl rotacionam o input deslizante nativo para uma orientação vertical. A direction: rtl garante que o valor mínimo esteja na parte inferior e o máximo na parte superior. Essa abordagem evita transformações CSS que podem quebrar as coordenadas de clique.
"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">
{/* Custom track fill */}
<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}%` }}
/>
{/* Tooltip */}
{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>
);
});Principais aspectos:
isDragging), evitando poluição visual e fornecendo feedback preciso do valor durante a interação.aria-value -- aria-valuenow, aria-valuemin, aria-valuemax e aria-valuetext fornecem aos leitores de tela contexto completo sobre o estado atual do slider. O aria-valuetext inclui a unidade para anúncios legíveis por humanos.<output> -- a exibição do valor em tempo real usa o elemento semântico <output> com htmlFor vinculando-o ao slider, que os leitores de tela tratam como uma região ao vivo.onTouchStart e onTouchEnd rastreiam o estado de arrasto em dispositivos móveis, além dos eventos do mouse.onChange retorna uma string -- o evento onChange do input deslizante nativo retorna e.target.value como uma string. Sempre converta com Number() ou parseFloat() antes de usar o valor em cálculos.
Estilização do polegar entre navegadores -- WebKit, Firefox e Edge usam pseudo-elementos diferentes para o polegar do slider (::-webkit-slider-thumb, ::-moz-range-thumb). Os seletores arbitrários do Tailwind visam apenas o WebKit por padrão. Adicione estilos específicos para Firefox, se necessário.
Precisão de ponto flutuante do passo -- um step de 0.1 pode produzir valores como 0.30000000000000004 devido à matemática de ponto flutuante. Arredonde os valores exibidos com toFixed() e limite os valores armazenados à precisão desejada.
Orientação vertical não é padrão -- o truque writing-mode CSS para sliders verticais não funciona de forma consistente em todos os navegadores. Considere uma implementação totalmente personalizada com manipuladores de eventos de mouse/toque para um comportamento vertical confiável.
Conflitos de z-index de dois polegares -- quando dois inputs deslizantes se sobrepõem para um slider de intervalo, o input superior captura todos os eventos. Use pointer-events-none na trilha do input e pointer-events-auto apenas no polegar, além de z-index dinâmico no polegar ativo.
Sem onChange em alteração programática de valor -- definir o valor via estado não aciona o evento change nativo. Se você precisar sincronizar com ouvintes externos, dispare um evento sintético após a atualização.
Estilização desabilitada no input nativo -- a aparência desabilitada do input deslizante nativo varia muito entre os navegadores. Aplique suas próprias classes opacity e cursor-not-allowed no wrapper para um visual consistente.
Revisado por Chris St. John·Última atualização: 7 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥