§ NOTA TÉCNICA · ACCESIBILIDAD

WCAG 2.2 AA en sliders custom React

Tres tools de aGo lab usan sliders custom en lugar del input range nativo: aGo Palette (HSL), aGo CSS Gradient (paradas de color) y aGo QR (corrección de error). Esta nota documenta cómo los hicimos accesibles según WCAG 2.2 AA, qué atributos ARIA importaron, cómo manejamos el teclado y dónde encontramos diferencias reales entre lectores de pantalla.

El contexto: por qué no usamos input range nativo

El input range del navegador es accesible por defecto. Funciona con teclado, con lectores de pantalla y respeta preferencias del sistema. Pero su apariencia visual es limitada y reescribir CSS para el thumb, el track y los focus rings termina rompiendo comportamientos que dábamos por hechos. En particular, los pseudo-elementos ::-webkit-slider-thumb y ::-moz-range-thumb tienen reglas distintas en cada navegador y no aceptan animaciones complejas sin trade-offs.

En aGo Palette el slider muestra un preview del color en el track según la posición. Eso no es trivial con input range porque el contenido del track debería ser un gradiente dinámico calculado en tiempo real. En aGo CSS Gradient cada parada del gradiente es un thumb arrastrable independiente con menú contextual. En aGo QR el slider de corrección de error muestra un preview del QR a la derecha mientras el usuario mueve. Cada uno empuja el control fuera del territorio cómodo del nativo.

Decidimos construir sliders custom con React, asumiendo el costo de hacer accesibilidad manual. La regla del proyecto es clara: un slider custom no entra a producción si no pasa teclado, ARIA, lectores de pantalla y prefers-reduced-motion.

Opciones evaluadas

Opción A · Input range nativo + CSS

Mantener el control nativo y estilizar con pseudo-elementos.

Pros: accesibilidad gratis, comportamiento conocido.

Contras: CSS condicional por navegador, no permite preview complejo en el track, focus ring difícil de armonizar con el resto del diseño.

Opción B · react-aria sliders

La librería de Adobe ofrece useSlider, useSliderThumb y manejo ARIA correcto.

Pros: excelente cobertura ARIA, gestos touch incluidos, mantenida.

Contras: bundle considerable, opinión fuerte sobre estilo y estructura. En tools muy específicas el costo de doblar la lib es alto.

Opción C · react-slider

Librería liviana con API simple y soporte de range.

Pros: bundle chico, API directa.

Contras: ARIA básico pero incompleto. No incluye Shift+Arrow para salto grande por defecto. Mantención esporádica.

Opción D · Custom con pointer + keyboard listener

Componente propio con onPointerDown, onKeyDown y atributos ARIA explícitos.

Pros: control total, integra perfecto con el design system, bundle mínimo.

Contras: tienes que implementar y testear ARIA, teclado y touch manualmente. Costo de testing significativo.

La decisión: custom con accesibilidad explícita

Elegimos la opción D. La razón es que las tres tools tienen sliders con comportamientos suficientemente distintos como para no encajar bien en react-aria (que asume estructura más rígida) y suficientemente exigentes en lo visual como para no caber en input range nativo. Construir el propio con cuidado, y testear con teclado + VoiceOver + NVDA antes de declarar listo, fue el camino más predecible.

En aGo lab este patrón se repite: cuando una lib externa cubre el 80% pero el 20% restante requiere fork o hack, suele rendir más escribir el componente desde cero copiando las decisiones correctas de la lib. La clave es no inventar la accesibilidad, sino seguir la spec ARIA y la W3C ARIA Authoring Practices al pie de la letra.

Implementación: código real

Este es el slider base de aGo Palette, simplificado. Soporta teclado completo, gesto pointer (touch y mouse unificados), atributos ARIA correctos y respeta prefers-reduced-motion vía CSS. El valor está controlado desde fuera para que el padre decida la fuente de verdad.

import { useCallback, useRef, KeyboardEvent, PointerEvent } from 'react';

interface SliderProps {
  value: number;
  min: number;
  max: number;
  step?: number;
  bigStep?: number;
  label: string;
  unit?: string;
  onChange: (next: number) => void;
}

