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 CaseNative HTMLCustom Implementation
Basic value selectionRecommendedNot needed
Keyboard supportBuilt-inManual implementation
JavaScript disabled supportWorks nativelyRequires fallback
Form integrationBuilt-inManual implementation
Custom stylingLimited (pseudo-elements)Full control
Consistent cross-browser appearanceVaries significantlyConsistent
Vertical orientationSupported in modern browsers (fallback for older)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

WAI-ARIA Properties

aria-valuenow

Updated dynamically when value changes via keyboard or pointer

Values
Number
Required
Yes

aria-valuemin

Default: 0

Values
Number
Required
Yes

aria-valuemax

Default: 100

Values
Number
Required
Yes

aria-valuetext

Example: “50%”, “Medium”, “3 of 5 stars”

Values
String
Required
No (recommended when value needs context)

aria-orientation

Default: horizontal (implicit). Only set for vertical sliders.

Values
horizontal | vertical
Required
No

aria-disabled

Set only when disabled

Values
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)
  • Sliders must have an accessible name via visible label (recommended), aria-label, or aria-labelledby.
  • This implementation follows WCAG guidelines for accessible visual design including focus indicators, visual fill, and hover states.
  • Pointer interaction: Click on track moves thumb immediately; drag allows continuous adjustment.
  • Forced colors mode uses system colors for Windows High Contrast Mode accessibility.

Implementation Notes

Structure:
<div class="slider-container">
  <label id="label">Volume</label>
  <div class="slider-track">
    <div class="slider-fill" />
    <div
      role="slider"
      tabindex="0"
      aria-valuenow="50"
      aria-valuemin="0"
      aria-valuemax="100"
      aria-labelledby="label"
      class="slider-thumb"
    />
  </div>
</div>

Visual Layout:
┌─────────────────────────────────────┐
│ Volume                               │
│ ├─────────●─────────────────────────┤
│ 0        50                     100  │
└─────────────────────────────────────┘

Keyboard Navigation:
← / ↓  = Decrease by step
→ / ↑  = Increase by step
Home   = Set to min
End    = Set to max
PgUp   = Increase by large step
PgDn   = Decrease by large step

Slider component structure and visual layout

References

Source Code

Slider.astro
---
/**
 * APG Slider Pattern - Astro Implementation
 *
 * A control that allows users to select a value from within a range.
 * Uses Web Components for interactive behavior.
 *
 * @see https://www.w3.org/WAI/ARIA/apg/patterns/slider/
 */

export interface Props {
  /** Default value */
  defaultValue?: number;
  /** Minimum value (default: 0) */
  min?: number;
  /** Maximum value (default: 100) */
  max?: number;
  /** Step increment (default: 1) */
  step?: number;
  /** Large step for PageUp/PageDown */
  largeStep?: number;
  /** Slider orientation */
  orientation?: 'horizontal' | 'vertical';
  /** Whether slider is disabled */
  disabled?: boolean;
  /** Show value text (default: true) */
  showValue?: boolean;
  /** Visible label text */
  label?: string;
  /** Human-readable value text for aria-valuetext */
  valueText?: string;
  /** Format pattern for dynamic value display (e.g., "{value}%", "{value} of {max}") */
  format?: string;
  /** Slider id */
  id?: string;
  /** Additional CSS class */
  class?: string;
  /** Accessible label when no visible label */
  'aria-label'?: string;
  /** Reference to external label element */
  'aria-labelledby'?: string;
  /** Reference to description element */
  'aria-describedby'?: string;
}

const {
  defaultValue,
  min = 0,
  max = 100,
  step = 1,
  largeStep,
  orientation = 'horizontal',
  disabled = false,
  showValue = true,
  label,
  valueText,
  format,
  id,
  class: className = '',
  'aria-label': ariaLabel,
  'aria-labelledby': ariaLabelledby,
  'aria-describedby': ariaDescribedby,
} = Astro.props;

// Utility functions
const clamp = (val: number, minVal: number, maxVal: number): number => {
  return Math.min(maxVal, Math.max(minVal, val));
};

const roundToStep = (val: number, stepVal: number, minVal: number): number => {
  const steps = Math.round((val - minVal) / stepVal);
  const result = minVal + steps * stepVal;
  const decimalPlaces = (stepVal.toString().split('.')[1] || '').length;
  return Number(result.toFixed(decimalPlaces));
};

