Skip to content

Slider

A single-thumb or dual-thumb slider for selecting a numeric value or a numeric range. Form-associated with native <form> support.

  • Single mode (default) — one thumb; use value and name for form integration.
  • Range mode (range attribute) — two independent thumbs; use from and to to set bounds.

Features

  • 6 Semantic Colors — primary, secondary, info, success, warning, error
  • 3 Sizes — sm, md, lg
  • Range Mode — two-thumb selection with from/to bounds
  • Keyboard Navigation — Arrow keys step the value; Home/End jump to min/max
  • Touch Support — Smooth pointer-event dragging on mobile
  • ARIA Sliderrole="slider" with aria-valuenow, aria-valuemin, aria-valuemax
  • Flexible Bounds — Configurable min, max, and step
  • Customizable — CSS custom properties for track, fill, and thumb colors

Source Code

View Source Code
ts
import { createStableId, define, useField, html, inject, prop, ref, syncAria } from '@vielzeug/craft';
import { computed, signal, watch } from '@vielzeug/ripple';

import type { ComponentSize, ThemeColor } from '../../types';

import { createSliderControl } from '../../headless';
import { disablableBundle, sizableBundle, SLIDER_SIZE_PRESET, themableBundle } from '../../shared';
import { coarsePointerMixin, colorThemeMixin, disabledStateMixin, sizeVariantMixin } from '../../styles';
import { FORM_CTX, useFormContext } from '../shared/form-context';
import componentStyles from './slider.css?inline';

const guard =
  <E extends Event = Event>(condition: () => unknown, handler: (e: E) => void): ((e: E) => void) =>
  (e) => {
    if (condition()) handler(e);
  };

/** Slider component properties */

export type SgSliderEvents = {
  change: { from?: number; originalEvent?: Event; to?: number; value: number | { from: number; to: number } };
};

export type SgSliderProps = {
  /** Theme color */
  color?: ThemeColor;
  /** Disable interaction */
  disabled?: boolean;
  /** Range mode: lower bound */
  from?: number | string;
  /** Range mode a11y label for the start thumb (e.g. "$20") */
  'from-value-text'?: string;
  /** Maximum value */
  max?: number | string;
  /** Minimum value */
  min?: number | string;
  /** Single-value mode: form field name */
  name?: string;
  /** Activate two-thumb range selection */
  range?: boolean;
  /** Component size */
  size?: ComponentSize;
  /** Step increment */
  step?: number | string;
  /** Range mode: upper bound */
  to?: number | string;
  /** Range mode a11y label for the end thumb (e.g. "$80") */
  'to-value-text'?: string;
  /** Single-value mode: current value */
  value?: number | string;
  /** Single-value mode a11y label override (e.g. "75%"). Overrides raw aria-valuenow. */
  'value-text'?: string;
};

/**
 * A slider for selecting a single numeric value or a numeric range.
 *
 * Add the boolean `range` attribute to activate two-thumb range mode.
 *
 * @element sg-slider
 *
 * @attr {number}  min   - Minimum value (default: 0)
 * @attr {number}  max   - Maximum value (default: 100)
 * @attr {number}  step  - Step increment (default: 1)
 * @attr {number}  value - Current value (single mode)
 * @attr {number}  from  - Lower bound (range mode)
 * @attr {number}  to    - Upper bound (range mode)
 * @attr {boolean} range - Activate range mode
 * @attr {boolean} disabled - Disable interaction
 * @attr {string}  name  - Form field name (single mode)
 * @attr {string}  color - Theme color: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'
 * @attr {string}  size  - 'sm' | 'md' | 'lg'
 *
 * @fires change - detail always includes `value`; single mode: { value: number }, range mode: { value: { from, to }, from, to }, plus optional originalEvent
 *
 * @slot - Slider label text
 *
 * @part slider      - Slider container
 * @part track       - Track element
 * @part fill        - Fill element
 * @part thumb       - Single-value thumb
 * @part thumb-start - Range start thumb
 * @part thumb-end   - Range end thumb
 * @part label       - Label element
 *
 * @cssprop --slider-height    - Track height
 * @cssprop --slider-size      - Thumb dimensions
 * @cssprop --slider-track-bg  - Track background color
 * @cssprop --slider-fill      - Active fill color
 * @cssprop --slider-thumb-bg  - Thumb background color
 *
 * @example
 * ```html
 * <sg-slider value="50" name="volume">Volume</sg-slider>
 * <sg-slider range from="20" to="80" color="primary">Price range</sg-slider>
 * ```
 */
