useSlider

Provides behavior and accessibility for a slider component.

installyarn add @react-aria/slider
version3.0.0-alpha.3
usageimport {useSlider, useSliderThumb} from '@react-aria/slider'

API#


useSlider( props: SliderProps, state: SliderState, trackRef: RefObject<HTMLElement> ): SliderAriauseSliderThumb( (opts: SliderThumbOptions, , state: SliderState )): SliderThumbAria

Features#


The <input type="range"> HTML element can be used to build a slider, however it is very difficult to style cross browser. useSlider and useSliderThumb help achieve accessible sliders that can be styled as needed.

  • Exposed to assistive technology as a group and slider via ARIA
  • Labeling support for accessibility
  • Internationalized number formatting as a percentage or value
  • Multiple thumbs, or group of range values
  • Horizontal and Vertical layouts
  • Normalized across devices

Anatomy#


Sliders consist of a track element showing the range of available values, a thumb or multiple thumbs showing the current value(s), and a label. Thumbs consist of an indicator that designates a position on the track. The track element represents the value(s) visually, the thumbs represent individual values with role=slider. The group of value(s) is represented by the wrapping element with role=group.

useSlider hook#

useSlider returns three sets of props that you should spread onto the appropriate element:

NameTypeDescription
labelPropsHTMLAttributes<HTMLElement>Props for the label element.
containerPropsHTMLAttributes<HTMLElement>Props for the root element of the slider component; groups slider inputs.
trackPropsHTMLAttributes<HTMLElement>Props for the track element.

If there is no visual label, an aria-label or aria-labelledby prop must be passed instead to identify the element to screen readers.

useSliderThumb hook#

useSliderThumb returns three sets of props that you should spread onto the appropriate element:

NameTypeDescription
inputPropsHTMLAttributes<HTMLElement>Props for the range input.
thumbPropsHTMLAttributes<HTMLElement>Props for the root thumb element; handles the dragging motion.
labelPropsHTMLAttributes<HTMLElement>Props for the label element for this thumb.

If there is no visual label, an aria-label or aria-labelledby prop must be passed instead to identify the element to screen readers.

Slider Thumb value(s) are managed by useSliderState.

Example#


Single Thumb#

.slider {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 300px;
}

.sliderLabel {
  display: flex;
  align-self: stretch;
}

.label {
  color: #8b8b8b;
}

.value {
  color: #585858;
  margin-inline-start: auto;
}

.thumb {
  position: absolute;
  top: 4px;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
}

.thumbHandle {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 2px solid #707070;
  box-sizing: border-box;
  background-color: #f5f5f5;
  box-shadow: 0 0 0 4px #f5f5f5;
  display: flex;
  align-items: center;
  justify-content: center;
}

.thumbFocusVisible .thumbHandle {
  box-shadow: 0 0 0 2px #f5f5f5, 0 0 0 4px #2c83eb;
}

.thumbFocused {
  border-color: #2c83eb;
}

.thumbDisabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.thumbDisabled label {
  cursor: inherit;
}

.rail {
  position: absolute;
  background-color: #e4e4e4;
  height: 3px;
  top: 13px;
  width: 100%;
}

.filledRail {
  position: absolute;
  background-color: #707070;
  height: 3px;
  top: 13px;
  inset-inline-start: 0;
}

.track {
  position: relative;
  height: 30px;
  width: 100%;
}

.trackContainer {
  position: relative;
  width: 100%;
}
.slider {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 300px;
}

.sliderLabel {
  display: flex;
  align-self: stretch;
}

.label {
  color: #8b8b8b;
}

.value {
  color: #585858;
  margin-inline-start: auto;
}

.thumb {
  position: absolute;
  top: 4px;
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
}

.thumbHandle {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 2px solid #707070;
  box-sizing: border-box;
  background-color: #f5f5f5;
  box-shadow: 0 0 0 4px #f5f5f5;
  display: flex;
  align-items: center;
  justify-content: center;
}