// Calculate initial value
const initialValue = clamp(roundToStep(defaultValue ?? min, step, min), min, max);

// Calculate percentage for visual display
const percentage = max === min ? 0 : ((initialValue - min) / (max - min)) * 100;

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

// Display text
const displayText = valueText ?? formatValueText(initialValue, format);

// Initial aria-valuetext
const initialAriaValueText =
  valueText ?? (format ? formatValueText(initialValue, format) : undefined);

// Generate unique label ID
const labelId = label ? `slider-label-${Math.random().toString(36).slice(2, 9)}` : undefined;

const isVertical = orientation === 'vertical';
const effectiveLargeStep = largeStep ?? step * 10;
---

<apg-slider
  data-min={min}
  data-max={max}
  data-step={step}
  data-large-step={effectiveLargeStep}
  data-orientation={orientation}
  data-disabled={disabled}
  data-format={format}
>
  <div
    class={`apg-slider ${isVertical ? 'apg-slider--vertical' : ''} ${disabled ? 'apg-slider--disabled' : ''} ${className}`.trim()}
  >
    {
      label && (
        <span id={labelId} class="apg-slider-label">
          {label}
        </span>
      )
    }
    <div class="apg-slider-track" style={`--slider-position: ${percentage}%`}>
      <div class="apg-slider-fill" aria-hidden="true"></div>
      <div
        role="slider"
        id={id}
        tabindex={disabled ? -1 : 0}
        aria-valuenow={initialValue}
        aria-valuemin={min}
        aria-valuemax={max}
        aria-valuetext={initialAriaValueText}
        aria-label={label ? undefined : ariaLabel}
        aria-labelledby={ariaLabelledby ?? labelId}
        aria-orientation={isVertical ? 'vertical' : undefined}
        aria-disabled={disabled ? true : undefined}
        aria-describedby={ariaDescribedby}
        class="apg-slider-thumb"
      >
      </div>
    </div>
    {
      showValue && (
        <span class="apg-slider-value" aria-hidden="true">
          {displayText}
        </span>
      )
    }
  </div>
</apg-slider>