export const SLIDER_TAG = 'sg-slider' as const;
define<SgSliderProps, SgSliderEvents>(SLIDER_TAG, {
  formAssociated: true,
  props: {
    ...themableBundle,
    ...sizableBundle,
    ...disablableBundle,
    from: prop.number(0),
    'from-value-text': prop.string(),
    max: prop.number(100),
    min: prop.number(0),
    name: prop.string(),
    range: prop.bool(false),
    step: prop.number(1),
    to: prop.number(100),
    'to-value-text': prop.string(),
    value: prop.number(0),
    'value-text': prop.string(),
  },
  setup(props, { bind, el, emit, onEvent, onMounted, slots }) {
    // Treat `range` as static — determined at first render
    const isRange = props.range.value;
    // ── Shared helpers ────────────────────────────────────────────
    const sliderControl = createSliderControl({
      max: props.max,
      min: props.min,
      step: props.step,
    });
    // ── Single-value state ────────────────────────────────────────
    const formCtx = inject(FORM_CTX);
    const fCtxProps = useFormContext(bind, props, formCtx);
    const isDragging = signal(false);
    const isDisabled = fCtxProps.disabled;
    const labelledById = signal<string | undefined>(undefined);

    bind({
      attr: {
        'data-dragging': () => (isDragging.value ? true : undefined),
        size: fCtxProps.size,
      },
    });

    let sliderFd:
      | {
          reportValidity: () => boolean;
        }
      | undefined;
    const valueSignal = signal('0');

    if (!isRange) {
      sliderFd = useField({ disabled: isDisabled, value: valueSignal });
      watch(
        props.value,
        (v) => {
          valueSignal.value = String(v);
        },
        { immediate: true },
      );
      bind({
        attr: {
          ariaDisabled: () => (isDisabled.value ? 'true' : null),
          ariaLabelledby: () => labelledById.value ?? null,
          ariaValuemax: () => sliderControl.max(),
          ariaValuemin: () => sliderControl.min(),
          ariaValuenow: () => Number(valueSignal.value || 0),
          ariaValuetext: () => props['value-text'].value ?? null,
          role: () => 'slider',
          tabindex: () => (isDisabled.value ? null : '0'),
        },
      });
    }

    // ── Range state ───────────────────────────────────────────────
    const startVal = signal(0);
    const endVal = signal(100);

    if (isRange) {
      sliderFd = useField<{
        from: number;
        to: number;
      }>({
        disabled: isDisabled,
        toFormValue: ({ from, to }) => {
          const name = props.name.value;

          if (!name) return null;

          const fd = new FormData();

          fd.append(`${name}[from]`, String(from));
          fd.append(`${name}[to]`, String(to));

          return fd;
        },
        value: computed(() => ({ from: startVal.value, to: endVal.value })),
      });
      watch(
        props.from,
        (v) => {
          startVal.value = sliderControl.snap(Number(v));
        },
        { immediate: true },
      );
      watch(
        props.to,
        (v) => {
          endVal.value = sliderControl.snap(Number(v));
        },
        { immediate: true },
      );
    }

    // ── Refs ──────────────────────────────────────────────────────
    const containerRef = ref<HTMLDivElement>();
    const labelRef = ref<HTMLSpanElement>();
    const thumbStartRef = ref<HTMLDivElement>();
    const thumbEndRef = ref<HTMLDivElement>();
    const startId = createStableId('slider-start');
    const endId = createStableId('slider-end');
    // ── CSS update helpers ────────────────────────────────────────
    const updateSingleCSS = (value: number) => {
      const pct = sliderControl.toPercent(value);

      el.style.setProperty('--_thumb-pos', `${pct}%`);
      el.style.setProperty('--_fill-start', '0%');
      el.style.setProperty('--_fill-width', `${pct}%`);
    };
    const updateRangeCSS = () => {
      const s = sliderControl.toPercent(startVal.value);
      const e = sliderControl.toPercent(endVal.value);

      el.style.setProperty('--_thumb-start', `${s}%`);
      el.style.setProperty('--_thumb-end', `${e}%`);
      el.style.setProperty('--_fill-start', `${s}%`);
      el.style.setProperty('--_fill-width', `${e - s}%`);
    };

    // ── Range mode setup ──────────────────────────────────────────
    const triggerValidation = (on: 'blur' | 'change') => {
      if (formCtx?.validateOn?.value === on) {
        sliderFd?.reportValidity();
      }
    };

    const setupRangeMode = (container: HTMLDivElement) => {
      updateRangeCSS();

      const clientToValue = (clientX: number) => {
        const rect = container.getBoundingClientRect();

        return sliderControl.fromClientX(clientX, rect);
      };
      let dragging: 'start' | 'end' | null = null;
      const applyDrag = (val: number) => {
        if (dragging === 'start') startVal.value = Math.min(sliderControl.snap(val), endVal.value);
        else if (dragging === 'end') endVal.value = Math.max(sliderControl.snap(val), startVal.value);

        startVal.value = sliderControl.clamp(startVal.value);
        endVal.value = sliderControl.clamp(endVal.value);
        updateRangeCSS();
        emit('change', {
          from: startVal.value,
          to: endVal.value,
          value: { from: startVal.value, to: endVal.value },
        });
        triggerValidation('change');
      };

      onEvent(
        container,
        'pointerdown',
        guard(
          () => !isDisabled.value,
          (e: PointerEvent) => {
            e.preventDefault();

            const val = clientToValue(e.clientX);

            dragging = Math.abs(val - startVal.value) <= Math.abs(val - endVal.value) ? 'start' : 'end';
            (e.target as Element).setPointerCapture(e.pointerId);
            applyDrag(val);
          },
        ),
      );
      onEvent(
        container,
        'pointermove',
        guard(
          () => !!dragging,
          (e: PointerEvent) => {
            e.preventDefault();

            if (!isDragging.value) isDragging.value = true;

            applyDrag(clientToValue(e.clientX));
          },
        ),
      );
      onEvent(
        container,
        'pointerup',
        guard(
          () => !!dragging,
          (e: PointerEvent) => {
            e.preventDefault();
            dragging = null;
            isDragging.value = false;
            (e.target as Element).releasePointerCapture(e.pointerId);
          },
        ),
      );

      const makeThumbKeydown = (getVal: () => number, setVal: (v: number) => void) => (e: KeyboardEvent) => {
        if (isDisabled.value) return;

        const next = sliderControl.nextFromKey(e.key, getVal());

        if (next === null) return;

        e.preventDefault();
        setVal(sliderControl.snap(next));
        updateRangeCSS();
        emit('change', {
          from: startVal.value,
          originalEvent: e,
          to: endVal.value,
          value: { from: startVal.value, to: endVal.value },
        });
        triggerValidation('change');
      };
      const thumbStartEl = thumbStartRef.value;
      const thumbEndEl = thumbEndRef.value;

      if (thumbStartEl) {
        onEvent(
          thumbStartEl,
          'keydown',
          makeThumbKeydown(
            () => startVal.value,
            (v) => {
              startVal.value = Math.min(v, endVal.value);
            },
          ),
        );
        syncAria(thumbStartEl, {
          label: 'Range start',
          valuemax: () => endVal.value,
          valuemin: () => sliderControl.min(),
          valuenow: () => startVal.value,
          valuetext: () => props['from-value-text'].value ?? null,
        });
      }

      if (thumbEndEl) {
        onEvent(
          thumbEndEl,
          'keydown',
          makeThumbKeydown(
            () => endVal.value,
            (v) => {
              endVal.value = Math.max(v, startVal.value);
            },
          ),
        );
        syncAria(thumbEndEl, {
          label: 'Range end',
          valuemax: () => sliderControl.max(),
          valuemin: () => startVal.value,
          valuenow: () => endVal.value,
          valuetext: () => props['to-value-text'].value ?? null,
        });
      }
    };
    // ── Single-value mode setup ───────────────────────────────────
    const setupSingleMode = (container: HTMLDivElement) => {
      updateSingleCSS(Number(valueSignal.value));

      const updateValue = (clientX: number) => {
        if (isDisabled.value) return;

        const rect = container.getBoundingClientRect();
        const newValue = sliderControl.fromClientX(clientX, rect);

        if (Number(valueSignal.value) !== newValue) {
          valueSignal.value = newValue.toString();
          updateSingleCSS(newValue);
          emit('change', { value: newValue });
          triggerValidation('change');
        }
      };
      let isPointerDragging = false;

      onEvent(
        container,
        'pointerdown',
        guard(
          () => !isDisabled.value,
          (e: PointerEvent) => {
            e.preventDefault();
            isPointerDragging = true;
            updateValue(e.clientX);
            (e.target as Element).setPointerCapture(e.pointerId);
          },
        ),
      );
      onEvent(
        container,
        'pointermove',
        guard(
          () => isPointerDragging,
          (e: PointerEvent) => {
            e.preventDefault();

            if (!isDragging.value) isDragging.value = true;

            updateValue(e.clientX);
          },
        ),
      );
      onEvent(
        container,
        'pointerup',
        guard(
          () => isPointerDragging,
          (e: PointerEvent) => {
            e.preventDefault();
            isPointerDragging = false;
            isDragging.value = false;
            (e.target as Element).releasePointerCapture(e.pointerId);
          },
        ),
      );
      onEvent(
        el,
        'keydown',
        guard(
          () => !isDisabled.value,
          (e: KeyboardEvent) => {
            const val = Number(valueSignal.value || 0);
            const next = sliderControl.nextFromKey(e.key, val);

            if (next === null) return;

            e.preventDefault();

            const newValue = sliderControl.clamp(next);

            if (newValue !== val) {
              valueSignal.value = newValue.toString();
              updateSingleCSS(newValue);
              emit('change', { originalEvent: e, value: newValue });
              triggerValidation('change');
            }
          },
        ),
      );
    };

    onMounted(() => {
      const container = containerRef.value;

      if (!container) return;

      if (slots.has().value && labelRef.value) {
        const labelId = createStableId('slider-label');

        labelRef.value.id = labelId;

        if (!isRange) labelledById.value = labelId;
      }

      if (isRange) setupRangeMode(container);
      else setupSingleMode(container);
    });

    return html`
      <div class="slider-container" part="slider" ref=${containerRef}>
        <div class="slider-track" part="track">
          <div class="slider-fill" part="fill"></div>
          <div class="slider-thumb slider-thumb-sole" part="thumb"></div>
          <div
            class="slider-thumb slider-thumb-start"
            part="thumb-start"
            ref=${thumbStartRef}
            role="slider"
            tabindex="${() => (isDisabled.value ? '-1' : '0')}"
            id="${startId}"></div>
          <div
            class="slider-thumb slider-thumb-end"
            part="thumb-end"
            ref=${thumbEndRef}
            role="slider"
            tabindex="${() => (isDisabled.value ? '-1' : '0')}"
            id="${endId}"></div>
        </div>
      </div>
      <span class="label" part="label" ref=${labelRef}><slot></slot></span>
    `;
  },
  styles: [
    disabledStateMixin,
    colorThemeMixin,
    sizeVariantMixin(SLIDER_SIZE_PRESET),
    componentStyles,
    coarsePointerMixin,
  ],
});

