APG Patterns
日本語
日本語

Slider

An interactive control that allows users to select a value from within a range.

Demo

Volume
Progress
Rating
Vertical
Disabled

Open demo only →

Native HTML

Use Native HTML First

Before using this custom component, consider using native <input type="range"> elements. They provide built-in keyboard support, work without JavaScript, and have native accessibility support.


          <label for="volume">Volume</label>
<input type="range" id="volume" min="0" max="100" value="50">
        

Use custom implementations only when you need custom styling that native elements cannot provide, or when you require specific visual feedback during interactions.

Use Case Native HTML Custom Implementation
Basic value selection Recommended Not needed
Keyboard support Built-in Manual implementation
JavaScript disabled support Works natively Requires fallback
Form integration Built-in Manual implementation
Custom styling Limited (pseudo-elements) Full control
Consistent cross-browser appearance Varies significantly Consistent
Vertical orientation Limited browser support Full control

Native <input type="range"> styling is notoriously inconsistent across browsers. Styling requires vendor-specific pseudo-elements (::-webkit-slider-thumb, ::-moz-range-thumb, etc.) which can be complex to maintain.

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
slider Thumb element Identifies the element as a slider that allows users to select a value from within a range

The slider role is used for interactive controls that let users select a value by moving a thumb along a track. Unlike the meter role, sliders are interactive and receive keyboard focus.

WAI-ARIA Properties

aria-valuenow (Required)

Indicates the current numeric value of the slider. Updated dynamically as the user changes the value.

Type Number
Required Yes
Range Must be between aria-valuemin and aria-valuemax

aria-valuemin (Required)

Specifies the minimum allowed value for the slider.

Type Number
Required Yes
Default 0

aria-valuemax (Required)

Specifies the maximum allowed value for the slider.

Type Number
Required Yes
Default 100

aria-valuetext

Provides a human-readable text alternative for the current value. Use when the numeric value alone doesn't convey sufficient meaning.

Type String
Required No (recommended when value needs context)
Example "50%", "Medium", "3 of 5 stars"

aria-orientation

Specifies the orientation of the slider. Only set to "vertical" for vertical sliders; omit for horizontal (default).

Type "horizontal" | "vertical"
Required No
Default horizontal (implicit)

aria-disabled

Indicates that the slider is disabled and not interactive.

Type true | undefined
Required No

Keyboard Support

Key Action
Right Arrow Increases the value by one step
Up Arrow Increases the value by one step
Left Arrow Decreases the value by one step
Down Arrow Decreases the value by one step
Home Sets the slider to its minimum value
End Sets the slider to its maximum value
Page Up Increases the value by a large step (default: step * 10)
Page Down Decreases the value by a large step (default: step * 10)

Accessible Naming

Sliders must have an accessible name. This can be provided through:

  • Visible label - Using the label prop to display a visible label
  • aria-label - Provides an invisible label for the slider
  • aria-labelledby - References an external element as the label

Pointer Interaction

This implementation supports mouse and touch interaction:

  • Click on track - Immediately moves the thumb to the clicked position
  • Drag thumb - Allows continuous adjustment while dragging
  • Pointer capture - Maintains interaction even when pointer moves outside the slider

Visual Design

This implementation follows WCAG guidelines for accessible visual design:

  • Focus indicator - Visible focus ring on the thumb element
  • Visual fill - Proportionally represents the current value
  • Hover states - Visual feedback on hover
  • Disabled state - Clear visual indication when slider is disabled
  • Forced colors mode - Uses system colors for accessibility in Windows High Contrast Mode

References

Source Code

Slider.tsx
import { clsx } from 'clsx';
import { useCallback, useId, useRef, useState } from 'react';

// Label: one of these required (exclusive)
type LabelProps =
  | { label: string; 'aria-label'?: never; 'aria-labelledby'?: never }
  | { label?: never; 'aria-label': string; 'aria-labelledby'?: never }
  | { label?: never; 'aria-label'?: never; 'aria-labelledby': string };

// ValueText: exclusive with format
type ValueTextProps =
  | { valueText: string; format?: never }
  | { valueText?: never; format?: string }
  | { valueText?: never; format?: never };

type SliderBaseProps = {
  defaultValue?: number;
  min?: number;
  max?: number;
  step?: number;
  largeStep?: number;
  orientation?: 'horizontal' | 'vertical';
  disabled?: boolean;
  showValue?: boolean;
  onValueChange?: (value: number) => void;
  className?: string;
  id?: string;
  'aria-describedby'?: string;
  'data-testid'?: string;
};

export type SliderProps = SliderBaseProps & LabelProps & ValueTextProps;

// Clamp value to min/max range
const clamp = (value: number, min: number, max: number): number => {
  return Math.min(max, Math.max(min, value));
};

// Round value to nearest step
const roundToStep = (value: number, step: number, min: number): number => {
  const steps = Math.round((value - min) / step);
  const result = min + steps * step;
  // Fix floating point precision issues
  const decimalPlaces = (step.toString().split('.')[1] || '').length;
  return Number(result.toFixed(decimalPlaces));
};