export function Slider({
  value,
  min,
  max,
  step = 1,
  bigStep = 10,
  label,
  unit = '',
  onChange
}: SliderProps) {
  const trackRef = useRef<HTMLDivElement>(null);

  // Normaliza el valor al rango [min, max] y respeta el step.
  const clamp = useCallback(
    (n: number) => Math.max(min, Math.min(max, Math.round(n / step) * step)),
    [min, max, step]
  );

  // Calcula el valor desde una coordenada X del pointer relativa al track.
  const valueFromPointer = useCallback(
    (clientX: number): number => {
      if (!trackRef.current) return value;
      const rect = trackRef.current.getBoundingClientRect();
      const pct = (clientX - rect.left) / rect.width;
      const raw = min + pct * (max - min);
      return clamp(raw);
    },
    [value, min, max, clamp]
  );

  function handleKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    let next = value;

    switch (e.key) {
      case 'ArrowLeft':
      case 'ArrowDown':
        next = clamp(value - (e.shiftKey ? bigStep : step));
        break;
      case 'ArrowRight':
      case 'ArrowUp':
        next = clamp(value + (e.shiftKey ? bigStep : step));
        break;
      case 'Home':
        next = min;
        break;
      case 'End':
        next = max;
        break;
      case 'PageDown':
        next = clamp(value - bigStep);
        break;
      case 'PageUp':
        next = clamp(value + bigStep);
        break;
      default:
        return; // No prevenir comportamiento de otras teclas.
    }

    e.preventDefault();
    if (next !== value) onChange(next);
  }

  function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
    if (e.button !== 0 && e.pointerType !== 'touch') return;
    (e.target as HTMLElement).setPointerCapture(e.pointerId);

    const next = valueFromPointer(e.clientX);
    if (next !== value) onChange(next);

    function handleMove(ev: globalThis.PointerEvent) {
      const updated = valueFromPointer(ev.clientX);
      onChange(updated);
    }

    function handleUp() {
      window.removeEventListener('pointermove', handleMove);
      window.removeEventListener('pointerup', handleUp);
    }

    window.addEventListener('pointermove', handleMove);
    window.addEventListener('pointerup', handleUp);
  }

  const pct = ((value - min) / (max - min)) * 100;

  return (
    <div className="slider-wrap">
      <label className="slider-label" id={`label-${label}`}>
        {label}: <span aria-hidden="true">{value}{unit}</span>
      </label>
      <div
        ref={trackRef}
        className="slider-track"
        role="slider"
        tabIndex={0}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuenow={value}
        aria-valuetext={`${value}${unit}`}
        aria-labelledby={`label-${label}`}
        onKeyDown={handleKeyDown}
        onPointerDown={handlePointerDown}
      >
        <div className="slider-fill" style={{ width: `${pct}%` }} />
        <div className="slider-thumb" style={{ left: `${pct}%` }} aria-hidden="true" />
      </div>
    </div>
  );
}

Cuatro decisiones del código que conviene desglosar.

Una. El track tiene tabIndex=0 y role="slider". Esto lo hace foco-capturable por Tab y lo anuncia como slider a los lectores de pantalla. El thumb visual está aria-hidden="true" porque es solo decoración. El control accesible es el track entero, no el thumb.

Dos. El handler de teclado cubre Arrow, Shift+Arrow, Home, End, PageUp y PageDown. La W3C ARIA Authoring Practices recomienda todas estas teclas para sliders. Saltarse PageUp/PageDown es común y rompe la experiencia para usuarios que las esperan.

Tres. El gesto pointer usa setPointerCapture para que el arrastre siga funcionando aunque el cursor salga del track. Sin esto, soltar fuera del track no dispara pointerup y el arrastre se queda colgado.

Cuatro. aria-valuetext sirve para casos donde aria-valuenow sin unidad sería ambiguo. Para un slider de "saturación" leer "75" no es tan claro como "75 por ciento". El lector de pantalla prioriza valuetext cuando está presente.

Estilos accesibles: el CSS que no es opcional

El componente React de arriba va acompañado por estos estilos. Lo que no es decoración: focus ring visible, contraste suficiente y respeto por prefers-reduced-motion.