.thumbFocusVisible .thumbHandle {
  box-shadow: 0 0 0 2px #f5f5f5, 0 0 0 4px #2c83eb;
}

.thumbFocused {
  border-color: #2c83eb;
}

.thumbDisabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.thumbDisabled label {
  cursor: inherit;
}

.rail {
  position: absolute;
  background-color: #e4e4e4;
  height: 3px;
  top: 13px;
  width: 100%;
}

.filledRail {
  position: absolute;
  background-color: #707070;
  height: 3px;
  top: 13px;
  inset-inline-start: 0;
}

.track {
  position: relative;
  height: 30px;
  width: 100%;
}

.trackContainer {
  position: relative;
  width: 100%;
}
.slider {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  width: 300px;
}

.sliderLabel {
  display: flex;
  align-self: stretch;
}

.label {
  color: #8b8b8b;
}

.value {
  color: #585858;
  margin-inline-start: auto;
}

.thumb {
  position: absolute;
  top: 4px;
  transform: translateX(
    -50%
  );
  display: flex;
  flex-direction: column;
  align-items: center;
}

.thumbHandle {
  width: 20px;
  height: 20px;
  border-radius: 50%;
  border: 2px solid
    #707070;
  box-sizing: border-box;
  background-color: #f5f5f5;
  box-shadow: 0 0 0 4px
    #f5f5f5;
  display: flex;
  align-items: center;
  justify-content: center;
}

.thumbFocusVisible
  .thumbHandle {
  box-shadow: 0 0 0 2px
      #f5f5f5,
    0 0 0 4px #2c83eb;
}

.thumbFocused {
  border-color: #2c83eb;
}

.thumbDisabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.thumbDisabled label {
  cursor: inherit;
}

.rail {
  position: absolute;
  background-color: #e4e4e4;
  height: 3px;
  top: 13px;
  width: 100%;
}

.filledRail {
  position: absolute;
  background-color: #707070;
  height: 3px;
  top: 13px;
  inset-inline-start: 0;
}

.track {
  position: relative;
  height: 30px;
  width: 100%;
}

.trackContainer {
  position: relative;
  width: 100%;
}
import {useSliderState} from '@react-stately/slider';
import {FocusRing} from '@react-aria/focus';
import {VisuallyHidden} from '@react-aria/visually-hidden';
import {I18nProvider} from '@react-aria/i18n';

function Slider(props) {
  let trackRef = React.useRef(null);
  let inputRef = React.useRef(null);
  let origin = props.origin ?? props.minValue ?? 0;

  let multiProps = {
    ...props,
    value: props.value == null ? undefined : [props.value],
    defaultValue: props.defaultValue == null ? undefined : [props.defaultValue],
    onChange:
      props.onChange == null ? undefined : (vals) => props.onChange(vals[0]),
    onChangeEnd:
      props.onChangeEnd == null
        ? undefined
        : (vals) => props.onChangeEnd(vals[0])
  };

  let state = useSliderState(multiProps);
  let {containerProps, trackProps, labelProps} = useSlider(
    multiProps,
    state,
    trackRef
  );

  let {thumbProps, inputProps} = useSliderThumb(
    {
      index: 0,
      isDisabled: props.isDisabled,
      trackRef,
      inputRef
    },
    state
  );

  let value = state.values[0];

  return (
    <I18nProvider locale="en-US">
      <div className="slider" {...containerProps}>
        <div className="sliderLabel">
          {props.label && (
            <label {...labelProps} className="label">
              {props.label}
            </label>
          )}
          <div className="value">{state.getThumbValueLabel(0)}</div>
        </div>
        <div className="trackContainer">
          {
            // We make rail, filledRail, and track siblings in the DOM, so that trackRef has no children.
            // User must click on the trackRef to drag by track, and so it comes last in the DOM.
          }
          <div className="rail" />
          <div
            className="filledRail"
            style={{
              insetInlineStart: `${
                state.getValuePercent(Math.min(value, origin)) * 100
              }%`,
              width: `${
                (state.getValuePercent(Math.max(value, origin)) -
                  state.getValuePercent(Math.min(value, origin))) *
                100
              }%`
            }}
          />
          <div ref={trackRef} className="track" {...trackProps} />
          <FocusRing
            within
            focusRingClass="thumbFocusVisible"
            focusClass="thumbFocused">
            <div
              className="thumb"
              style={{
                insetInlineStart: `${state.getThumbPercent(0) * 100}%`
              }}>
              <div {...thumbProps} className="thumbHandle">
                <VisuallyHidden>
                  <input className="input" ref={inputRef} {...inputProps} />
                </VisuallyHidden>
              </div>
            </div>
          </FocusRing>
        </div>
      </div>
    </I18nProvider>
  );
}