// Calculate percentage for visual position
const getPercent = (value: number, min: number, max: number): number => {
  if (max === min) return 0;
  return ((value - min) / (max - min)) * 100;
};

// Format value helper
const formatValueText = (
  value: number,
  formatStr: string | undefined,
  min: number,
  max: number
): string => {
  if (!formatStr) return String(value);
  return formatStr
    .replace('{value}', String(value))
    .replace('{min}', String(min))
    .replace('{max}', String(max));
};

export const Slider: React.FC<SliderProps> = ({
  defaultValue,
  min = 0,
  max = 100,
  step = 1,
  largeStep,
  orientation = 'horizontal',
  disabled = false,
  showValue = true,
  onValueChange,
  label,
  valueText,
  format,
  className,
  id,
  'aria-describedby': ariaDescribedby,
  'data-testid': dataTestId,
  ...rest
}) => {
  // Calculate initial value: defaultValue clamped and rounded to step
  const initialValue = clamp(roundToStep(defaultValue ?? min, step, min), min, max);
  const [value, setValue] = useState(initialValue);

  const thumbRef = useRef<HTMLDivElement>(null);
  const trackRef = useRef<HTMLDivElement>(null);
  const labelId = useId();
  const isVertical = orientation === 'vertical';
  const effectiveLargeStep = largeStep ?? step * 10;

  // Update value and call callback
  const updateValue = useCallback(
    (newValue: number) => {
      const clampedValue = clamp(roundToStep(newValue, step, min), min, max);
      if (clampedValue !== value) {
        setValue(clampedValue);
        onValueChange?.(clampedValue);
      }
    },
    [value, step, min, max, onValueChange]
  );

  // Calculate value from pointer position
  const getValueFromPointer = useCallback(
    (clientX: number, clientY: number) => {
      const track = trackRef.current;
      if (!track) return value;

      const rect = track.getBoundingClientRect();

      // Guard against zero-size track (e.g., in jsdom tests)
      if (rect.width === 0 && rect.height === 0) {
        return value;
      }

      let percent: number;

      if (isVertical) {
        // Vertical: top = max, bottom = min
        if (rect.height === 0) return value;
        percent = 1 - (clientY - rect.top) / rect.height;
      } else {
        // Horizontal: left = min, right = max
        if (rect.width === 0) return value;
        percent = (clientX - rect.left) / rect.width;
      }

      const rawValue = min + percent * (max - min);
      return clamp(roundToStep(rawValue, step, min), min, max);
    },
    [isVertical, min, max, step, value]
  );

  // Keyboard handler
  const handleKeyDown = useCallback(
    (event: React.KeyboardEvent) => {
      if (disabled) return;

      let newValue = value;

      switch (event.key) {
        case 'ArrowRight':
        case 'ArrowUp':
          newValue = value + step;
          break;
        case 'ArrowLeft':
        case 'ArrowDown':
          newValue = value - step;
          break;
        case 'Home':
          newValue = min;
          break;
        case 'End':
          newValue = max;
          break;
        case 'PageUp':
          newValue = value + effectiveLargeStep;
          break;
        case 'PageDown':
          newValue = value - effectiveLargeStep;
          break;
        default:
          return; // Don't prevent default for other keys
      }

      event.preventDefault();
      updateValue(newValue);
    },
    [value, step, min, max, effectiveLargeStep, disabled, updateValue]
  );

  // Track whether we're dragging (for environments without pointer capture support)
  const isDraggingRef = useRef(false);

  // Pointer handlers for drag
  const handlePointerDown = useCallback(
    (event: React.PointerEvent) => {
      if (disabled) return;

      event.preventDefault();
      const thumb = thumbRef.current;
      if (!thumb) return;

      // Use pointer capture if available
      if (typeof thumb.setPointerCapture === 'function') {
        thumb.setPointerCapture(event.pointerId);
      }
      isDraggingRef.current = true;
      thumb.focus();

      const newValue = getValueFromPointer(event.clientX, event.clientY);
      updateValue(newValue);
    },
    [disabled, getValueFromPointer, updateValue]
  );

  const handlePointerMove = useCallback(
    (event: React.PointerEvent) => {
      const thumb = thumbRef.current;
      if (!thumb) return;

      // Check pointer capture or fallback to dragging state
      const hasCapture =
        typeof thumb.hasPointerCapture === 'function'
          ? thumb.hasPointerCapture(event.pointerId)
          : isDraggingRef.current;

      if (!hasCapture) return;

      const newValue = getValueFromPointer(event.clientX, event.clientY);
      updateValue(newValue);
    },
    [getValueFromPointer, updateValue]
  );

  const handlePointerUp = useCallback((event: React.PointerEvent) => {
    const thumb = thumbRef.current;
    if (thumb && typeof thumb.releasePointerCapture === 'function') {
      try {
        thumb.releasePointerCapture(event.pointerId);
      } catch {
        // Ignore if pointer capture was not set
      }
    }
    isDraggingRef.current = false;
  }, []);

  // Track click handler
  const handleTrackClick = useCallback(
    (event: React.MouseEvent) => {
      if (disabled) return;

      // Ignore if already handled by thumb
      if (event.target === thumbRef.current) return;

      const newValue = getValueFromPointer(event.clientX, event.clientY);
      updateValue(newValue);
      thumbRef.current?.focus();
    },
    [disabled, getValueFromPointer, updateValue]
  );

  const percent = getPercent(value, min, max);

  // Determine aria-valuetext
  const ariaValueText =
    valueText ?? (format ? formatValueText(value, format, min, max) : undefined);

  // Determine display text
  const displayText = valueText ? valueText : formatValueText(value, format, min, max);

  // Determine aria-labelledby
  const ariaLabelledby = rest['aria-labelledby'] ?? (label ? labelId : undefined);

  return (
    <div
      className={clsx(
        'apg-slider',
        isVertical && 'apg-slider--vertical',
        disabled && 'apg-slider--disabled',
        className
      )}
    >
      {label && (
        <span id={labelId} className="apg-slider-label">
          {label}
        </span>
      )}
      {/* apg-slider-track capture child elements click events */}
      {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
      <div
        ref={trackRef}
        className="apg-slider-track"
        style={{ '--slider-position': `${percent}%` }}
        onClick={handleTrackClick}
      >
        <div className="apg-slider-fill" aria-hidden="true" />
        <div
          ref={thumbRef}
          role="slider"
          id={id}
          tabIndex={disabled ? -1 : 0}
          aria-valuenow={value}
          aria-valuemin={min}
          aria-valuemax={max}
          aria-valuetext={ariaValueText}
          aria-label={rest['aria-label']}
          aria-labelledby={ariaLabelledby}
          aria-orientation={isVertical ? 'vertical' : undefined}
          aria-disabled={disabled ? true : undefined}
          aria-describedby={ariaDescribedby}
          data-testid={dataTestId}
          className="apg-slider-thumb"
          onKeyDown={handleKeyDown}
          onPointerDown={handlePointerDown}
          onPointerMove={handlePointerMove}
          onPointerUp={handlePointerUp}
        />
      </div>
      {showValue && (
        <span className="apg-slider-value" aria-hidden="true">
          {displayText}
        </span>
      )}
    </div>
  );
};

Usage

Example
import { Slider } from './Slider';

function App() {
  return (
    <div>
      {/* Basic usage with aria-label */}
      <Slider defaultValue={50} aria-label="Volume" />

      {/* With visible label */}
      <Slider defaultValue={50} label="Volume" />

      {/* With format for display and aria-valuetext */}
      <Slider
        defaultValue={75}
        label="Progress"
        format="{value}%"
      />

      {/* Custom range with step */}
      <Slider
        defaultValue={3}
        min={1}
        max={5}
        step={1}
        label="Rating"
        format="{value} of {max}"
      />

      {/* Vertical slider */}
      <Slider
        defaultValue={50}
        label="Volume"
        orientation="vertical"
      />

      {/* With callback */}
      <Slider
        defaultValue={50}
        label="Value"
        onValueChange={(value) => console.log(value)}
      />
    </div>
  );
}

API

Prop Type Default Description
defaultValue number min Initial value of the slider
min number 0 Minimum value
max number 100 Maximum value
step number 1 Step increment for keyboard navigation
largeStep number step * 10 Large step for PageUp/PageDown
orientation 'horizontal' | 'vertical' 'horizontal' Slider orientation
disabled boolean false Whether the slider is disabled
showValue boolean true Whether to display the value text
label string - Visible label (also used as aria-labelledby)
valueText string - Human-readable value for aria-valuetext
format string - Format pattern for display and aria-valuetext (e.g., "{value}%", "{value} of {max}")
onValueChange (value: number) => void - Callback when value changes

One of label, aria-label, or aria-labelledby is required for accessibility.

Testing

Tests verify APG compliance for ARIA attributes, keyboard interactions, pointer operations, and accessibility requirements.

Test Categories

High Priority: ARIA Attributes

Test Description
role="slider" Element has the slider role
aria-valuenow Current value is correctly set and updated
aria-valuemin Minimum value is set
aria-valuemax Maximum value is set
aria-valuetext Human-readable text is set when provided
aria-disabled Disabled state is reflected when set

High Priority: Accessible Name

Test Description
aria-label Accessible name via aria-label attribute
aria-labelledby Accessible name via external element reference
visible label Visible label provides accessible name

High Priority: Keyboard Interaction

Test Description
Arrow Right/Up Increases value by one step
Arrow Left/Down Decreases value by one step
Home Sets value to minimum
End Sets value to maximum
Page Up/Down Increases/decreases value by large step
Boundary clamping Value does not exceed min/max limits
Disabled state Keyboard has no effect when disabled

High Priority: Focus Management

Test Description
tabindex="0" Thumb is focusable
tabindex="-1" Thumb is not focusable when disabled

High Priority: Orientation

Test Description
horizontal No aria-orientation for horizontal slider
vertical aria-orientation="vertical" is set

Medium Priority: Accessibility

Test Description
axe violations No accessibility violations detected by axe-core

Medium Priority: Edge Cases

Test Description
decimal values Handles decimal step values correctly
negative range Handles negative min/max ranges
clamp to min defaultValue below min is clamped to min
clamp to max defaultValue above max is clamped to max

Medium Priority: Callbacks

Test Description
onValueChange Callback is called with new value on change

Low Priority: HTML Attribute Inheritance

Test Description
className Custom class is applied to container
id ID attribute is set correctly
data-* Data attributes are passed through

Low Priority: Cross-framework Consistency

Test Description
All frameworks render sliders React, Vue, Svelte, Astro all render slider elements
Consistent ARIA attributes All frameworks have consistent aria-valuenow, aria-valuemin, aria-valuemax
Keyboard navigation All frameworks support Arrow keys, Home, End keyboard navigation

Example Test Code

The following is the actual E2E test file (e2e/slider.spec.ts).

e2e/slider.spec.ts
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

/**
 * E2E Tests for Slider Pattern
 *
 * An interactive control that allows users to select a value from within a range.
 * Uses role="slider" with aria-valuenow, aria-valuemin, and aria-valuemax.
 *
 * APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/slider/
 */

const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;

// ============================================
// Helper Functions
// ============================================

const getSlider = (page: import('@playwright/test').Page) => {
  return page.getByRole('slider');
};

const getSliderByLabel = (page: import('@playwright/test').Page, label: string) => {
  return page.getByRole('slider', { name: label });
};

// ============================================
// Framework-specific Tests
// ============================================

for (const framework of frameworks) {
  test.describe(`Slider (${framework})`, () => {
    test.beforeEach(async ({ page }) => {
      await page.goto(`patterns/slider/${framework}/demo/`);
      await getSlider(page).first().waitFor();

      // Wait for hydration - slider should have aria-valuenow
      const firstSlider = getSlider(page).first();
      await expect
        .poll(async () => {
          const valuenow = await firstSlider.getAttribute('aria-valuenow');
          return valuenow !== null;
        })
        .toBe(true);
    });

    // ------------------------------------------
    // 🔴 High Priority: APG ARIA Structure
    // ------------------------------------------
    test.describe('APG: ARIA Structure', () => {
      test('slider has role="slider"', async ({ page }) => {
        const slider = getSlider(page).first();
        await expect(slider).toHaveRole('slider');
      });

      test('slider has aria-valuenow', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        const valuenow = await slider.getAttribute('aria-valuenow');
        expect(valuenow).toBe('50');
      });

      test('slider has aria-valuemin', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        const valuemin = await slider.getAttribute('aria-valuemin');
        expect(valuemin).toBe('0');
      });

      test('slider has aria-valuemax', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        const valuemax = await slider.getAttribute('aria-valuemax');
        expect(valuemax).toBe('100');
      });

      test('slider with custom range has correct min/max', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Rating');
        await expect(slider).toHaveAttribute('aria-valuemin', '1');
        await expect(slider).toHaveAttribute('aria-valuemax', '5');
        await expect(slider).toHaveAttribute('aria-valuenow', '3');
      });

      test('slider has accessible name via label', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await expect(slider).toBeVisible();
      });

      test('slider has aria-valuetext when format is provided', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await expect(slider).toHaveAttribute('aria-valuetext', '50%');
      });

      test('rating slider has descriptive aria-valuetext', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Rating');
        await expect(slider).toHaveAttribute('aria-valuetext', '3 of 5');
      });

      test('vertical slider has aria-orientation="vertical"', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Vertical');
        await expect(slider).toHaveAttribute('aria-orientation', 'vertical');
      });

      test('disabled slider has aria-disabled="true"', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Disabled');
        await expect(slider).toHaveAttribute('aria-disabled', 'true');
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Keyboard Interaction
    // ------------------------------------------
    test.describe('APG: Keyboard Interaction', () => {
      test('ArrowRight increases value by step', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await page.keyboard.press('ArrowRight');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) + 1);
      });

      test('ArrowLeft decreases value by step', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await page.keyboard.press('ArrowLeft');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) - 1);
      });

      test('ArrowUp increases value by step', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await page.keyboard.press('ArrowUp');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) + 1);
      });

      test('ArrowDown decreases value by step', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await page.keyboard.press('ArrowDown');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) - 1);
      });

      test('Home sets value to minimum', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();

        await page.keyboard.press('Home');

        await expect(slider).toHaveAttribute('aria-valuenow', '0');
      });

      test('End sets value to maximum', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();

        await page.keyboard.press('End');

        await expect(slider).toHaveAttribute('aria-valuenow', '100');
      });

      test('PageUp increases value by large step', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await page.keyboard.press('PageUp');

        const newValue = await slider.getAttribute('aria-valuenow');
        // Large step is typically step * 10 = 10
        expect(Number(newValue)).toBe(Number(initialValue) + 10);
      });

      test('PageDown decreases value by large step', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await page.keyboard.press('PageDown');

        const newValue = await slider.getAttribute('aria-valuenow');
        // Large step is typically step * 10 = 10
        expect(Number(newValue)).toBe(Number(initialValue) - 10);
      });

      test('value does not exceed maximum', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();

        // Set to max first
        await page.keyboard.press('End');
        await expect(slider).toHaveAttribute('aria-valuenow', '100');

        // Try to go beyond max
        await page.keyboard.press('ArrowRight');
        await expect(slider).toHaveAttribute('aria-valuenow', '100');
      });

      test('value does not go below minimum', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();

        // Set to min first
        await page.keyboard.press('Home');
        await expect(slider).toHaveAttribute('aria-valuenow', '0');

        // Try to go below min
        await page.keyboard.press('ArrowLeft');
        await expect(slider).toHaveAttribute('aria-valuenow', '0');
      });

      test('Rating slider respects step of 1', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Rating');
        await slider.click();

        // Initial value might change due to click position, so use Home first
        await page.keyboard.press('Home');
        await expect(slider).toHaveAttribute('aria-valuenow', '1');

        await page.keyboard.press('ArrowRight');
        await expect(slider).toHaveAttribute('aria-valuenow', '2');

        await page.keyboard.press('End');
        await expect(slider).toHaveAttribute('aria-valuenow', '5');

        // Should not exceed max
        await page.keyboard.press('ArrowRight');
        await expect(slider).toHaveAttribute('aria-valuenow', '5');
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Vertical Slider Keyboard
    // ------------------------------------------
    test.describe('APG: Vertical Slider Keyboard', () => {
      test('ArrowUp increases value in vertical slider', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Vertical');
        await slider.click();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await page.keyboard.press('ArrowUp');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) + 1);
      });

      test('ArrowDown decreases value in vertical slider', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Vertical');
        await slider.click();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await page.keyboard.press('ArrowDown');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(Number(newValue)).toBe(Number(initialValue) - 1);
      });
    });

    // ------------------------------------------
    // 🔴 High Priority: Focus Management
    // ------------------------------------------
    test.describe('APG: Focus Management', () => {
      test('slider is focusable', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();
        await expect(slider).toBeFocused();
      });

      test('slider has tabindex="0"', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await expect(slider).toHaveAttribute('tabindex', '0');
      });

      test('disabled slider has tabindex="-1"', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Disabled');
        await expect(slider).toHaveAttribute('tabindex', '-1');
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: Disabled State
    // ------------------------------------------
    test.describe('Disabled State', () => {
      test('disabled slider does not change value on ArrowRight', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Disabled');

        // Force focus via JavaScript (click won't work on disabled)
        await slider.evaluate((el) => (el as HTMLElement).focus());

        const initialValue = await slider.getAttribute('aria-valuenow');
        await page.keyboard.press('ArrowRight');

        const newValue = await slider.getAttribute('aria-valuenow');
        expect(newValue).toBe(initialValue);
      });

      test('disabled slider does not change value on Home', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Disabled');

        // Force focus via JavaScript
        await slider.evaluate((el) => (el as HTMLElement).focus());

        await page.keyboard.press('Home');

        // Should still be 50 (default value)
        await expect(slider).toHaveAttribute('aria-valuenow', '50');
      });
    });

    // ------------------------------------------
    // 🟡 Medium Priority: aria-valuetext Updates
    // ------------------------------------------
    test.describe('aria-valuetext Updates', () => {
      test('aria-valuetext updates on value change', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Volume');
        await slider.click();

        // Use Home to reset to known value
        await page.keyboard.press('Home');
        await expect(slider).toHaveAttribute('aria-valuetext', '0%');

        await page.keyboard.press('ArrowRight');
        await expect(slider).toHaveAttribute('aria-valuetext', '1%');
      });

      test('Rating slider aria-valuetext updates correctly', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Rating');
        await slider.click();

        // Use Home to reset to known value (min=1)
        await page.keyboard.press('Home');
        await expect(slider).toHaveAttribute('aria-valuetext', '1 of 5');

        await page.keyboard.press('ArrowRight');
        await expect(slider).toHaveAttribute('aria-valuetext', '2 of 5');
      });
    });

    // ------------------------------------------
    // 🟢 Low Priority: Accessibility
    // ------------------------------------------
    test.describe('Accessibility', () => {
      test('has no axe-core violations', async ({ page }) => {
        const results = await new AxeBuilder({ page }).include('[role="slider"]').analyze();

        expect(results.violations).toEqual([]);
      });

      test('vertical slider has no axe-core violations', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Vertical');
        await slider.scrollIntoViewIfNeeded();

        const results = await new AxeBuilder({ page })
          .include('[aria-orientation="vertical"]')
          .analyze();

        expect(results.violations).toEqual([]);
      });

      test('disabled slider has no axe-core violations', async ({ page }) => {
        const slider = getSliderByLabel(page, 'Disabled');
        await slider.scrollIntoViewIfNeeded();

        const results = await new AxeBuilder({ page }).include('[aria-disabled="true"]').analyze();

        expect(results.violations).toEqual([]);
      });
    });
  });
}