.slider-track {
  position: relative;
  height: 8px;
  background: var(--color-track-inactive, #d4d0c8);
  border-radius: 999px;
  cursor: pointer;
  touch-action: none; /* Evita scroll al arrastrar en touch. */
}

.slider-track:focus-visible {
  outline: 3px solid var(--color-focus, #ff7a3c);
  outline-offset: 4px;
}

.slider-fill {
  position: absolute;
  inset: 0 auto 0 0;
  background: var(--color-track-active, #0b1f33);
  border-radius: 999px;
  transition: width 120ms ease-out;
}

.slider-thumb {
  position: absolute;
  top: 50%;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--color-thumb, #ffffff);
  border: 2px solid var(--color-petrol, #0b1f33);
  transform: translate(-50%, -50%);
  transition: transform 120ms ease-out, box-shadow 120ms ease-out;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}

.slider-track:focus-visible .slider-thumb,
.slider-track:hover .slider-thumb {
  transform: translate(-50%, -50%) scale(1.1);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}

@media (prefers-reduced-motion: reduce) {
  .slider-fill,
  .slider-thumb {
    transition: none;
  }
}

touch-action: none en el track impide que el navegador interprete el arrastre como scroll. Sin esa línea, mover el slider en móvil empuja la página y la interacción se rompe. El focus-visible asegura que el ring solo aparezca cuando el foco viene de teclado (no de mouse), lo que evita ruido visual sin sacrificar accesibilidad.

Limitaciones honestas que descubrimos

1. VoiceOver iOS y NVDA Windows leen distinto

VoiceOver lee "75 por ciento, slider" cuando el thumb se ajusta con swipe vertical. NVDA lee "Saturación: 75, control deslizante" con un fraseo diferente. Ambos correctos, pero si tu QA depende de una transcripción exacta del audio, prepárate para escribir tests que reconozcan ambas variantes. Lo importante es que ambos identifican el control como slider y comunican el valor.

2. El thumb chico es difícil de capturar en mobile small

WCAG 2.2 introduce el criterio 2.5.8 Target Size (Minimum) que exige 24x24 CSS pixels para targets táctiles. Nuestros thumbs miden 22px en diseño y el track entero es capturable. Eso técnicamente cumple porque el área receptiva supera 24x24, pero en la práctica un thumb de 22px sobre un track de 8px se siente justo. Aumentamos a 24px en v2 y el feedback de usuarios mejoró notablemente.

3. prefers-reduced-motion requiere CSS extra, no JS

La animación del thumb al moverse, el rebote suave del fill al cambiar, el ripple visual al hacer focus: todo eso necesita reglas explícitas dentro de @media (prefers-reduced-motion: reduce). Olvidarlo es la causa principal de violación del criterio 2.3.3. Recomendamos un grep periódico del repo buscando transition: sin equivalente reduced-motion.

4. Touch en iPad Safari tiene su propio drama

En iPad con Safari, el evento pointermove a veces no se dispara con la latencia esperada si el track está dentro de un contenedor con overflow: scroll. Solucionamos con touch-action: none en el track y overscroll-behavior: contain en el contenedor padre.

5. Doble slider para rango no es trivial

El slider mostrado arriba es de valor único. Para un rango (dos thumbs), el patrón ARIA recomendado es usar dos sliders independientes, cada uno con su rol y su aria-valuemin/max ajustado dinámicamente para no cruzarse. En aGo lab solo necesitamos rango en aGo CSS Gradient y allí el control real es la parada del gradiente, no un slider de rango clásico, así que lo evitamos.

6. Anuncios de cambio: aria-live opcional

Algunos lectores anuncian el valor cada vez que cambia. Otros lo anuncian solo al perder foco. Si tu tool depende de que el usuario escuche cada cambio (raro), agrega un aria-live="polite" en un live region separado donde anuncies el valor manualmente. En aGo lab no lo hicimos porque el cambio visual del preview ya da feedback rico, y sobre-anunciar resulta cansador.

Cuándo NO usar slider custom

  • Si solo necesitas un control simple desktop Chrome. Input range nativo con CSS modesto cubre el caso. No te ganas nada construyendo desde cero.
  • Si tu equipo no tiene capacidad de QA con lectores de pantalla. Un slider custom sin testear con VoiceOver y NVDA termina siendo inaccesible aunque tenga todos los atributos ARIA correctos. La spec es necesaria pero no suficiente.
  • Si vas a tener docenas de sliders en la misma UI. A esa escala, una lib estable como react-aria amortiza su bundle y reduce inconsistencias. Construir 30 sliders distintos a mano es invitación al caos.
  • Si necesitas patrones de rango complejos. Dual thumb con constrain, stepper paralelo, marker labels: territorio donde lib madura ahorra trabajo.

Checklist de validación antes de mergear

  1. Tab lleva foco al slider y el ring es visible.
  2. Arrow Left/Right cambia el valor en step pequeño.
  3. Arrow Up/Down también cambia el valor.
  4. Shift+Arrow cambia el valor en step grande.
  5. Home va al valor mínimo, End al máximo.
  6. PageUp y PageDown saltan en step grande.
  7. VoiceOver iOS anuncia label, valor y rol.
  8. NVDA Windows anuncia label, valor y rol.
  9. TalkBack Android anuncia label, valor y rol.
  10. Touch en iOS y Android arrastra sin scroll de página.
  11. Contraste track activo vs inactivo igual o superior a 3:1.
  12. prefers-reduced-motion desactiva transiciones del thumb y del fill.
  13. El control recibe focus-visible y no focus visible para mouse-only.

Recursos consultados

  • W3C ARIA Authoring Practices Guide, patrón slider.
  • WCAG 2.2 criterios 2.1.1, 2.4.7, 2.5.8, 2.3.3, 1.4.11.
  • Adobe react-aria docs, sección useSlider.
  • Pruebas internas aGo lab con VoiceOver iOS 17, NVDA 2024, TalkBack Android 14.

¿Quieres probar un slider accesible?

aGo Palette, aGo CSS Gradient y aGo QR usan el componente descrito en esta nota.

Abrir aGo Palette

¿Necesitas accesibilidad WCAG 2.2 en tu producto?

aGo lab construye software con accesibilidad por defecto. Si tu equipo necesita auditoría o implementación accesible de componentes existentes, conversemos.

Conversemos

§ CÓMO CITAR ESTE ARTÍCULO

aGo lab. (2026). "WCAG en sliders React, accesibilidad real". tools.ago.cl/notas/wcag-sliders-react. Recuperado el 2026-05-25.

Esta nota es contenido propio de aGo lab y se publica bajo política de atribución requerida. Si la citas o referencias, incluye el link a la URL original.

§ aGo lab estudio

¿Quieres que aGo lab implemente esto para ti?

Si esta decisión técnica te resuelve un problema y necesitas aplicarla a un sistema propio, conversemos.

§ COMMAND

↑↓ navega · Enter abre · Esc cierra