Basic Usage

html
<sg-slider value="50">Volume</sg-slider>

Visual Options

Colors

PreviewCode
RTL

Sizes

PreviewCode
RTL

Min, Max & Step

PreviewCode
RTL

States

Disabled

PreviewCode
RTL

Range Mode

Add the boolean range attribute to enable two-thumb mode. Use from and to to set the initial lower and upper bounds.

html
<sg-slider range from="20" to="80">Price range</sg-slider>
PreviewCode
RTL

In range mode, the change event fires with { from, to }:

js
document.getElementById('price').addEventListener('change', (e) => {
  if ('from' in e.detail) {
    // Range mode
    console.log(`Range: ${e.detail.from} – ${e.detail.to}`);
  } else {
    // Single mode
    console.log('Value:', e.detail.value);
  }
});

Accessible Labels

Use value-text (single mode) or from-value-text / to-value-text (range mode) to give screen readers a readable version of the value — useful when the raw number needs a unit or currency symbol.

html
<!-- Single mode: announce "75%" instead of "75" -->
<sg-slider value="75" value-text="75%" color="primary">Volume</sg-slider>

<!-- Range mode: announce "$20 – $80" -->
<sg-slider range from="20" to="80" min="0" max="100" from-value-text="$20" to-value-text="$80" color="primary">
  Price range