// ============================================
// Cross-framework Consistency Tests
// ============================================

test.describe('Slider - Cross-framework Consistency', () => {
  test('all frameworks render sliders', async ({ page }) => {
    for (const framework of frameworks) {
      await page.goto(`patterns/slider/${framework}/demo/`);
      await getSlider(page).first().waitFor();

      const sliders = getSlider(page);
      const count = await sliders.count();
      expect(count).toBeGreaterThanOrEqual(4); // Volume, Rating, Vertical, Disabled
    }
  });

  test('all frameworks have consistent ARIA attributes', async ({ page }) => {
    test.setTimeout(60000);

    for (const framework of frameworks) {
      await page.goto(`patterns/slider/${framework}/demo/`);
      await getSlider(page).first().waitFor();

      // Wait for hydration
      await expect
        .poll(async () => {
          const valuenow = await getSlider(page).first().getAttribute('aria-valuenow');
          return valuenow !== null;
        })
        .toBe(true);

      // Check Volume slider
      const volumeSlider = getSliderByLabel(page, 'Volume');
      await expect(volumeSlider).toHaveAttribute('aria-valuenow', '50');
      await expect(volumeSlider).toHaveAttribute('aria-valuemin', '0');
      await expect(volumeSlider).toHaveAttribute('aria-valuemax', '100');
      await expect(volumeSlider).toHaveAttribute('aria-valuetext', '50%');

      // Check Rating slider
      const ratingSlider = getSliderByLabel(page, 'Rating');
      await expect(ratingSlider).toHaveAttribute('aria-valuenow', '3');
      await expect(ratingSlider).toHaveAttribute('aria-valuemin', '1');
      await expect(ratingSlider).toHaveAttribute('aria-valuemax', '5');

      // Check Vertical slider
      const verticalSlider = getSliderByLabel(page, 'Vertical');
      await expect(verticalSlider).toHaveAttribute('aria-orientation', 'vertical');

      // Check Disabled slider
      const disabledSlider = getSliderByLabel(page, 'Disabled');
      await expect(disabledSlider).toHaveAttribute('aria-disabled', 'true');
    }
  });

  test('all frameworks support keyboard navigation', async ({ page }) => {
    test.setTimeout(60000);

    for (const framework of frameworks) {
      await page.goto(`patterns/slider/${framework}/demo/`);
      await getSlider(page).first().waitFor();

      // Wait for hydration
      await expect
        .poll(async () => {
          const valuenow = await getSlider(page).first().getAttribute('aria-valuenow');
          return valuenow !== null;
        })
        .toBe(true);

      const slider = getSliderByLabel(page, 'Volume');
      await slider.click();

      // Test Home (to reset to known value after click)
      await page.keyboard.press('Home');
      await expect(slider).toHaveAttribute('aria-valuenow', '0');

      // Test ArrowRight
      await page.keyboard.press('ArrowRight');
      await expect(slider).toHaveAttribute('aria-valuenow', '1');

      // Test End
      await page.keyboard.press('End');
      await expect(slider).toHaveAttribute('aria-valuenow', '100');
    }
  });
});