<Slider
  label="Opacity"
  formatOptions={{style: 'percent'}}
  maxValue={1}
  step={0.01}
  onChange={(e) => console.log('onChange', e)}
  onChangeEnd={(e) => console.log('onChangeEnd', e)}
/>
import {useSliderState} from '@react-stately/slider';
import {FocusRing} from '@react-aria/focus';
import {VisuallyHidden} from '@react-aria/visually-hidden';
import {I18nProvider} from '@react-aria/i18n';

function Slider(props) {
  let trackRef = React.useRef(null);
  let inputRef = React.useRef(null);
  let origin = props.origin ?? props.minValue ?? 0;

  let multiProps = {
    ...props,
    value: props.value == null ? undefined : [props.value],
    defaultValue:
      props.defaultValue == null
        ? undefined
        : [props.defaultValue],
    onChange:
      props.onChange == null
        ? undefined
        : (vals) => props.onChange(vals[0]),
    onChangeEnd:
      props.onChangeEnd == null
        ? undefined
        : (vals) => props.onChangeEnd(vals[0])
  };

  let state = useSliderState(multiProps);
  let {containerProps, trackProps, labelProps} = useSlider(
    multiProps,
    state,
    trackRef
  );

  let {thumbProps, inputProps} = useSliderThumb(
    {
      index: 0,
      isDisabled: props.isDisabled,
      trackRef,
      inputRef
    },
    state
  );

  let value = state.values[0];

  return (
    <I18nProvider locale="en-US">
      <div className="slider" {...containerProps}>
        <div className="sliderLabel">
          {props.label && (
            <label {...labelProps} className="label">
              {props.label}
            </label>
          )}
          <div className="value">
            {state.getThumbValueLabel(0)}
          </div>
        </div>
        <div className="trackContainer">
          {
            // We make rail, filledRail, and track siblings in the DOM, so that trackRef has no children.
            // User must click on the trackRef to drag by track, and so it comes last in the DOM.
          }
          <div className="rail" />
          <div
            className="filledRail"
            style={{
              insetInlineStart: `${
                state.getValuePercent(
                  Math.min(value, origin)
                ) * 100
              }%`,
              width: `${
                (state.getValuePercent(
                  Math.max(value, origin)
                ) -
                  state.getValuePercent(
                    Math.min(value, origin)
                  )) *
                100
              }%`
            }}
          />
          <div
            ref={trackRef}
            className="track"
            {...trackProps}
          />
          <FocusRing
            within
            focusRingClass="thumbFocusVisible"
            focusClass="thumbFocused">
            <div
              className="thumb"
              style={{
                insetInlineStart: `${
                  state.getThumbPercent(0) * 100
                }%`
              }}>
              <div {...thumbProps} className="thumbHandle">
                <VisuallyHidden>
                  <input
                    className="input"
                    ref={inputRef}
                    {...inputProps}
                  />
                </VisuallyHidden>
              </div>
            </div>
          </FocusRing>
        </div>
      </div>
    </I18nProvider>
  );
}

<Slider
  label="Opacity"
  formatOptions={{style: 'percent'}}
  maxValue={1}
  step={0.01}
  onChange={(e) => console.log('onChange', e)}
  onChangeEnd={(e) => console.log('onChangeEnd', e)}