</sg-slider>

Form Integration

sg-slider is form-associated in single mode. The name attribute is submitted as part of FormData.

html
<form id="settings-form">
  <sg-slider name="volume" min="0" max="100" value="70">Volume</sg-slider>
  <sg-slider name="bass" min="-10" max="10" value="0">Bass</sg-slider>
  <sg-button type="submit">Save</sg-button>
</form>

<script type="module">
  import '@vielzeug/sigil/slider';
  import '@vielzeug/sigil/button';

  document.getElementById('settings-form').addEventListener('submit', (e) => {
    e.preventDefault();
    const data = new FormData(e.target);
    console.log('volume:', data.get('volume'));
    console.log('bass:', data.get('bass'));
  });
</script>

API Reference

Attributes

AttributeTypeDefaultDescription
valuenumber0Current value (single mode)
fromnumber0Lower bound value (range mode)
tonumber100Upper bound value (range mode)
rangebooleanfalseActivate two-thumb range mode
minnumber0Minimum allowed value
maxnumber100Maximum allowed value
stepnumber1Value increment/decrement step
disabledbooleanfalseDisable slider interaction
namestringForm field name (single mode only)
color'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'error'Semantic color
size'sm' | 'md' | 'lg''md'Slider size
value-textstringHuman-readable ARIA value label for single mode (e.g. "75%")
from-value-textstringHuman-readable ARIA label for the start thumb in range mode (e.g. "$20")
to-value-textstringHuman-readable ARIA label for the end thumb in range mode (e.g. "$80")