<script>
  class ApgSlider extends HTMLElement {
    private thumb: HTMLElement | null = null;
    private track: HTMLElement | null = null;
    private valueDisplay: HTMLElement | null = null;
    private isDragging = false;

    connectedCallback() {
      this.thumb = this.querySelector('[role="slider"]');
      this.track = this.querySelector('.apg-slider-track');
      this.valueDisplay = this.querySelector('.apg-slider-value');

      if (this.thumb) {
        this.thumb.addEventListener('keydown', this.handleKeyDown.bind(this));
        this.thumb.addEventListener('pointerdown', this.handlePointerDown.bind(this));
        this.thumb.addEventListener('pointermove', this.handlePointerMove.bind(this));
        this.thumb.addEventListener('pointerup', this.handlePointerUp.bind(this));
      }

      if (this.track) {
        this.track.addEventListener('click', this.handleTrackClick.bind(this));
      }
    }

    disconnectedCallback() {
      if (this.thumb) {
        this.thumb.removeEventListener('keydown', this.handleKeyDown.bind(this));
        this.thumb.removeEventListener('pointerdown', this.handlePointerDown.bind(this));
        this.thumb.removeEventListener('pointermove', this.handlePointerMove.bind(this));
        this.thumb.removeEventListener('pointerup', this.handlePointerUp.bind(this));
      }

      if (this.track) {
        this.track.removeEventListener('click', this.handleTrackClick.bind(this));
      }
    }

    private get min(): number {
      return Number(this.dataset.min) || 0;
    }

    private get max(): number {
      return Number(this.dataset.max) || 100;
    }

    private get step(): number {
      return Number(this.dataset.step) || 1;
    }

    private get largeStep(): number {
      return Number(this.dataset.largeStep) || this.step * 10;
    }

    private get isVertical(): boolean {
      return this.dataset.orientation === 'vertical';
    }

    private get isDisabled(): boolean {
      return this.dataset.disabled === 'true';
    }

    private get format(): string | undefined {
      return this.dataset.format;
    }

    private formatValue(value: number): string {
      const fmt = this.format;
      if (!fmt) return String(value);
      return fmt
        .replace('{value}', String(value))
        .replace('{min}', String(this.min))
        .replace('{max}', String(this.max));
    }

    private get currentValue(): number {
      return Number(this.thumb?.getAttribute('aria-valuenow')) || this.min;
    }

    private clamp(val: number): number {
      return Math.min(this.max, Math.max(this.min, val));
    }

    private roundToStep(val: number): number {
      const steps = Math.round((val - this.min) / this.step);
      const result = this.min + steps * this.step;
      const decimalPlaces = (this.step.toString().split('.')[1] || '').length;
      return Number(result.toFixed(decimalPlaces));
    }

    private updateValue(newValue: number) {
      if (!this.thumb || this.isDisabled) return;

      const clampedValue = this.clamp(this.roundToStep(newValue));
      const currentValue = this.currentValue;

      if (clampedValue === currentValue) return;

      // Update ARIA
      this.thumb.setAttribute('aria-valuenow', String(clampedValue));

      // Update aria-valuetext if format is provided
      const formattedValue = this.formatValue(clampedValue);
      if (this.format) {
        this.thumb.setAttribute('aria-valuetext', formattedValue);
      }

      // Update visual via CSS custom property
      const percentage = ((clampedValue - this.min) / (this.max - this.min)) * 100;

      if (this.track) {
        this.track.style.setProperty('--slider-position', `${percentage}%`);
      }

      if (this.valueDisplay) {
        this.valueDisplay.textContent = formattedValue;
      }

      // Dispatch event
      this.dispatchEvent(
        new CustomEvent('valuechange', {
          detail: { value: clampedValue },
          bubbles: true,
        })
      );
    }

    private getValueFromPointer(clientX: number, clientY: number): number {
      if (!this.track) return this.currentValue;

      const rect = this.track.getBoundingClientRect();

      if (rect.width === 0 && rect.height === 0) {
        return this.currentValue;
      }

      let percent: number;

      if (this.isVertical) {
        if (rect.height === 0) return this.currentValue;
        percent = 1 - (clientY - rect.top) / rect.height;
      } else {
        if (rect.width === 0) return this.currentValue;
        percent = (clientX - rect.left) / rect.width;
      }

      return this.min + percent * (this.max - this.min);
    }

    private handleKeyDown(event: KeyboardEvent) {
      if (this.isDisabled) return;

      let newValue = this.currentValue;

      switch (event.key) {
        case 'ArrowRight':
        case 'ArrowUp':
          newValue = this.currentValue + this.step;
          break;
        case 'ArrowLeft':
        case 'ArrowDown':
          newValue = this.currentValue - this.step;
          break;
        case 'Home':
          newValue = this.min;
          break;
        case 'End':
          newValue = this.max;
          break;
        case 'PageUp':
          newValue = this.currentValue + this.largeStep;
          break;
        case 'PageDown':
          newValue = this.currentValue - this.largeStep;
          break;
        default:
          return;
      }

      event.preventDefault();
      this.updateValue(newValue);
    }

    private handlePointerDown(event: PointerEvent) {
      if (this.isDisabled || !this.thumb) return;

      event.preventDefault();

      if (typeof this.thumb.setPointerCapture === 'function') {
        this.thumb.setPointerCapture(event.pointerId);
      }
      this.isDragging = true;
      this.thumb.focus();

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

    private handlePointerMove(event: PointerEvent) {
      if (!this.thumb) return;

      const hasCapture =
        typeof this.thumb.hasPointerCapture === 'function'
          ? this.thumb.hasPointerCapture(event.pointerId)
          : this.isDragging;

      if (!hasCapture) return;

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

    private handlePointerUp(event: PointerEvent) {
      if (this.thumb && typeof this.thumb.releasePointerCapture === 'function') {
        try {
          this.thumb.releasePointerCapture(event.pointerId);
        } catch {
          // Ignore
        }
      }
      this.isDragging = false;
    }

    private handleTrackClick(event: MouseEvent) {
      if (this.isDisabled || event.target === this.thumb) return;

      const newValue = this.getValueFromPointer(event.clientX, event.clientY);
      this.updateValue(newValue);
      this.thumb?.focus();
    }

    // Public method to update value programmatically
    setValue(newValue: number) {
      this.updateValue(newValue);
    }
  }

  if (!customElements.get('apg-slider')) {
    customElements.define('apg-slider', ApgSlider);
  }
</script>

Usage

Example
---
import Slider from './Slider.astro';
---

<!-- 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"
/>

<!-- Dynamic updates via Web Component API -->
<Slider defaultValue={50} id="my-slider" label="Volume" />
<script>
  const slider = document.querySelector('#my-slider').closest('apg-slider');
  slider.setValue(75);

  slider.addEventListener('valuechange', (e) => {
    console.log('Value changed:', e.detail.value);
  });
</script>

API

PropTypeDefaultDescription
defaultValuenumberminInitial value of the slider
minnumber0Minimum value
maxnumber100Maximum value
stepnumber1Step increment for keyboard navigation
largeStepnumberstep * 10Large step for PageUp/PageDown
orientation'horizontal' | 'vertical''horizontal'Slider orientation
disabledbooleanfalseWhether the slider is disabled
showValuebooleantrueWhether to display the value text
labelstring-Visible label (also used as aria-labelledby)
valueTextstring-Human-readable value for aria-valuetext (static)
formatstring-Format pattern for display and aria-valuetext (e.g., "{value}%", "{value} of {max}")
One of label, aria-label, or aria-labelledby is required for accessibility.

Custom Events

EventDetailDescription
valuechange{ value: number }Fired when value changes via keyboard, pointer, or setValue()

Testing

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

Test Categories

High Priority: ARIA Attributes

TestDescription
role="slider"Element has the slider role
aria-valuenowCurrent value is correctly set and updated
aria-valueminMinimum value is set
aria-valuemaxMaximum value is set
aria-valuetextHuman-readable text is set when provided
aria-disabledDisabled state is reflected when set

High Priority: Accessible Name

TestDescription
aria-labelAccessible name via aria-label attribute
aria-labelledbyAccessible name via external element reference
visible labelVisible label provides accessible name

High Priority: Keyboard Interaction

TestDescription
Arrow Right/UpIncreases value by one step
Arrow Left/DownDecreases value by one step
HomeSets value to minimum
EndSets value to maximum
Page Up/DownIncreases/decreases value by large step
Boundary clampingValue does not exceed min/max limits
Disabled stateKeyboard has no effect when disabled

High Priority: Focus Management

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

High Priority: Orientation

TestDescription
horizontalNo aria-orientation for horizontal slider
verticalaria-orientation="vertical" is set

Medium Priority: Accessibility

TestDescription
axe violationsNo accessibility violations detected by axe-core

Medium Priority: Edge Cases

TestDescription
decimal valuesHandles decimal step values correctly
negative rangeHandles negative min/max ranges
clamp to mindefaultValue below min is clamped to min
clamp to maxdefaultValue above max is clamped to max

Medium Priority: Callbacks

TestDescription
onValueChangeCallback is called with new value on change

Low Priority: HTML Attribute Inheritance

TestDescription
classNameCustom class is applied to container
idID attribute is set correctly
data-*Data attributes are passed through

Low Priority: Cross-framework Consistency

TestDescription
All frameworks render slidersReact, Vue, Svelte, Astro all render slider elements
Consistent ARIA attributesAll frameworks have consistent aria-valuenow, aria-valuemin, aria-valuemax
Keyboard navigationAll 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 });
};