/>
import {useSliderState} from '@react-stately/slider';
import {FocusRing} from '@react-aria/focus';
import {VisuallyHidden} from '@react-aria/visually-hidden';
import {I18nProvider} from '@react-aria/i18n';

function Slider(props) {
  let trackRef = React.useRef(
    null
  );
  let inputRef = React.useRef(
    null
  );
  let origin =
    props.origin ??
    props.minValue ??
    0;

  let multiProps = {
    ...props,
    value:
      props.value == null
        ? undefined
        : [props.value],
    defaultValue:
      props.defaultValue ==
      null
        ? undefined
        : [
            props.defaultValue
          ],
    onChange:
      props.onChange ==
      null
        ? undefined
        : (vals) =>
            props.onChange(
              vals[0]
            ),
    onChangeEnd:
      props.onChangeEnd ==
      null
        ? undefined
        : (vals) =>
            props.onChangeEnd(
              vals[0]
            )
  };

  let state = useSliderState(
    multiProps
  );
  let {
    containerProps,
    trackProps,
    labelProps
  } = useSlider(
    multiProps,
    state,
    trackRef
  );

  let {
    thumbProps,
    inputProps
  } = useSliderThumb(
    {
      index: 0,
      isDisabled:
        props.isDisabled,
      trackRef,
      inputRef
    },
    state
  );

  let value =
    state.values[0];

  return (
    <I18nProvider locale="en-US">
      <div
        className="slider"
        {...containerProps}>
        <div className="sliderLabel">
          {props.label && (
            <label
              {...labelProps}
              className="label">
              {
                props.label
              }
            </label>
          )}
          <div className="value">
            {state.getThumbValueLabel(
              0
            )}
          </div>
        </div>
        <div className="trackContainer">
          {
            // We make rail, filledRail, and track siblings in the DOM, so that trackRef has no children.
            // User must click on the trackRef to drag by track, and so it comes last in the DOM.
          }
          <div className="rail" />
          <div
            className="filledRail"
            style={{
              insetInlineStart: `${
                state.getValuePercent(
                  Math.min(
                    value,
                    origin
                  )
                ) * 100
              }%`,
              width: `${
                (state.getValuePercent(
                  Math.max(
                    value,
                    origin
                  )
                ) -
                  state.getValuePercent(
                    Math.min(
                      value,
                      origin
                    )
                  )) *
                100
              }%`
            }}
          />
          <div
            ref={
              trackRef
            }
            className="track"
            {...trackProps}
          />
          <FocusRing
            within
            focusRingClass="thumbFocusVisible"
            focusClass="thumbFocused">
            <div
              className="thumb"
              style={{
                insetInlineStart: `${
                  state.getThumbPercent(
                    0
                  ) * 100
                }%`
              }}>
              <div
                {...thumbProps}
                className="thumbHandle">
                <VisuallyHidden>
                  <input
                    className="input"
                    ref={
                      inputRef
                    }
                    {...inputProps}
                  />
                </VisuallyHidden>
              </div>
            </div>
          </FocusRing>
        </div>
      </div>
    </I18nProvider>
  );
}

<Slider
  label="Opacity"
  formatOptions={{
    style: 'percent'
  }}
  maxValue={1}
  step={0.01}
  onChange={(e) =>
    console.log(
      'onChange',
      e
    )
  }
  onChangeEnd={(e) =>
    console.log(
      'onChangeEnd',
      e
    )
  }
/>

Internationalization#


Value formatting#

Formatting the value that should be displayed in the value label or aria-valuetext is handled by useSliderState. The formatting can be controlled using the formatOptions prop. For this to work, the I18nProvider must be somewhere in the hierarchy above the Slider. This will tell the formatter what locale to use.

RTL#

In right-to-left languages, the slider should be mirrored. The label is right-aligned, the value is left-aligned. Ensure that your CSS accounts for this.