Slots

SlotDescription
(default)Slider label content

Parts

PartDescription
sliderThe outer slider container
trackThe slider track
fillThe filled portion of the track
thumbThe single-value thumb
thumb-startThe range start (lower) thumb
thumb-endThe range end (upper) thumb
labelThe label element

Events

EventDetail — single modeDetail — range modeDescription
change{ value: number }{ from: number, to: number }Emitted when value changes

CSS Custom Properties

PropertyDescriptionDefault
--slider-heightHeight of the slider trackSize-dependent
--slider-sizeThumb diameterSize-dependent
--slider-track-bgTrack background colorvar(--color-contrast-300)
--slider-fillActive fill colorTheme color
--slider-thumb-bgThumb background colorvar(--color-contrast-100)

Accessibility

The slider component follows WAI-ARIA best practices.

sg-slider

Keyboard Navigation
  • Arrow Right / Arrow Up — increase value by one step
  • Arrow Left / Arrow Down — decrease value by one step
  • Home — jump to minimum value
  • End — jump to maximum value
Screen Readers
  • The thumb has role="slider" with aria-valuenow, aria-valuemin, and aria-valuemax.
  • Provide value-text or from-value-text / to-value-text when the raw number needs a unit (e.g. "$80", "75%").
  • In range mode, each thumb has its own accessible label and independent ARIA attributes.
  • aria-disabled is set when disabled is active.
Touch & Focus
  • Touch-friendly draggable thumb with a minimum 44 × 44 px hit area.
  • Tab focuses the slider; Shift+Tab blurs it.

Best Practices

Do:

  • Always provide a visible label via the default slot to describe what the slider controls.
  • Use value-text / from-value-text / to-value-text when the value needs a unit or currency symbol.
  • Keep min, max, and step values consistent and predictable for users.
  • Use range mode for "between X and Y" inputs like price ranges or date spans.

Don't:

  • Use a slider for a small, discrete set of options — a sg-select or sg-radio-group is clearer.
  • Omit value-text when using fractional step values — screen readers announce the raw float verbatim.