Slider (Multi-Thumb)
A slider with two thumbs that allows users to select a range of values within a given range. This Astro implementation uses Web Components for client-side interactivity.
Demo
Accessibility Features
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
slider | Lower thumb element | Identifies the element as a slider for selecting the lower bound of the range. |
slider | Upper thumb element | Identifies the element as a slider for selecting the upper bound of the range. |
group | Container element | Groups the two sliders together and associates them with a common label. |
Each thumb is an independent slider element with its own ARIA attributes. The group role establishes the semantic relationship between the two sliders.
WAI-ARIA Properties
aria-valuenow (Required)
Indicates the current numeric value of each thumb. Updated dynamically as the user changes the value.
| Type | Number |
| Required | Yes |
| Range |
Must be between aria-valuemin and aria-valuemax |
aria-valuemin (Required)
Dynamic bounds: Per the APG specification, when the range of one slider depends on the value of another, these attributes must update dynamically:
| Thumb | aria-valuemin | aria-valuemax |
|---|---|---|
| Lower thumb | Static (absolute min) | Dynamic (upper value - minDistance) |
| Upper thumb | Dynamic (lower value + minDistance) | Static (absolute max) |
This approach correctly communicates to assistive technology users the actual allowed range for each thumb at any given moment, which enables better predictability of Home and End key behaviors.
aria-valuemax (Required)
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 | "$20", "$80", "20% - 80%" |
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 |
|---|---|
| Tab | Moves focus between thumbs (lower to upper) |
| Shift + Tab | Moves focus between thumbs (upper to lower) |
| 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 thumb to its minimum allowed value (dynamic for upper thumb) |
| End | Sets the thumb to its maximum allowed value (dynamic for lower thumb) |
| Page Up | Increases the value by a large step (default: step * 10) |
| Page Down | Decreases the value by a large step (default: step * 10) |
Collision Prevention
The multi-thumb slider ensures that the thumbs cannot cross each other:
- Lower thumb - Cannot exceed (upper value - minDistance)
- Upper thumb - Cannot go below (lower value + minDistance)
- minDistance - Configurable minimum gap between thumbs (default: 0)
Accessible Naming
Multi-thumb sliders require careful labeling to distinguish between the two thumbs. This implementation supports several approaches:
- Visible group label - Using the
labelprop to display a visible label for the slider group -
aria-label(tuple) - Provides individual labels for each thumb, e.g., ["Minimum Price", "Maximum Price"] -
aria-labelledby(tuple) - References external elements as labels for each thumb -
getAriaLabelfunction - Dynamic label generation based on thumb index
Focus Management
Focus behavior in this implementation:
- Tab order - Both thumbs are in the tab order (tabindex="0")
- Constant order - Lower thumb always comes first in tab order, regardless of values
- Track click - Clicking the track moves the nearest thumb and focuses it
Pointer Interaction
This implementation supports mouse and touch interaction:
- Click on track: Moves the nearest 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 each thumb element
- Range indicator: Visual representation of the selected range between thumbs
- 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
---
/**
* APG Multi-Thumb Slider Pattern - Astro Implementation
*
* A control that allows users to select a range of values using two thumbs.
* Uses Web Components for interactive behavior.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/slider-multithumb/
*/
export interface Props {
/** Default values [lowerValue, upperValue] */
defaultValue?: [number, 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;
/** Minimum distance between thumbs (default: 0) */
minDistance?: number;
/** Slider orientation */
orientation?: 'horizontal' | 'vertical';
/** Whether slider is disabled */
disabled?: boolean;
/** Show value text (default: true) */
showValues?: boolean;
/** Visible label text for the group */
label?: string;
/** Format pattern for value display (e.g., "{value}") */
format?: string;
/** Slider id */
id?: string;
/** Additional CSS class */
class?: string;
/** Accessible label for lower thumb */
'aria-label-lower'?: string;
/** Accessible label for upper thumb */
'aria-label-upper'?: string;
/** Reference to external label element for lower thumb */
'aria-labelledby-lower'?: string;
/** Reference to external label element for upper thumb */
'aria-labelledby-upper'?: string;
/** Reference to description element (shared or per thumb) */
'aria-describedby'?: string;
/** Test id */
'data-testid'?: string;
}
const {
defaultValue,
min = 0,
max = 100,
step = 1,
largeStep,
minDistance = 0,
orientation = 'horizontal',
disabled = false,
showValues = true,
label,
format,
id,
class: className = '',
'aria-label-lower': ariaLabelLower,
'aria-label-upper': ariaLabelUpper,
'aria-labelledby-lower': ariaLabelledbyLower,
'aria-labelledby-upper': ariaLabelledbyUpper,
'aria-describedby': ariaDescribedby,
'data-testid': dataTestId,
} = 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));
};
// Normalize values
const normalizeValues = (
values: [number, number],
minVal: number,
maxVal: number,
stepVal: number,
minDist: number
): [number, number] => {
let [lower, upper] = values;
const effectiveMinDistance = Math.min(minDist, maxVal - minVal);
lower = roundToStep(lower, stepVal, minVal);
upper = roundToStep(upper, stepVal, minVal);
lower = clamp(lower, minVal, maxVal - effectiveMinDistance);
upper = clamp(upper, minVal + effectiveMinDistance, maxVal);
if (lower > upper - effectiveMinDistance) {
lower = upper - effectiveMinDistance;
}
return [lower, upper];
};
// Calculate initial values
const initialValues = normalizeValues(defaultValue ?? [min, max], min, max, step, minDistance);
// Calculate percentages for visual display
const lowerPercent = max === min ? 0 : ((initialValues[0] - min) / (max - min)) * 100;
const upperPercent = max === min ? 100 : ((initialValues[1] - min) / (max - min)) * 100;
// Dynamic bounds
const effectiveMinDistance = Math.min(minDistance, max - min);
const lowerBoundsMax = initialValues[1] - effectiveMinDistance;
const upperBoundsMin = initialValues[0] + effectiveMinDistance;
// Format value helper
const formatValue = (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 lowerDisplayText = formatValue(initialValues[0], format);
const upperDisplayText = formatValue(initialValues[1], format);
// Generate unique label ID
const groupLabelId = label
? `slider-multithumb-label-${Math.random().toString(36).slice(2, 9)}`
: undefined;
const isVertical = orientation === 'vertical';
const effectiveLargeStep = largeStep ?? step * 10;
---
<apg-slider-multithumb
data-min={min}
data-max={max}
data-step={step}
data-large-step={effectiveLargeStep}
data-min-distance={minDistance}
data-orientation={orientation}
data-disabled={disabled}
data-format={format}
>
<div
role={label ? 'group' : undefined}
aria-labelledby={label ? groupLabelId : undefined}
class={`apg-slider-multithumb ${isVertical ? 'apg-slider-multithumb--vertical' : ''} ${disabled ? 'apg-slider-multithumb--disabled' : ''} ${className}`.trim()}
id={id}
data-testid={dataTestId}
>
{
label && (
<span id={groupLabelId} class="apg-slider-multithumb-label">
{label}
</span>
)
}
<div
class="apg-slider-multithumb-track"
style={`--slider-lower: ${lowerPercent}%; --slider-upper: ${upperPercent}%`}
>
<div class="apg-slider-multithumb-range" aria-hidden="true"></div>
<!-- Lower thumb -->
<div
role="slider"
tabindex={disabled ? -1 : 0}
aria-valuenow={initialValues[0]}
aria-valuemin={min}
aria-valuemax={lowerBoundsMax}
aria-valuetext={format ? formatValue(initialValues[0], format) : undefined}
aria-label={ariaLabelLower}
aria-labelledby={ariaLabelledbyLower}
aria-orientation={isVertical ? 'vertical' : undefined}
aria-disabled={disabled ? true : undefined}
aria-describedby={ariaDescribedby}
class="apg-slider-multithumb-thumb apg-slider-multithumb-thumb--lower"
style={isVertical ? `bottom: ${lowerPercent}%` : `left: ${lowerPercent}%`}
data-thumb-index="0"
>
<span class="apg-slider-multithumb-tooltip" aria-hidden="true">
{ariaLabelLower}
</span>
</div>
<!-- Upper thumb -->
<div
role="slider"
tabindex={disabled ? -1 : 0}
aria-valuenow={initialValues[1]}
aria-valuemin={upperBoundsMin}
aria-valuemax={max}
aria-valuetext={format ? formatValue(initialValues[1], format) : undefined}
aria-label={ariaLabelUpper}
aria-labelledby={ariaLabelledbyUpper}
aria-orientation={isVertical ? 'vertical' : undefined}
aria-disabled={disabled ? true : undefined}
aria-describedby={ariaDescribedby}
class="apg-slider-multithumb-thumb apg-slider-multithumb-thumb--upper"
style={isVertical ? `bottom: ${upperPercent}%` : `left: ${upperPercent}%`}
data-thumb-index="1"
>
<span class="apg-slider-multithumb-tooltip" aria-hidden="true">
{ariaLabelUpper}
</span>
</div>
</div>
{
showValues && (
<div class="apg-slider-multithumb-values" aria-hidden="true">
<span class="apg-slider-multithumb-value apg-slider-multithumb-value--lower">
{lowerDisplayText}
</span>
<span class="apg-slider-multithumb-value-separator"> - </span>
<span class="apg-slider-multithumb-value apg-slider-multithumb-value--upper">
{upperDisplayText}
</span>
</div>
)
}
</div>
</apg-slider-multithumb>
<script>
class ApgSliderMultithumb extends HTMLElement {
private lowerThumb: HTMLElement | null = null;
private upperThumb: HTMLElement | null = null;
private track: HTMLElement | null = null;
private rangeIndicator: HTMLElement | null = null;
private lowerValueDisplay: HTMLElement | null = null;
private upperValueDisplay: HTMLElement | null = null;
private activeThumbIndex: number | null = null;
connectedCallback() {
this.lowerThumb = this.querySelector('[data-thumb-index="0"]');
this.upperThumb = this.querySelector('[data-thumb-index="1"]');
this.track = this.querySelector('.apg-slider-multithumb-track');
this.rangeIndicator = this.querySelector('.apg-slider-multithumb-range');
this.lowerValueDisplay = this.querySelector('.apg-slider-multithumb-value--lower');
this.upperValueDisplay = this.querySelector('.apg-slider-multithumb-value--upper');
// Bind event handlers for lower thumb
if (this.lowerThumb) {
this.lowerThumb.addEventListener('keydown', this.handleKeyDown.bind(this, 0));
this.lowerThumb.addEventListener('pointerdown', this.handlePointerDown.bind(this, 0));
this.lowerThumb.addEventListener('pointermove', this.handlePointerMove.bind(this, 0));
this.lowerThumb.addEventListener('pointerup', this.handlePointerUp.bind(this, 0));
}
// Bind event handlers for upper thumb
if (this.upperThumb) {
this.upperThumb.addEventListener('keydown', this.handleKeyDown.bind(this, 1));
this.upperThumb.addEventListener('pointerdown', this.handlePointerDown.bind(this, 1));
this.upperThumb.addEventListener('pointermove', this.handlePointerMove.bind(this, 1));
this.upperThumb.addEventListener('pointerup', this.handlePointerUp.bind(this, 1));
}
// Track click
if (this.track) {
this.track.addEventListener('click', this.handleTrackClick.bind(this));
}
}
disconnectedCallback() {
// Note: Event listeners are automatically removed when element is removed
}
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 minDistance(): number {
return Number(this.dataset.minDistance) || 0;
}
private get effectiveMinDistance(): number {
return Math.min(this.minDistance, this.max - this.min);
}
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 getThumb(index: number): HTMLElement | null {
return index === 0 ? this.lowerThumb : this.upperThumb;
}
private getValue(index: number): number {
const thumb = this.getThumb(index);
return Number(thumb?.getAttribute('aria-valuenow')) || this.min;
}
private getValues(): [number, number] {
return [this.getValue(0), this.getValue(1)];
}
private getThumbBounds(index: number): { min: number; max: number } {
const values = this.getValues();
if (index === 0) {
return {
min: this.min,
max: values[1] - this.effectiveMinDistance,
};
} else {
return {
min: values[0] + this.effectiveMinDistance,
max: this.max,
};
}
}
private clamp(val: number, minVal: number, maxVal: number): number {
return Math.min(maxVal, Math.max(minVal, 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(index: number, newValue: number) {
if (this.isDisabled) return;
const thumb = this.getThumb(index);
if (!thumb) return;
const bounds = this.getThumbBounds(index);
const clampedValue = this.clamp(this.roundToStep(newValue), bounds.min, bounds.max);
const currentValue = this.getValue(index);
if (clampedValue === currentValue) return;
// Update ARIA
thumb.setAttribute('aria-valuenow', String(clampedValue));
// Update aria-valuetext if format is provided
const formattedValue = this.formatValue(clampedValue);
if (this.format) {
thumb.setAttribute('aria-valuetext', formattedValue);
}
// Update visual position
const percentage = ((clampedValue - this.min) / (this.max - this.min)) * 100;
if (this.isVertical) {
thumb.style.bottom = `${percentage}%`;
} else {
thumb.style.left = `${percentage}%`;
}
// Update range indicator
const values = this.getValues();
const lowerPercent = ((values[0] - this.min) / (this.max - this.min)) * 100;
const upperPercent = ((values[1] - this.min) / (this.max - this.min)) * 100;
if (this.track) {
this.track.style.setProperty('--slider-lower', `${lowerPercent}%`);
this.track.style.setProperty('--slider-upper', `${upperPercent}%`);
}
// Update value display
const valueDisplay = index === 0 ? this.lowerValueDisplay : this.upperValueDisplay;
if (valueDisplay) {
valueDisplay.textContent = formattedValue;
}
// Update dynamic bounds on the other thumb
this.updateOtherThumbBounds(index);
// Dispatch event
this.dispatchEvent(
new CustomEvent('valuechange', {
detail: { values: this.getValues(), activeThumbIndex: index },
bubbles: true,
})
);
}
private updateOtherThumbBounds(changedIndex: number) {
const otherIndex = changedIndex === 0 ? 1 : 0;
const otherThumb = this.getThumb(otherIndex);
if (!otherThumb) return;
const bounds = this.getThumbBounds(otherIndex);
otherThumb.setAttribute(
otherIndex === 0 ? 'aria-valuemax' : 'aria-valuemin',
String(otherIndex === 0 ? bounds.max : bounds.min)
);
}
private getValueFromPointer(clientX: number, clientY: number): number {
if (!this.track) return this.getValue(0);
const rect = this.track.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) {
return this.getValue(0);
}
let percent: number;
if (this.isVertical) {
if (rect.height === 0) return this.getValue(0);
percent = 1 - (clientY - rect.top) / rect.height;
} else {
if (rect.width === 0) return this.getValue(0);
percent = (clientX - rect.left) / rect.width;
}
return this.min + percent * (this.max - this.min);
}
private handleKeyDown(index: number, event: KeyboardEvent) {
if (this.isDisabled) return;
const bounds = this.getThumbBounds(index);
const currentValue = this.getValue(index);
let newValue = currentValue;
switch (event.key) {
case 'ArrowRight':
case 'ArrowUp':
newValue = currentValue + this.step;
break;
case 'ArrowLeft':
case 'ArrowDown':
newValue = currentValue - this.step;
break;
case 'Home':
newValue = bounds.min;
break;
case 'End':
newValue = bounds.max;
break;
case 'PageUp':
newValue = currentValue + this.largeStep;
break;
case 'PageDown':
newValue = currentValue - this.largeStep;
break;
default:
return;
}
event.preventDefault();
this.updateValue(index, newValue);
}
private handlePointerDown(index: number, event: PointerEvent) {
if (this.isDisabled) return;
const thumb = this.getThumb(index);
if (!thumb) return;
event.preventDefault();
if (typeof thumb.setPointerCapture === 'function') {
thumb.setPointerCapture(event.pointerId);
}
this.activeThumbIndex = index;
thumb.focus();
}
private handlePointerMove(index: number, event: PointerEvent) {
const thumb = this.getThumb(index);
if (!thumb) return;
const hasCapture =
typeof thumb.hasPointerCapture === 'function'
? thumb.hasPointerCapture(event.pointerId)
: this.activeThumbIndex === index;
if (!hasCapture) return;
const newValue = this.getValueFromPointer(event.clientX, event.clientY);
this.updateValue(index, newValue);
}
private handlePointerUp(index: number, event: PointerEvent) {
const thumb = this.getThumb(index);
if (thumb && typeof thumb.releasePointerCapture === 'function') {
try {
thumb.releasePointerCapture(event.pointerId);
} catch {
// Ignore
}
}
this.activeThumbIndex = null;
// Dispatch commit event
this.dispatchEvent(
new CustomEvent('valuecommit', {
detail: { values: this.getValues() },
bubbles: true,
})
);
}
private handleTrackClick(event: MouseEvent) {
if (this.isDisabled || event.target === this.lowerThumb || event.target === this.upperThumb)
return;
const clickValue = this.getValueFromPointer(event.clientX, event.clientY);
const values = this.getValues();
// Determine which thumb to move (nearest, prefer lower on tie)
const distToLower = Math.abs(clickValue - values[0]);
const distToUpper = Math.abs(clickValue - values[1]);
const targetIndex = distToLower <= distToUpper ? 0 : 1;
this.updateValue(targetIndex, clickValue);
this.getThumb(targetIndex)?.focus();
}
// Public method to update values programmatically
setValues(lowerValue: number, upperValue: number) {
this.updateValue(0, lowerValue);
this.updateValue(1, upperValue);
}
}
if (!customElements.get('apg-slider-multithumb')) {
customElements.define('apg-slider-multithumb', ApgSliderMultithumb);
}
</script> Usage
---
import MultiThumbSlider from './MultiThumbSlider.astro';
---
<!-- Basic usage with visible label and aria-label props -->
<MultiThumbSlider
defaultValue={[20, 80]}
label="Price Range"
aria-label-lower="Minimum Price"
aria-label-upper="Maximum Price"
/>
<!-- With format for display and aria-valuetext -->
<MultiThumbSlider
defaultValue={[25, 75]}
label="Temperature"
format="{value}°C"
aria-label-lower="Min Temp"
aria-label-upper="Max Temp"
/>
<!-- With minDistance to prevent thumbs from getting too close -->
<MultiThumbSlider
defaultValue={[30, 70]}
minDistance={10}
label="Budget"
format="${value}"
aria-label-lower="Min Budget"
aria-label-upper="Max Budget"
/>
<!-- Listening for events with JavaScript -->
<MultiThumbSlider
id="my-slider"
defaultValue={[20, 80]}
label="Range"
aria-label-lower="Lower"
aria-label-upper="Upper"
/>
<script>
const slider = document.getElementById('my-slider');
slider?.addEventListener('valuechange', (e) => {
const { values, activeThumbIndex } = e.detail;
console.log('Changed:', values, 'Active thumb:', activeThumbIndex);
});
slider?.addEventListener('valuecommit', (e) => {
const { values } = e.detail;
console.log('Committed:', values);
});
</script> API
Props
| Prop | Type | Default | Description |
|---|---|---|---|
defaultValue | [number, number] | [min, max] | Initial values for the two thumbs |
min | number | 0 | Minimum value |
max | number | 100 | Maximum value |
step | number | 1 | Step increment |
minDistance | number | 0 | Minimum distance between thumbs |
disabled | boolean | false | Whether the slider is disabled |
label | string | - | Visible label for the slider group |
format | string | - | Format pattern for display |
aria-label-lower | string | - | Accessible label for lower thumb |
aria-label-upper | string | - | Accessible label for upper thumb |
Custom Events
| Event | Detail | Description |
|---|---|---|
valuechange | {values, activeThumbIndex} | Dispatched when any value changes |
valuecommit | {values} | Dispatched when interaction ends |
Testing
Tests verify APG compliance for ARIA attributes, keyboard interactions, collision prevention, and accessibility requirements for multi-thumb sliders.
Test Categories
High Priority: ARIA Structure
| Test | Description |
|---|---|
two slider elements | Container has exactly two elements with role="slider" |
role="group" | Sliders are contained in a group with aria-labelledby |
aria-valuenow | Both thumbs have correct initial values |
static aria-valuemin | Lower thumb has static min (absolute minimum) |
static aria-valuemax | Upper thumb has static max (absolute maximum) |
dynamic aria-valuemax | Lower thumb's max depends on upper thumb's value |
dynamic aria-valuemin | Upper thumb's min depends on lower thumb's value |
High Priority: Dynamic Bounds Update
| Test | Description |
|---|---|
lower -> upper bound | Moving lower thumb updates upper thumb's aria-valuemin |
upper -> lower bound | Moving upper thumb updates lower thumb's aria-valuemax |
High Priority: Keyboard Interaction
| Test | Description |
|---|---|
Arrow Right/Up | Increases value by one step |
Arrow Left/Down | Decreases value by one step |
Home (lower) | Sets lower thumb to absolute minimum |
End (lower) | Sets lower thumb to dynamic maximum (upper - minDistance) |
Home (upper) | Sets upper thumb to dynamic minimum (lower + minDistance) |
End (upper) | Sets upper thumb to absolute maximum |
Page Up/Down | Increases/decreases value by large step |
High Priority: Collision Prevention
| Test | Description |
|---|---|
lower cannot exceed upper | Lower thumb stops at (upper - minDistance) |
upper cannot go below lower | Upper thumb stops at (lower + minDistance) |
rapid key presses | Thumbs do not cross when rapidly pressing arrow keys |
High Priority: Focus Management
| Test | Description |
|---|---|
Tab order | Tab moves from lower to upper thumb |
Shift+Tab order | Shift+Tab moves from upper to lower thumb |
tabindex="0" | Both thumbs have tabindex="0" (always in tab order) |
Medium Priority: aria-valuetext Updates
| Test | Description |
|---|---|
lower thumb update | aria-valuetext updates when lower thumb value changes |
upper thumb update | aria-valuetext updates when upper thumb value changes |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations (container) | No accessibility violations on the container |
axe violations (sliders) | No accessibility violations on each slider element |
Low Priority: Cross-framework Consistency
| Test | Description |
|---|---|
render two sliders | All frameworks render exactly two slider elements |
consistent initial values | All frameworks have identical initial aria-valuenow values |
keyboard navigation | All frameworks support identical keyboard navigation |
collision prevention | All frameworks prevent thumb crossing |
Example Test Code
The following is the actual E2E test file (e2e/slider-multithumb.spec.ts).
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Multi-Thumb Slider Pattern
*
* A slider with two thumbs that allows users to select a range of values.
* Each thumb uses role="slider" with dynamic aria-valuemin/aria-valuemax
* based on the other thumb's position.
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/slider-multithumb/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// ============================================
// Helper Functions
// ============================================
const getBasicSliderContainer = (page: import('@playwright/test').Page) => {
return page.getByTestId('basic-slider');
};
const getSliders = (page: import('@playwright/test').Page) => {
return getBasicSliderContainer(page).getByRole('slider');
};
const getSliderByLabel = (page: import('@playwright/test').Page, label: string) => {
return getBasicSliderContainer(page).getByRole('slider', { name: label });
};
// ============================================
// Framework-specific Tests
// ============================================
for (const framework of frameworks) {
test.describe(`MultiThumbSlider (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/slider-multithumb/${framework}/demo/`);
await getSliders(page).first().waitFor();
// Wait for hydration - sliders should have aria-valuenow
const firstSlider = getSliders(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('has two slider elements', async ({ page }) => {
const sliders = getSliders(page);
await expect(sliders).toHaveCount(2);
});
test('lower thumb has role="slider"', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
await expect(lowerThumb).toHaveRole('slider');
});
test('upper thumb has role="slider"', async ({ page }) => {
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await expect(upperThumb).toHaveRole('slider');
});
test('lower thumb has correct initial aria-valuenow', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const valuenow = await lowerThumb.getAttribute('aria-valuenow');
expect(valuenow).toBe('20');
});
test('upper thumb has correct initial aria-valuenow', async ({ page }) => {
const upperThumb = getSliderByLabel(page, 'Maximum Price');
const valuenow = await upperThumb.getAttribute('aria-valuenow');
expect(valuenow).toBe('80');
});
test('lower thumb has static aria-valuemin (absolute min)', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
await expect(lowerThumb).toHaveAttribute('aria-valuemin', '0');
});
test('upper thumb has static aria-valuemax (absolute max)', async ({ page }) => {
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await expect(upperThumb).toHaveAttribute('aria-valuemax', '100');
});
test('lower thumb has dynamic aria-valuemax based on upper thumb', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
// Upper thumb is at 80, so lower thumb max should be 80 (or 80 - minDistance)
const valuemax = await lowerThumb.getAttribute('aria-valuemax');
expect(Number(valuemax)).toBeLessThanOrEqual(80);
});
test('upper thumb has dynamic aria-valuemin based on lower thumb', async ({ page }) => {
const upperThumb = getSliderByLabel(page, 'Maximum Price');
// Lower thumb is at 20, so upper thumb min should be 20 (or 20 + minDistance)
const valuemin = await upperThumb.getAttribute('aria-valuemin');
expect(Number(valuemin)).toBeGreaterThanOrEqual(20);
});
test('sliders are contained in group with label', async ({ page }) => {
const group = page.getByRole('group', { name: 'Price Range' });
await expect(group).toBeVisible();
});
});
// ------------------------------------------
// 🔴 High Priority: Dynamic Bounds Update
// ------------------------------------------
test.describe('APG: Dynamic Bounds Update', () => {
test('moving lower thumb updates upper thumb aria-valuemin', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await lowerThumb.click();
await page.keyboard.press('ArrowRight');
// Lower thumb moved from 20 to 21
await expect(lowerThumb).toHaveAttribute('aria-valuenow', '21');
// Upper thumb's min should have increased
const valuemin = await upperThumb.getAttribute('aria-valuemin');
expect(Number(valuemin)).toBeGreaterThanOrEqual(21);
});
test('moving upper thumb updates lower thumb aria-valuemax', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await upperThumb.click();
await page.keyboard.press('ArrowLeft');
// Upper thumb moved from 80 to 79
await expect(upperThumb).toHaveAttribute('aria-valuenow', '79');
// Lower thumb's max should have decreased
const valuemax = await lowerThumb.getAttribute('aria-valuemax');
expect(Number(valuemax)).toBeLessThanOrEqual(79);
});
});
// ------------------------------------------
// 🔴 High Priority: Keyboard Interaction
// ------------------------------------------
test.describe('APG: Keyboard Interaction', () => {
test('ArrowRight increases lower thumb value by step', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
await lowerThumb.click();
await expect(lowerThumb).toBeFocused();
const initialValue = await lowerThumb.getAttribute('aria-valuenow');
await page.keyboard.press('ArrowRight');
const newValue = await lowerThumb.getAttribute('aria-valuenow');
expect(Number(newValue)).toBe(Number(initialValue) + 1);
});
test('ArrowLeft decreases upper thumb value by step', async ({ page }) => {
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await upperThumb.click();
const initialValue = await upperThumb.getAttribute('aria-valuenow');
await page.keyboard.press('ArrowLeft');
const newValue = await upperThumb.getAttribute('aria-valuenow');
expect(Number(newValue)).toBe(Number(initialValue) - 1);
});
test('Home sets lower thumb to absolute minimum', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
await lowerThumb.click();
await page.keyboard.press('Home');
await expect(lowerThumb).toHaveAttribute('aria-valuenow', '0');
});
test('End sets lower thumb to dynamic maximum (not absolute)', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await lowerThumb.click();
// Get upper thumb value to determine expected max
const upperValue = await upperThumb.getAttribute('aria-valuenow');
await page.keyboard.press('End');
// Lower thumb should be at or near upper thumb value (respecting minDistance)
const newValue = await lowerThumb.getAttribute('aria-valuenow');
expect(Number(newValue)).toBeLessThanOrEqual(Number(upperValue));
});
test('Home sets upper thumb to dynamic minimum (not absolute)', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await upperThumb.click();
// Get lower thumb value to determine expected min
const lowerValue = await lowerThumb.getAttribute('aria-valuenow');
await page.keyboard.press('Home');
// Upper thumb should be at or near lower thumb value (respecting minDistance)
const newValue = await upperThumb.getAttribute('aria-valuenow');
expect(Number(newValue)).toBeGreaterThanOrEqual(Number(lowerValue));
});
test('End sets upper thumb to absolute maximum', async ({ page }) => {
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await upperThumb.click();
await page.keyboard.press('End');
await expect(upperThumb).toHaveAttribute('aria-valuenow', '100');
});
test('PageUp increases value by large step', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
await lowerThumb.click();
const initialValue = await lowerThumb.getAttribute('aria-valuenow');
await page.keyboard.press('PageUp');
const newValue = await lowerThumb.getAttribute('aria-valuenow');
expect(Number(newValue)).toBe(Number(initialValue) + 10);
});
test('PageDown decreases value by large step', async ({ page }) => {
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await upperThumb.click();
const initialValue = await upperThumb.getAttribute('aria-valuenow');
await page.keyboard.press('PageDown');
const newValue = await upperThumb.getAttribute('aria-valuenow');
expect(Number(newValue)).toBe(Number(initialValue) - 10);
});
});
// ------------------------------------------
// 🔴 High Priority: Collision Prevention
// ------------------------------------------
test.describe('APG: Collision Prevention', () => {
test('lower thumb cannot exceed upper thumb', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await lowerThumb.click();
// Get upper thumb's current value
const upperValue = await upperThumb.getAttribute('aria-valuenow');
// Try to move lower thumb to End (dynamic max)
await page.keyboard.press('End');
// Verify lower thumb is at or below upper thumb
const lowerValue = await lowerThumb.getAttribute('aria-valuenow');
expect(Number(lowerValue)).toBeLessThanOrEqual(Number(upperValue));
});
test('upper thumb cannot go below lower thumb', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await upperThumb.click();
// Get lower thumb's current value
const lowerValue = await lowerThumb.getAttribute('aria-valuenow');
// Try to move upper thumb to Home (dynamic min)
await page.keyboard.press('Home');
// Verify upper thumb is at or above lower thumb
const upperValue = await upperThumb.getAttribute('aria-valuenow');
expect(Number(upperValue)).toBeGreaterThanOrEqual(Number(lowerValue));
});
test('thumbs cannot cross when rapidly pressing arrow keys', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const upperThumb = getSliderByLabel(page, 'Maximum Price');
// Move lower thumb toward upper thumb
await lowerThumb.click();
for (let i = 0; i < 100; i++) {
await page.keyboard.press('ArrowRight');
}
const lowerValue = await lowerThumb.getAttribute('aria-valuenow');
const upperValue = await upperThumb.getAttribute('aria-valuenow');
expect(Number(lowerValue)).toBeLessThanOrEqual(Number(upperValue));
});
});
// ------------------------------------------
// 🔴 High Priority: Focus Management
// ------------------------------------------
test.describe('APG: Focus Management', () => {
test('Tab moves from lower to upper thumb', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await lowerThumb.focus();
await expect(lowerThumb).toBeFocused();
await page.keyboard.press('Tab');
await expect(upperThumb).toBeFocused();
});
test('Shift+Tab moves from upper to lower thumb', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await upperThumb.focus();
await expect(upperThumb).toBeFocused();
await page.keyboard.press('Shift+Tab');
await expect(lowerThumb).toBeFocused();
});
test('both thumbs have tabindex="0"', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await expect(lowerThumb).toHaveAttribute('tabindex', '0');
await expect(upperThumb).toHaveAttribute('tabindex', '0');
});
});
// ------------------------------------------
// 🟡 Medium Priority: aria-valuetext Updates
// ------------------------------------------
test.describe('aria-valuetext Updates', () => {
test('lower thumb aria-valuetext updates on value change', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
await lowerThumb.click();
await page.keyboard.press('Home');
await expect(lowerThumb).toHaveAttribute('aria-valuetext', '$0');
await page.keyboard.press('ArrowRight');
await expect(lowerThumb).toHaveAttribute('aria-valuetext', '$1');
});
test('upper thumb aria-valuetext updates on value change', async ({ page }) => {
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await upperThumb.click();
await page.keyboard.press('End');
await expect(upperThumb).toHaveAttribute('aria-valuetext', '$100');
await page.keyboard.press('ArrowLeft');
await expect(upperThumb).toHaveAttribute('aria-valuetext', '$99');
});
});
// ------------------------------------------
// 🟢 Low Priority: Accessibility
// ------------------------------------------
test.describe('Accessibility', () => {
test('has no axe-core violations', async ({ page }) => {
const results = await new AxeBuilder({ page })
.include('[data-testid="basic-slider"]')
.exclude('[aria-hidden="true"]')
.analyze();
expect(results.violations).toEqual([]);
});
test('both sliders pass axe-core', async ({ page }) => {
const results = await new AxeBuilder({ page })
.include('[data-testid="basic-slider"] [role="slider"]')
.analyze();
expect(results.violations).toEqual([]);
});
});
// ------------------------------------------
// 🟡 Medium Priority: Pointer Interactions
// ------------------------------------------
test.describe('Pointer Interactions', () => {
test('track click moves nearest thumb', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const track = page.locator('[data-testid="basic-slider"] .apg-slider-multithumb-track');
// Click near the start of the track (should move lower thumb)
const trackBox = await track.boundingBox();
if (trackBox) {
await page.mouse.click(trackBox.x + 10, trackBox.y + trackBox.height / 2);
}
// Lower thumb should have moved toward the click position
const newValue = await lowerThumb.getAttribute('aria-valuenow');
expect(Number(newValue)).toBeLessThan(20); // Was 20, should be lower
});
test('thumb can be dragged', async ({ page }) => {
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const thumbBox = await lowerThumb.boundingBox();
if (thumbBox) {
// Drag thumb to the right
await page.mouse.move(thumbBox.x + thumbBox.width / 2, thumbBox.y + thumbBox.height / 2);
await page.mouse.down();
await page.mouse.move(thumbBox.x + 100, thumbBox.y + thumbBox.height / 2);
await page.mouse.up();
}
// Value should have increased
const newValue = await lowerThumb.getAttribute('aria-valuenow');
expect(Number(newValue)).toBeGreaterThan(20); // Was 20
});
});
// ------------------------------------------
// 🟡 Medium Priority: Disabled State
// ------------------------------------------
test.describe('Disabled State', () => {
test('disabled slider thumbs have tabindex="-1"', async ({ page }) => {
const disabledSliders = page.locator('[data-testid="disabled-slider"]').getByRole('slider');
await expect(disabledSliders.first()).toHaveAttribute('tabindex', '-1');
await expect(disabledSliders.last()).toHaveAttribute('tabindex', '-1');
});
test('disabled slider thumbs have aria-disabled="true"', async ({ page }) => {
const disabledSliders = page.locator('[data-testid="disabled-slider"]').getByRole('slider');
await expect(disabledSliders.first()).toHaveAttribute('aria-disabled', 'true');
await expect(disabledSliders.last()).toHaveAttribute('aria-disabled', 'true');
});
test('disabled slider ignores keyboard input', async ({ page }) => {
const disabledThumb = page
.locator('[data-testid="disabled-slider"]')
.getByRole('slider')
.first();
const initialValue = await disabledThumb.getAttribute('aria-valuenow');
// Try to click and press arrow key (disabled elements can still receive focus via click)
await disabledThumb.click({ force: true });
await page.keyboard.press('ArrowRight');
// Value should not change
await expect(disabledThumb).toHaveAttribute('aria-valuenow', initialValue!);
});
});
// ------------------------------------------
// 🟡 Medium Priority: Vertical Orientation
// ------------------------------------------
test.describe('Vertical Orientation', () => {
test('vertical slider has aria-orientation="vertical"', async ({ page }) => {
const verticalSliders = page.locator('[data-testid="vertical-slider"]').getByRole('slider');
await expect(verticalSliders.first()).toHaveAttribute('aria-orientation', 'vertical');
await expect(verticalSliders.last()).toHaveAttribute('aria-orientation', 'vertical');
});
test('vertical slider responds to ArrowUp/Down', async ({ page }) => {
const verticalThumb = page
.locator('[data-testid="vertical-slider"]')
.getByRole('slider')
.first();
await verticalThumb.click();
const initialValue = await verticalThumb.getAttribute('aria-valuenow');
await page.keyboard.press('ArrowUp');
const afterUp = await verticalThumb.getAttribute('aria-valuenow');
expect(Number(afterUp)).toBe(Number(initialValue) + 1);
await page.keyboard.press('ArrowDown');
const afterDown = await verticalThumb.getAttribute('aria-valuenow');
expect(Number(afterDown)).toBe(Number(initialValue));
});
});
// ------------------------------------------
// 🟡 Medium Priority: minDistance
// ------------------------------------------
test.describe('minDistance Constraint', () => {
test('thumbs maintain minimum distance', async ({ page }) => {
const minDistanceSliders = page
.locator('[data-testid="min-distance-slider"]')
.getByRole('slider');
const lowerThumb = minDistanceSliders.first();
const upperThumb = minDistanceSliders.last();
// Try to move lower thumb to End
await lowerThumb.click();
await page.keyboard.press('End');
const lowerValue = Number(await lowerThumb.getAttribute('aria-valuenow'));
const upperValue = Number(await upperThumb.getAttribute('aria-valuenow'));
// Should maintain minDistance of 10
expect(upperValue - lowerValue).toBeGreaterThanOrEqual(10);
});
test('lower thumb aria-valuemax respects minDistance', async ({ page }) => {
const minDistanceSliders = page
.locator('[data-testid="min-distance-slider"]')
.getByRole('slider');
const lowerThumb = minDistanceSliders.first();
const upperThumb = minDistanceSliders.last();
const upperValue = Number(await upperThumb.getAttribute('aria-valuenow'));
const lowerMax = Number(await lowerThumb.getAttribute('aria-valuemax'));
// Lower thumb max should be upper value - minDistance
expect(lowerMax).toBeLessThanOrEqual(upperValue - 10);
});
test('upper thumb aria-valuemin respects minDistance', async ({ page }) => {
const minDistanceSliders = page
.locator('[data-testid="min-distance-slider"]')
.getByRole('slider');
const lowerThumb = minDistanceSliders.first();
const upperThumb = minDistanceSliders.last();
const lowerValue = Number(await lowerThumb.getAttribute('aria-valuenow'));
const upperMin = Number(await upperThumb.getAttribute('aria-valuemin'));
// Upper thumb min should be lower value + minDistance
expect(upperMin).toBeGreaterThanOrEqual(lowerValue + 10);
});
});
});
}
// ============================================
// Cross-framework Consistency Tests
// ============================================
test.describe('MultiThumbSlider - Cross-framework Consistency', () => {
test('all frameworks render two sliders', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/slider-multithumb/${framework}/demo/`);
await getSliders(page).first().waitFor();
const sliders = getSliders(page);
const count = await sliders.count();
expect(count).toBe(2);
}
});
test('all frameworks have consistent initial values', async ({ page }) => {
test.setTimeout(60000);
for (const framework of frameworks) {
await page.goto(`patterns/slider-multithumb/${framework}/demo/`);
await getSliders(page).first().waitFor();
// Wait for hydration
await expect
.poll(async () => {
const valuenow = await getSliders(page).first().getAttribute('aria-valuenow');
return valuenow !== null;
})
.toBe(true);
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const upperThumb = getSliderByLabel(page, 'Maximum Price');
await expect(lowerThumb).toHaveAttribute('aria-valuenow', '20');
await expect(upperThumb).toHaveAttribute('aria-valuenow', '80');
}
});
test('all frameworks support keyboard navigation', async ({ page }) => {
test.setTimeout(60000);
for (const framework of frameworks) {
await page.goto(`patterns/slider-multithumb/${framework}/demo/`);
await getSliders(page).first().waitFor();
// Wait for hydration
await expect
.poll(async () => {
const valuenow = await getSliders(page).first().getAttribute('aria-valuenow');
return valuenow !== null;
})
.toBe(true);
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
await lowerThumb.click();
// Test ArrowRight
const initialValue = await lowerThumb.getAttribute('aria-valuenow');
await page.keyboard.press('ArrowRight');
const newValue = await lowerThumb.getAttribute('aria-valuenow');
expect(Number(newValue)).toBe(Number(initialValue) + 1);
}
});
test('all frameworks prevent thumb crossing', async ({ page }) => {
test.setTimeout(60000);
for (const framework of frameworks) {
await page.goto(`patterns/slider-multithumb/${framework}/demo/`);
await getSliders(page).first().waitFor();
// Wait for hydration
await expect
.poll(async () => {
const valuenow = await getSliders(page).first().getAttribute('aria-valuenow');
return valuenow !== null;
})
.toBe(true);
const lowerThumb = getSliderByLabel(page, 'Minimum Price');
const upperThumb = getSliderByLabel(page, 'Maximum Price');
// Try to move lower thumb beyond upper
await lowerThumb.click();
await page.keyboard.press('End');
const lowerValue = Number(await lowerThumb.getAttribute('aria-valuenow'));
const upperValue = Number(await upperThumb.getAttribute('aria-valuenow'));
expect(lowerValue).toBeLessThanOrEqual(upperValue);
}
});
}); Running Tests
# Run unit tests for MultiThumbSlider
npm run test -- MultiThumbSlider
# Run E2E tests for MultiThumbSlider (all frameworks)
npm run test:e2e:pattern --pattern=slider-multithumb
# Run E2E tests for specific framework
npm run test:e2e:react:pattern --pattern=slider-multithumb
npm run test:e2e:vue:pattern --pattern=slider-multithumb
npm run test:e2e:svelte:pattern --pattern=slider-multithumb
npm run test:e2e:astro:pattern --pattern=slider-multithumb Testing Tools
- Vitest (opens in new tab) - Test runner for unit tests
- Testing Library (opens in new tab) - Framework-specific testing utilities
- Playwright (opens in new tab) - Browser automation for E2E tests
- axe-core (opens in new tab) - Accessibility testing engine
Resources
- WAI-ARIA APG: Slider (Multi-Thumb) Pattern (opens in new tab)
- WAI-ARIA APG: Slider Pattern (opens in new tab)
- MDN: Web Components (opens in new tab)
- AI Implementation Guide (llm.md) (opens in new tab) - ARIA specs, keyboard support, test checklist