Running Tests

# Run unit tests for Slider
npm run test -- slider

# Run E2E tests for Slider (all frameworks)
npm run test:e2e:pattern --pattern=slider

# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=slider
npm run test:e2e:vue:pattern --pattern=slider
npm run test:e2e:svelte:pattern --pattern=slider
npm run test:e2e:astro:pattern --pattern=slider

Testing Tools

Slider.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe } from 'jest-axe';
import { describe, expect, it, vi } from 'vitest';
import { Slider } from './Slider';

describe('Slider', () => {
  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('has role="slider"', () => {
      render(<Slider aria-label="Volume" />);
      expect(screen.getByRole('slider')).toBeInTheDocument();
    });

    it('has aria-valuenow set to current value', () => {
      render(<Slider defaultValue={50} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '50');
    });

    it('has aria-valuenow set to min when no defaultValue', () => {
      render(<Slider min={10} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '10');
    });

    it('has aria-valuemin set (default: 0)', () => {
      render(<Slider aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuemin', '0');
    });

    it('has aria-valuemax set (default: 100)', () => {
      render(<Slider aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuemax', '100');
    });

    it('has custom aria-valuemin when provided', () => {
      render(<Slider defaultValue={50} min={10} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuemin', '10');
    });

    it('has custom aria-valuemax when provided', () => {
      render(<Slider defaultValue={50} max={200} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuemax', '200');
    });

    it('has aria-valuetext when valueText provided', () => {
      render(<Slider defaultValue={75} valueText="75 percent" aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuetext', '75 percent');
    });

    it('does not have aria-valuetext when not provided', () => {
      render(<Slider defaultValue={75} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).not.toHaveAttribute('aria-valuetext');
    });

    it('uses format for aria-valuetext', () => {
      render(<Slider defaultValue={75} format="{value}%" aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuetext', '75%');
    });

    it('updates aria-valuetext on value change', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} format="{value}%" aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(slider).toHaveAttribute('aria-valuetext', '51%');
    });

    it('has aria-disabled="true" when disabled', () => {
      render(<Slider disabled aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-disabled', 'true');
    });

    it('does not have aria-disabled when not disabled', () => {
      render(<Slider aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).not.toHaveAttribute('aria-disabled');
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has accessible name via aria-label', () => {
      render(<Slider aria-label="Volume" />);
      expect(screen.getByRole('slider', { name: 'Volume' })).toBeInTheDocument();
    });

    it('has accessible name via aria-labelledby', () => {
      render(
        <>
          <span id="slider-label">Brightness</span>
          <Slider aria-labelledby="slider-label" />
        </>
      );
      expect(screen.getByRole('slider', { name: 'Brightness' })).toBeInTheDocument();
    });

    it('has accessible name via visible label', () => {
      render(<Slider label="Zoom Level" />);
      expect(screen.getByRole('slider', { name: 'Zoom Level' })).toBeInTheDocument();
    });
  });

  // 🔴 High Priority: Keyboard Interaction
  describe('Keyboard Interaction', () => {
    it('increases value by step on ArrowRight', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(slider).toHaveAttribute('aria-valuenow', '51');
    });

    it('decreases value by step on ArrowLeft', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowLeft}');

      expect(slider).toHaveAttribute('aria-valuenow', '49');
    });

    it('increases value by step on ArrowUp', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowUp}');

      expect(slider).toHaveAttribute('aria-valuenow', '51');
    });

    it('decreases value by step on ArrowDown', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowDown}');

      expect(slider).toHaveAttribute('aria-valuenow', '49');
    });

    it('sets min value on Home', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} min={0} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{Home}');

      expect(slider).toHaveAttribute('aria-valuenow', '0');
    });

    it('sets max value on End', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} max={100} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{End}');

      expect(slider).toHaveAttribute('aria-valuenow', '100');
    });

    it('increases value by large step on PageUp', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{PageUp}');

      expect(slider).toHaveAttribute('aria-valuenow', '60'); // default largeStep = step * 10
    });

    it('decreases value by large step on PageDown', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{PageDown}');

      expect(slider).toHaveAttribute('aria-valuenow', '40');
    });

    it('uses custom largeStep when provided', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={1} largeStep={20} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{PageUp}');

      expect(slider).toHaveAttribute('aria-valuenow', '70');
    });

    it('respects custom step value', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} step={5} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(slider).toHaveAttribute('aria-valuenow', '55');
    });

    it('does not exceed max on ArrowRight', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={100} max={100} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(slider).toHaveAttribute('aria-valuenow', '100');
    });

    it('does not go below min on ArrowLeft', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={0} min={0} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowLeft}');

      expect(slider).toHaveAttribute('aria-valuenow', '0');
    });

    it('does not change value when disabled', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} disabled aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      slider.focus();
      await user.keyboard('{ArrowRight}');

      expect(slider).toHaveAttribute('aria-valuenow', '50');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('has tabindex="0" on thumb', () => {
      render(<Slider aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('tabindex', '0');
    });

    it('has tabindex="-1" when disabled', () => {
      render(<Slider disabled aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('tabindex', '-1');
    });

    it('is focusable via Tab', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <Slider aria-label="Volume" />
          <button>After</button>
        </>
      );

      await user.tab(); // Focus "Before" button
      await user.tab(); // Focus slider

      expect(screen.getByRole('slider')).toHaveFocus();
    });

    it('is not focusable via Tab when disabled', async () => {
      const user = userEvent.setup();
      render(
        <>
          <button>Before</button>
          <Slider disabled aria-label="Volume" />
          <button>After</button>
        </>
      );

      await user.tab(); // Focus "Before" button
      await user.tab(); // Focus "After" button (skip slider)

      expect(screen.getByRole('button', { name: 'After' })).toHaveFocus();
    });
  });

  // 🔴 High Priority: Orientation
  describe('Orientation', () => {
    it('does not have aria-orientation for horizontal slider', () => {
      render(<Slider orientation="horizontal" aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).not.toHaveAttribute('aria-orientation');
    });

    it('has aria-orientation="vertical" for vertical slider', () => {
      render(<Slider orientation="vertical" aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-orientation', 'vertical');
    });

    it('keyboard works correctly for vertical slider', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={50} orientation="vertical" aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowUp}');

      expect(slider).toHaveAttribute('aria-valuenow', '51');
    });
  });

  // 🟡 Medium Priority: Accessibility
  describe('Accessibility', () => {
    it('has no axe violations', async () => {
      const { container } = render(<Slider aria-label="Volume" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with visible label', async () => {
      const { container } = render(<Slider label="Volume" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with aria-labelledby', async () => {
      const { container } = render(
        <>
          <span id="label">Volume</span>
          <Slider aria-labelledby="label" />
        </>
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations when disabled', async () => {
      const { container } = render(<Slider disabled aria-label="Volume" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations with valueText', async () => {
      const { container } = render(
        <Slider defaultValue={50} valueText="50%" aria-label="Volume" />
      );
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations at boundary values', async () => {
      const { container } = render(<Slider defaultValue={0} aria-label="Volume" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

    it('has no axe violations for vertical slider', async () => {
      const { container } = render(<Slider orientation="vertical" aria-label="Volume" />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });
  });

  // 🟡 Medium Priority: Callbacks
  describe('Callbacks', () => {
    it('calls onValueChange on keyboard interaction', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(<Slider defaultValue={50} onValueChange={handleChange} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(handleChange).toHaveBeenCalledWith(51);
    });

    it('calls onValueChange with correct value on Home', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(<Slider defaultValue={50} min={0} onValueChange={handleChange} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{Home}');

      expect(handleChange).toHaveBeenCalledWith(0);
    });

    it('does not call onValueChange when disabled', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <Slider defaultValue={50} disabled onValueChange={handleChange} aria-label="Volume" />
      );
      const slider = screen.getByRole('slider');

      slider.focus();
      await user.keyboard('{ArrowRight}');

      expect(handleChange).not.toHaveBeenCalled();
    });

    it('does not call onValueChange when value does not change', async () => {
      const handleChange = vi.fn();
      const user = userEvent.setup();
      render(
        <Slider defaultValue={100} max={100} onValueChange={handleChange} aria-label="Volume" />
      );
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(handleChange).not.toHaveBeenCalled();
    });
  });

  // 🟡 Medium Priority: Edge Cases
  describe('Edge Cases', () => {
    it('handles decimal step values correctly', async () => {
      const user = userEvent.setup();
      render(<Slider defaultValue={0.5} min={0} max={1} step={0.1} aria-label="Volume" />);
      const slider = screen.getByRole('slider');

      await user.click(slider);
      await user.keyboard('{ArrowRight}');

      expect(slider).toHaveAttribute('aria-valuenow', '0.6');
    });

    it('handles negative min/max range', () => {
      render(<Slider defaultValue={0} min={-50} max={50} aria-label="Temperature" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '0');
      expect(slider).toHaveAttribute('aria-valuemin', '-50');
      expect(slider).toHaveAttribute('aria-valuemax', '50');
    });

    it('clamps defaultValue to min', () => {
      render(<Slider defaultValue={-10} min={0} max={100} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '0');
    });

    it('clamps defaultValue to max', () => {
      render(<Slider defaultValue={150} min={0} max={100} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '100');
    });

    it('rounds value to step', () => {
      render(<Slider defaultValue={53} min={0} max={100} step={5} aria-label="Volume" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-valuenow', '55');
    });
  });

  // 🟡 Medium Priority: Visual Display
  describe('Visual Display', () => {
    it('shows value when showValue is true (default)', () => {
      render(<Slider defaultValue={75} aria-label="Volume" />);
      expect(screen.getByText('75')).toBeInTheDocument();
    });

    it('hides value when showValue is false', () => {
      render(<Slider defaultValue={75} aria-label="Volume" showValue={false} />);
      expect(screen.queryByText('75')).not.toBeInTheDocument();
    });

    it('displays formatted value when format provided', () => {
      render(<Slider defaultValue={75} format="{value}%" aria-label="Volume" />);
      expect(screen.getByText('75%')).toBeInTheDocument();
    });

    it('displays visible label when label provided', () => {
      render(<Slider label="Volume" />);
      expect(screen.getByText('Volume')).toBeInTheDocument();
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attribute Inheritance', () => {
    it('applies className to container', () => {
      render(<Slider aria-label="Volume" className="custom-slider" />);
      const container = screen.getByRole('slider').closest('.apg-slider');
      expect(container).toHaveClass('custom-slider');
    });

    it('sets id attribute on slider element', () => {
      render(<Slider aria-label="Volume" id="my-slider" />);
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('id', 'my-slider');
    });

    it('passes through data-* attributes', () => {
      render(<Slider aria-label="Volume" data-testid="custom-slider" />);
      expect(screen.getByTestId('custom-slider')).toBeInTheDocument();
    });

    it('supports aria-describedby', () => {
      render(
        <>
          <Slider aria-label="Volume" aria-describedby="desc" />
          <p id="desc">Adjust the volume level</p>
        </>
      );
      const slider = screen.getByRole('slider');
      expect(slider).toHaveAttribute('aria-describedby', 'desc');
    });
  });
});

Resources