ATOM
Components (registry)Forms

Slider

Drag a thumb along a track to set a value. Ranges, volume, and continuous settings.

Preview

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

PropTipoDefaultRango / opcionesWhatHow
minnumber00–10000 step 1 unitLower bound of the range.0 for percentages; set to business min for money/quantity. Default: 0.
maxnumber1001–10000 step 1 unitUpper bound of the range.100 for %; match the product ceiling. Must be > min. Default: 100.
stepnumber10.01–1000 step 0.01 unitSnap increment for keyboard and drag.1 for integers; 0.1/0.01 for fine controls. Default: 1.
disabledbooleanfalse`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>
ClasePropósito
sliderroot
slider--disabledmodifier
slider__rangeelement
slider__thumbelement
slider__thumb--draggingelement
slider__trackelement

Tokens resueltos

Valores finales tras seguir la cadena de tokens. Derivados del source: si un token cambia, esta tabla cambia sola.

VariantePropValorToken
allbg#fafafabackground
allborder2pxstroke.medium

Animaciones

PropiedadDuraciónEasing
box-shadowvar(--duration-150)var(--easing-in-out)
scalevar(--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

components-react
components/atoms/Slider.tsx
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

Webflowslider

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-visible on .slider__thumb:focus-visiblepseudo-class not a safe Designer variant — moved to head Custom Code
  • selector on .slider__thumb:active, .slider__thumb--draggingcompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • selector on .slider__thumb:focus-visiblecompound/descendant selector — moved to head Custom Code (Designer styles are single-class)
  • @media on (prefers-reduced-motion: reduce)not a Designer breakpoint — moved to head Custom Code block
  • --focus-ring-color on :roottoken not found in tokens-nested.json — resolve upstream or the declaration stays invalid on paste

Tras pegar: Create component → nombre Atom / Slider → Publish.

Componentes relacionados

On this page

Detalles

Publicado14 de mayo de 2026
Categoriaforms
Lectura...
Visitas...
Ayuda?Slack

Componente

Slider

Source

components-react / cssDisponible via MCP: atom_uikit_source("slider")
Abrir en Storybook