// Wait for hydration to complete: Astro removes the `ssr` attribute from
// each <astro-island> once its component has hydrated. (aria-valuenow is
// already present in the SSR HTML, so it cannot be used as a signal.)
const waitForHydration = async (page: import('@playwright/test').Page) => {
  await expect(page.locator('astro-island[ssr]')).toHaveCount(0);
};

// ============================================
// 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();
      await waitForHydration(page);
    });

    // ------------------------------------------
    // 🔴 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 slider.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();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.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();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.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();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.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 expect(slider).toBeFocused();

        await slider.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 expect(slider).toBeFocused();

        await slider.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();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.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();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.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();
        await expect(slider).toBeFocused();

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

        // Try to go beyond max
        await slider.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();
        await expect(slider).toBeFocused();

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

        // Try to go below min
        await slider.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();
        await expect(slider).toBeFocused();

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

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

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

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

      test('ArrowRight increases a decimal step value without floating-point error', async ({
        page,
      }) => {
        const slider = getSliderByLabel(page, 'Opacity');
        await slider.focus();
        await expect(slider).toHaveAttribute('aria-valuenow', '0.5');

        await slider.press('ArrowRight');

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

    // ------------------------------------------
    // 🔴 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();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.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();
        await expect(slider).toBeFocused();

        const initialValue = await slider.getAttribute('aria-valuenow');
        await slider.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();
        await expect(slider).toBeFocused();

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

        await slider.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();
        await expect(slider).toBeFocused();

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

        await slider.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([]);
      });
    });
  });
}

