Slider
Drag a thumb along a track to set a value. Ranges, volume, and continuous settings.
Editorial
<!-- F12c editorial — non-derivable only. Review: Karen. -->
## Ejemplos
Volume-style range:
```tsx import { Slider } from '@/components/atoms/Slider'; import { Field } from '@/components/atoms/Field';
<Field label="Volume"> <Slider min={0} max={100} step={5} value={volume} onValueChange={setVolume} /> </Field> ```
## Accesibilidad
- Expose the live value in nearby text or `aria-valuetext` when the unit is not obvious (% vs currency). - Keep `min` / `max` / `step` consistent with the visible unit; keyboard must still move the thumb.
### Correcto
- role='slider' en cada thumb con aria-valuemin, aria-valuemax, aria-valuenow - `tabIndex={0}` permite foco por teclado en los thumbs - `tabIndex={-1}` cuando disabled — remueve del tab order - aria-disabled en el thumb cuando disabled - RangeSlider: cada thumb es focusable independientemente - Drag previene text selection (userSelect='none' en body durante drag)
### Evitar
- No usar Slider sin label visible — combinar con Field o un `<label>` externo - No usar step demasiado pequeno en rangos grandes — arrow key navigation sera lenta - No olvidar touch-action: none en el root — sin esto, touch drag mueve la pagina
## Cuándo no usar
- Discrete few options (S/M/L) → `ToggleGroup` or `Select`. - Free numeric entry with high precision typing → `Input type="number"`.
## Criterio de uso
- Usa Slider para valores continuos o rangos donde explorar el espacio sea más rápido que escribir. - Define `min`, `max` y `step` con la unidad visible; para porcentajes, dinero o volumen muestra el valor actual junto al track. - Si el valor necesita precisión exacta, ofrece también un input numérico o usa directamente `Input type="number"`.
## Gotchas
- El teclado debe avanzar en el mismo incremento que el drag; un `step` que no coincide con la unidad mostrada confunde y puede impedir alcanzar el límite. - En multi-thumb, etiqueta cada thumb y evita que dos controles compartan un valor ambiguo. - **Ojo**: En vanilla, necesitas JS para calcular left% del thumb y width% del range. El CSS solo define el visual — la posicion es inline style. - **Ojo**: El CSS solo define el visual. Necesitas JS para: (1) calcular left% del thumb desde el valor, (2) actualizar width% del range, (3) manejar drag (mouse/touch), (4) keyboard navigation. Ver el componente React como referencia de la logica completa.
## Navegacion por teclado
| Tecla | Accion | | --- | --- | | Arrow Right / Up | Incrementa value por step | | Arrow Left / Down | Decrementa value por step | | Home | Salta al min | | End | Salta al max | | Tab | Mueve foco al siguiente thumb (RangeSlider) o siguiente elemento |
Uso
import { Slider } from '@/components/atoms/Slider';
<Slider min={0} max={100} step={5} value={volume} onValueChange={setVolume} />Props
| Prop | Tipo | Default | Rango / opciones | What | How |
|---|---|---|---|---|---|
| min | number | 0 | 0–10000 step 1 unit | Lower bound of the range. | 0 for percentages; set to business min for money/quantity. Default: 0. |
| max | number | 100 | 1–10000 step 1 unit | Upper bound of the range. | 100 for %; match the product ceiling. Must be > min. Default: 100. |
| step | number | 1 | 0.01–1000 step 0.01 unit | Snap increment for keyboard and drag. | 1 for integers; 0.1/0.01 for fine controls. Default: 1. |
| disabled | boolean | false | `true` / `false` | Blocks interaction. | true when prerequisites are unmet. Default: false. |
Gotchas
- a11y
Expose the value in text nearby or aria-valuetext; keep min/max/step consistent with the visible unit.
- react
Controlled: value + onValueChange; uncontrolled: defaultValue. Values are numbers (or number[] if multi-thumb patterns are used).
Anatomía CSS
<div class="slider"> <span class="slider__range"></span> <span class="slider__thumb"></span> <span class="slider__thumb--dragging"></span> <span class="slider__track"></span> </div>
| Clase | Propósito |
|---|---|
slider | root |
slider--disabled | modifier |
slider__range | element |
slider__thumb | element |
slider__thumb--dragging | element |
slider__track | element |
Tokens resueltos
Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.
| Variante | Prop | Valor | Token |
|---|---|---|---|
| all | bg | #fafafa | background |
| all | border | 2px | stroke.medium |
Animaciones
| Propiedad | Duración | Easing |
|---|---|---|
box-shadow | var(--duration-150) | var(--easing-in-out) |
scale | var(--duration-150) | var(--easing-in-out) |
CSS mínimo funcional
Autocontenido: sin imports ni tokens. Para previews y prototipos — en producción se consume el CSS del DS (@atom-uikit/css/components.css</code>, <code>@atom-uikit/tokens/tokens.css).
/* -------------------------------------------------------------------------
Slider
Range input. Drag thumb along track to select value.
Supports single value and range (two thumbs).
Parts: .slider, .slider__track, .slider__range, .slider__thumb
------------------------------------------------------------------------- */
/* ---- Root ---- */
.slider {
position: relative;
display: flex;
align-items: center;
width: 100%;
touch-action: none;
user-select: none;
cursor: pointer;
}
/* ---- Track ---- */
.slider__track {
position: relative;
width: 100%;
height: 8px;
border-radius: 9999px;
background-color: #f5f5f5;
overflow: hidden;
}
/* ---- Range (filled portion) ---- */
.slider__range {
position: absolute;
height: 100%;
background-color: #0a0a0a;
border-radius: 9999px;
}
/* ---- Thumb ---- */
.slider__thumb {
position: absolute;
top: 50%;
width: 20px;
height: 20px;
border-radius: 9999px;
border: 2px solid #0a0a0a;
background-color: #fafafa;
transform: translate(-50%, -50%);
cursor: grab;
transition:
box-shadow 150ms cubic-bezier(0.4, 0, 0.2, 1),
scale 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
.slider__thumb:hover {
box-shadow: 0 0 0 8px color-mix(in srgb, #0a0a0a 4%, transparent);
}
.slider__thumb:focus-visible {
outline: 2px solid var(--focus-ring-color);
outline-offset: 2px;
}
.slider__thumb:active,
.slider__thumb--dragging {
cursor: grabbing;
scale: 1.1;
}
/* ---- Disabled ---- */
.slider--disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* ---- Reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
.slider__thumb {
transition-duration: 0ms;
}
}
Codigo fuente
import { type KeyboardEvent, useState, useRef, useCallback, useEffect, } from 'react'; function cn(...classes: (string | false | undefined | null)[]) { return classes.filter(Boolean).join(' '); } function clamp(v: number, min: number, max: number) { return Math.min(Math.max(v, min), max); } function snap(v: number, step: number, min: number) { return Math.round((v - min) / step) * step + min; } function pct(v: number, min: number, max: number) { return ((v - min) / (max - min)) * 100; } /* ---- Single Slider ---- */ export type SliderProps = { value?: number; defaultValue?: number; onValueChange?: (value: number) => void; min?: number; max?: number; step?: number; disabled?: boolean; className?: string; }; export function Slider({ value: controlledValue, defaultValue = 0, onValueChange, min = 0, max = 100, step = 1, disabled = false, className, }: SliderProps) { const [internalValue, setInternalValue] = useState(defaultValue); const value = controlledValue ?? internalValue; const trackRef = useRef<HTMLDivElement>(null); const [dragging, setDragging] = useState(false); const setValue = useCallback( (v: number) => { const clamped = snap(clamp(v, min, max), step, min); onValueChange ? onValueChange(clamped) : setInternalValue(clamped); }, [min, max, step, onValueChange], ); const getValueFromPosition = useCallback( (clientX: number) => { if (!trackRef.current) return value; const rect = trackRef.current.getBoundingClientRect(); const ratio = clamp((clientX - rect.left) / rect.width, 0, 1); return min + ratio * (max - min); }, [min, max, value], ); const handlePointerDown = (clientX: number) => { if (disabled) return; setDragging(true); setValue(getValueFromPosition(clientX)); }; // Mouse const onMouseDown = (e: React.MouseEvent) => { handlePointerDown(e.clientX); const onMove = (ev: MouseEvent) => setValue(getValueFromPosition(ev.clientX)); const onUp = () => { setDragging(false); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }; // Touch const onTouchStart = (e: React.TouchEvent) => { handlePointerDown(e.touches[0].clientX); const onMove = (ev: TouchEvent) => setValue(getValueFromPosition(ev.touches[0].clientX)); const onEnd = () => { setDragging(false); document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onEnd); }; document.addEventListener('touchmove', onMove, { passive: true }); document.addEventListener('touchend', onEnd); }; // Keyboard const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => { if (disabled) return; if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); setValue(value + step); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); setValue(value - step); } else if (e.key === 'Home') { e.preventDefault(); setValue(min); } else if (e.key === 'End') { e.preventDefault(); setValue(max); } }; // Prevent text selection during drag useEffect(() => { if (!dragging) return; const prev = document.body.style.userSelect; document.body.style.userSelect = 'none'; return () => { document.body.style.userSelect = prev; }; }, [dragging]); const p = pct(value, min, max); return ( <div className={cn('slider', disabled && 'slider--disabled', className)} onMouseDown={onMouseDown} onTouchStart={onTouchStart} > <div ref={trackRef} className="slider__track"> <div className="slider__range" style={{ width: `${p}%` }} /> </div> <div role="slider" tabIndex={disabled ? -1 : 0} aria-valuemin={min} aria-valuemax={max} aria-valuenow={value} aria-disabled={disabled || undefined} className={cn('slider__thumb', dragging && 'slider__thumb--dragging')} style={{ left: `${p}%` }} onKeyDown={onKeyDown} /> </div> ); } /* ---- Range Slider ---- */ export type RangeSliderProps = { value?: [number, number]; defaultValue?: [number, number]; onValueChange?: (value: [number, number]) => void; min?: number; max?: number; step?: number; disabled?: boolean; className?: string; }; export function RangeSlider({ value: controlledValue, defaultValue = [25, 75], onValueChange, min = 0, max = 100, step = 1, disabled = false, className, }: RangeSliderProps) { const [internalValue, setInternalValue] = useState<[number, number]>(defaultValue); const value = controlledValue ?? internalValue; const trackRef = useRef<HTMLDivElement>(null); const [activeThumb, setActiveThumb] = useState<0 | 1 | null>(null); const setValue = useCallback( (v: [number, number]) => { const sorted: [number, number] = [ snap(clamp(v[0], min, max), step, min), snap(clamp(v[1], min, max), step, min), ]; if (sorted[0] > sorted[1]) [sorted[0], sorted[1]] = [sorted[1], sorted[0]]; onValueChange ? onValueChange(sorted) : setInternalValue(sorted); }, [min, max, step, onValueChange], ); const getValueFromPosition = useCallback( (clientX: number) => { if (!trackRef.current) return min; const rect = trackRef.current.getBoundingClientRect(); const ratio = clamp((clientX - rect.left) / rect.width, 0, 1); return min + ratio * (max - min); }, [min, max], ); const handleTrackDown = (clientX: number) => { if (disabled) return; const v = getValueFromPosition(clientX); const d0 = Math.abs(v - value[0]); const d1 = Math.abs(v - value[1]); const idx = d0 <= d1 ? 0 : 1; setActiveThumb(idx as 0 | 1); const next: [number, number] = [...value]; next[idx] = v; setValue(next); }; const onMouseDown = (e: React.MouseEvent) => { handleTrackDown(e.clientX); const onMove = (ev: MouseEvent) => { if (activeThumb === null) return; const v = getValueFromPosition(ev.clientX); const next: [number, number] = [...value]; next[activeThumb] = v; setValue(next); }; const onUp = () => { setActiveThumb(null); document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }; const onTouchStart = (e: React.TouchEvent) => { handleTrackDown(e.touches[0].clientX); const onMove = (ev: TouchEvent) => { if (activeThumb === null) return; const v = getValueFromPosition(ev.touches[0].clientX); const next: [number, number] = [...value]; next[activeThumb] = v; setValue(next); }; const onEnd = () => { setActiveThumb(null); document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onEnd); }; document.addEventListener('touchmove', onMove, { passive: true }); document.addEventListener('touchend', onEnd); }; const makeKeyDown = (idx: 0 | 1) => (e: KeyboardEvent<HTMLDivElement>) => { if (disabled) return; const next: [number, number] = [...value]; if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); next[idx] += step; } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); next[idx] -= step; } else if (e.key === 'Home') { e.preventDefault(); next[idx] = min; } else if (e.key === 'End') { e.preventDefault(); next[idx] = max; } else return; setValue(next); }; useEffect(() => { if (activeThumb === null) return; const prev = document.body.style.userSelect; document.body.style.userSelect = 'none'; return () => { document.body.style.userSelect = prev; }; }, [activeThumb]); const p0 = pct(value[0], min, max); const p1 = pct(value[1], min, max); return ( <div className={cn('slider', disabled && 'slider--disabled', className)} onMouseDown={onMouseDown} onTouchStart={onTouchStart} > <div ref={trackRef} className="slider__track"> <div className="slider__range" style={{ left: `${p0}%`, width: `${p1 - p0}%` }} /> </div> <div role="slider" tabIndex={disabled ? -1 : 0} aria-valuemin={min} aria-valuemax={max} aria-valuenow={value[0]} className={cn('slider__thumb', activeThumb === 0 && 'slider__thumb--dragging')} style={{ left: `${p0}%` }} onKeyDown={makeKeyDown(0)} /> <div role="slider" tabIndex={disabled ? -1 : 0} aria-valuemin={min} aria-valuemax={max} aria-valuenow={value[1]} className={cn('slider__thumb', activeThumb === 1 && 'slider__thumb--dragging')} style={{ left: `${p1}%` }} onKeyDown={makeKeyDown(1)} /> </div> ); }
Webflow
Pega en el Designer como application/json, luego convierte a Component (Atom / Slider) y publica. Formato interno no documentado de Webflow — regenerable, no dependencia de runtime.
Setup del sitio (una vez)
Custom Code → Head:
<link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/tokens.css"> <link rel="stylesheet" href="https://atom-web-ds.vercel.app/v1/components.css">
Unsupported (no silencioso)
:focus-visibleon.slider__thumb:focus-visible— pseudo-class not a safe Designer variant — moved to head Custom Codeselectoron.slider__thumb:active, .slider__thumb--dragging— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)selectoron.slider__thumb:focus-visible— compound/descendant selector — moved to head Custom Code (Designer styles are single-class)@mediaon(prefers-reduced-motion: reduce)— not a Designer breakpoint — moved to head Custom Code block--focus-ring-coloron:root— token not found in tokens-nested.json — resolve upstream or the declaration stays invalid on paste
Tras pegar: Create component → nombre Atom / Slider → Publish.