// ============================================
// Astro-specific: valuechange CustomEvent
// ============================================

test.describe('Slider (astro) - valuechange event', () => {
  test.beforeEach(async ({ page }) => {
    await page.goto('patterns/slider/astro/demo/');
    await getSlider(page).first().waitFor();
    await page.evaluate(() => {
      const events: number[] = [];
      (window as unknown as { __valueChanges: number[] }).__valueChanges = events;
      document.addEventListener('valuechange', (e) => {
        events.push((e as CustomEvent<{ value: number }>).detail.value);
      });
    });
  });

  test('dispatches valuechange with the new value on ArrowRight', async ({ page }) => {
    const slider = getSliderByLabel(page, 'Volume');
    await slider.focus();
    await slider.press('ArrowRight');

    const events = await page.evaluate(
      () => (window as unknown as { __valueChanges: number[] }).__valueChanges
    );
    expect(events).toEqual([51]);
  });

  test('does not dispatch valuechange when the value does not change', async ({ page }) => {
    const slider = getSliderByLabel(page, 'Volume');
    await slider.focus();
    await slider.press('End');
    await slider.press('ArrowRight');

    const events = await page.evaluate(
      () => (window as unknown as { __valueChanges: number[] }).__valueChanges
    );
    expect(events).toEqual([100]);
  });
});

// ============================================
// 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();
      await waitForHydration(page);

      // 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();
      await waitForHydration(page);

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

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

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

      // Test End
      await slider.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

See the Testing Strategy guide for details.

Slider.test.astro.ts
/**
 * Slider Astro Component Tests using Container API
 *
 * These tests verify the initial server-rendered HTML of Slider.astro.
 * Web Component behavior (keyboard, pointer, value/valuetext updates and the
 * valuechange event) is covered by E2E tests in e2e/slider.spec.ts.
 *
 * @see https://docs.astro.build/en/reference/container-reference/
 */
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
import { describe, it, expect, beforeEach } from 'vitest';
import { JSDOM } from 'jsdom';
import Slider from './Slider.astro';

describe('Slider (Astro Container API)', () => {
  let container: AstroContainer;

  beforeEach(async () => {
    container = await AstroContainer.create();
  });

  async function renderSlider(props: Record<string, unknown> = {}): Promise<Document> {
    const html = await container.renderToString(Slider, { props });
    return new JSDOM(html).window.document;
  }

  // 🔴 High Priority: HTML Structure
  describe('HTML Structure', () => {
    it('renders apg-slider custom element wrapper', async () => {
      const doc = await renderSlider({ 'aria-label': 'Volume' });
      expect(doc.querySelector('apg-slider')).not.toBeNull();
    });

    it('passes range settings to the Web Component via data attributes', async () => {
      const doc = await renderSlider({ min: 10, max: 50, step: 2, 'aria-label': 'Volume' });
      const element = doc.querySelector('apg-slider');
      expect(element?.getAttribute('data-min')).toBe('10');
      expect(element?.getAttribute('data-max')).toBe('50');
      expect(element?.getAttribute('data-step')).toBe('2');
    });

    it('sets data-large-step to step * 10 when largeStep is not provided', async () => {
      const doc = await renderSlider({ step: 2, 'aria-label': 'Volume' });
      expect(doc.querySelector('apg-slider')?.getAttribute('data-large-step')).toBe('20');
    });

    it('sets data-large-step to largeStep when provided', async () => {
      const doc = await renderSlider({ largeStep: 5, 'aria-label': 'Volume' });
      expect(doc.querySelector('apg-slider')?.getAttribute('data-large-step')).toBe('5');
    });

    it('sets data-format when format is provided', async () => {
      const doc = await renderSlider({ format: '{value}%', 'aria-label': 'Volume' });
      expect(doc.querySelector('apg-slider')?.getAttribute('data-format')).toBe('{value}%');
    });

    it('sets the track position custom property from the initial value', async () => {
      const doc = await renderSlider({ defaultValue: 25, 'aria-label': 'Volume' });
      const track = doc.querySelector<HTMLElement>('.apg-slider-track');
      expect(track?.getAttribute('style')).toBe('--slider-position: 25%');
    });

    it('adds the vertical modifier class for vertical orientation', async () => {
      const doc = await renderSlider({ orientation: 'vertical', 'aria-label': 'Volume' });
      expect(doc.querySelector('.apg-slider')?.classList.contains('apg-slider--vertical')).toBe(
        true
      );
    });

    it('hides the fill element from assistive technology', async () => {
      const doc = await renderSlider({ 'aria-label': 'Volume' });
      expect(doc.querySelector('.apg-slider-fill')?.getAttribute('aria-hidden')).toBe('true');
    });
  });

  // 🔴 High Priority: ARIA Attributes
  describe('ARIA Attributes', () => {
    it('renders an element with role="slider"', async () => {
      const doc = await renderSlider({ 'aria-label': 'Volume' });
      expect(doc.querySelector('[role="slider"]')).not.toBeNull();
    });

    it('has aria-valuenow set to defaultValue', async () => {
      const doc = await renderSlider({ defaultValue: 50, 'aria-label': 'Volume' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuenow')).toBe('50');
    });

    it('has aria-valuenow set to min when no defaultValue is given', async () => {
      const doc = await renderSlider({ min: 10, 'aria-label': 'Volume' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuenow')).toBe('10');
    });

    it('has aria-valuemin="0" and aria-valuemax="100" by default', async () => {
      const doc = await renderSlider({ 'aria-label': 'Volume' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuemin')).toBe('0');
      expect(slider?.getAttribute('aria-valuemax')).toBe('100');
    });

    it('has aria-valuemin and aria-valuemax set to the provided range', async () => {
      const doc = await renderSlider({ min: 1, max: 5, 'aria-label': 'Rating' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuemin')).toBe('1');
      expect(slider?.getAttribute('aria-valuemax')).toBe('5');
    });

    it('has aria-valuetext when valueText is provided', async () => {
      const doc = await renderSlider({
        defaultValue: 75,
        valueText: '75 percent',
        'aria-label': 'Volume',
      });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuetext')).toBe('75 percent');
    });

    it('has aria-valuetext built from format with value, min and max', async () => {
      const doc = await renderSlider({
        defaultValue: 3,
        min: 1,
        max: 5,
        format: '{value} of {max}',
        'aria-label': 'Rating',
      });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuetext')).toBe('3 of 5');
    });

    it('does not have aria-valuetext when neither valueText nor format is provided', async () => {
      const doc = await renderSlider({ defaultValue: 75, 'aria-label': 'Volume' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.hasAttribute('aria-valuetext')).toBe(false);
    });

    it('has aria-disabled="true" when disabled', async () => {
      const doc = await renderSlider({ disabled: true, 'aria-label': 'Volume' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-disabled')).toBe('true');
    });

    it('does not have aria-disabled when not disabled', async () => {
      const doc = await renderSlider({ 'aria-label': 'Volume' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.hasAttribute('aria-disabled')).toBe(false);
    });

    it('has aria-orientation="vertical" for a vertical slider', async () => {
      const doc = await renderSlider({ orientation: 'vertical', 'aria-label': 'Volume' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-orientation')).toBe('vertical');
    });

    it('does not have aria-orientation for a horizontal slider', async () => {
      const doc = await renderSlider({ orientation: 'horizontal', 'aria-label': 'Volume' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.hasAttribute('aria-orientation')).toBe(false);
    });
  });

  // 🔴 High Priority: Accessible Name
  describe('Accessible Name', () => {
    it('has aria-label when provided', async () => {
      const doc = await renderSlider({ 'aria-label': 'Volume' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-label')).toBe('Volume');
    });

    it('has aria-labelledby when provided', async () => {
      const doc = await renderSlider({ 'aria-labelledby': 'external-label' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-labelledby')).toBe('external-label');
    });

    it('references the visible label via aria-labelledby when label is provided', async () => {
      const doc = await renderSlider({ label: 'Brightness' });
      const slider = doc.querySelector('[role="slider"]');
      const labelId = slider?.getAttribute('aria-labelledby') ?? '';
      expect(doc.getElementById(labelId)?.textContent?.trim()).toBe('Brightness');
    });

    it('does not set aria-label when a visible label is provided', async () => {
      const doc = await renderSlider({ label: 'Brightness', 'aria-label': 'Ignored' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.hasAttribute('aria-label')).toBe(false);
    });

    it('has aria-describedby when provided', async () => {
      const doc = await renderSlider({ 'aria-label': 'Volume', 'aria-describedby': 'volume-help' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-describedby')).toBe('volume-help');
    });
  });

  // 🔴 High Priority: Focus Management
  describe('Focus Management', () => {
    it('has tabindex="0" on the thumb', async () => {
      const doc = await renderSlider({ 'aria-label': 'Volume' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('tabindex')).toBe('0');
    });

    it('has tabindex="-1" on the thumb when disabled', async () => {
      const doc = await renderSlider({ disabled: true, 'aria-label': 'Volume' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('tabindex')).toBe('-1');
    });
  });

  // 🟡 Medium Priority: Initial Value Normalization
  describe('Initial Value Normalization', () => {
    it('clamps defaultValue below min to min', async () => {
      const doc = await renderSlider({
        defaultValue: -10,
        min: 0,
        max: 100,
        'aria-label': 'Volume',
      });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuenow')).toBe('0');
    });

    it('clamps defaultValue above max to max', async () => {
      const doc = await renderSlider({
        defaultValue: 150,
        min: 0,
        max: 100,
        'aria-label': 'Volume',
      });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuenow')).toBe('100');
    });

    it('rounds defaultValue to the nearest step', async () => {
      const doc = await renderSlider({ defaultValue: 53, step: 5, 'aria-label': 'Volume' });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuenow')).toBe('55');
    });

    it('rounds a decimal defaultValue without floating-point error', async () => {
      const doc = await renderSlider({
        defaultValue: 0.1 + 0.2,
        min: 0,
        max: 1,
        step: 0.1,
        'aria-label': 'Opacity',
      });
      const slider = doc.querySelector('[role="slider"]');
      expect(slider?.getAttribute('aria-valuenow')).toBe('0.3');
    });
  });

  // 🟡 Medium Priority: Visual Display
  describe('Visual Display', () => {
    it('shows the value when showValue is true', async () => {
      const doc = await renderSlider({ defaultValue: 75, showValue: true, 'aria-label': 'Volume' });
      expect(doc.querySelector('.apg-slider-value')?.textContent?.trim()).toBe('75');
    });

    it('shows the formatted value when format is provided', async () => {
      const doc = await renderSlider({
        defaultValue: 75,
        format: '{value}%',
        'aria-label': 'Volume',
      });
      expect(doc.querySelector('.apg-slider-value')?.textContent?.trim()).toBe('75%');
    });

    it('hides the value display from assistive technology', async () => {
      const doc = await renderSlider({ defaultValue: 75, 'aria-label': 'Volume' });
      expect(doc.querySelector('.apg-slider-value')?.getAttribute('aria-hidden')).toBe('true');
    });

    it('does not render the value when showValue is false', async () => {
      const doc = await renderSlider({
        defaultValue: 75,
        showValue: false,
        'aria-label': 'Volume',
      });
      expect(doc.querySelector('.apg-slider-value')).toBeNull();
    });

    it('renders the visible label text when label is provided', async () => {
      const doc = await renderSlider({ label: 'Volume' });
      expect(doc.querySelector('.apg-slider-label')?.textContent?.trim()).toBe('Volume');
    });
  });

  // 🟢 Low Priority: HTML Attribute Inheritance
  describe('HTML Attributes', () => {
    it('sets the id attribute on the thumb', async () => {
      const doc = await renderSlider({ id: 'my-slider', 'aria-label': 'Volume' });
      expect(doc.querySelector('[role="slider"]')?.id).toBe('my-slider');
    });

    it('applies a custom class to the container', async () => {
      const doc = await renderSlider({ class: 'custom-class', 'aria-label': 'Volume' });
      expect(doc.querySelector('.apg-slider')?.classList.contains('custom-class')).toBe(true);
    });
  });
});

Resources