Slider

An input where the user selects a value from within a given range.

PreviousNext
import { Component } from '@angular/core';
 
import { ZardSliderComponent } from '../slider.component';
 
@Component({
  selector: 'z-demo-slider-default',
  standalone: true,
  imports: [ZardSliderComponent],
  template: `
    <div class="preview flex min-h-[350px] w-full items-center justify-center p-10">
      <z-slider [class]="'w-[60%]'" zDefault="50" />
    </div>
  `,
})
export class ZardDemoSliderDefaultComponent {}
 

Installation

1

Run the CLI

Use the CLI to add the component to your project.

npx @ngzard/ui add slider
1

Add the component files

Create the component directory structure and add the following files to your project.

slider.component.ts
slider.component.ts
import { fromEvent, map, Subject, switchMap, takeUntil, tap } from 'rxjs';
 
import { DOCUMENT } from '@angular/common';
import {
  type AfterViewInit,
  booleanAttribute,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  computed,
  ElementRef,
  forwardRef,
  inject,
  input,
  linkedSignal,
  numberAttribute,
  type OnChanges,
  type OnDestroy,
  output,
  signal,
  type SimpleChanges,
  viewChild,
  ViewEncapsulation,
} from '@angular/core';
import { type ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 
import { clamp, convertValueToPercentage, roundToStep } from '../../shared/utils/number';
import { mergeClasses } from '../../shared/utils/utils';
import { sliderOrientationVariants, sliderRangeVariants, sliderThumbVariants, sliderTrackVariants, sliderVariants } from './slider.variants';
 
import type { ClassValue } from 'clsx';
 
type OnTouchedType = () => void;
type OnChangeType = (value: number) => void;
 
@Component({
  selector: 'z-slider-track',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [],
  template: `
    <span #track data-slot="slider-track" [attr.data-orientation]="orientation()" [class]="classes()">
      <ng-content></ng-content>
    </span>
  `,
  host: {
    '[class]': '"data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full"',
    '[attr.data-orientation]': 'orientation()',
  },
})
export class ZSliderTrackComponent {
  readonly orientation = input<'horizontal' | 'vertical'>('horizontal');
  readonly class = input<ClassValue>('');
 
  protected readonly classes = computed(() => mergeClasses(sliderTrackVariants({ zOrientation: this.orientation() }), this.class()));
 
  private readonly trackEl = viewChild.required<ElementRef<HTMLElement>>('track');
 
  get nativeElement(): HTMLElement {
    return this.trackEl().nativeElement;
  }
}
 
@Component({
  selector: 'z-slider-range',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [],
  template: `
    <span
      data-slot="slider-range"
      [attr.data-orientation]="orientation()"
      [class]="classes()"
      [style.left]="orientation() === 'horizontal' ? '0' : null"
      [style.right]="orientation() === 'horizontal' ? 100 - percent() + '%' : null"
      [style.bottom]="orientation() === 'vertical' ? '0' : null"
      [style.top]="orientation() === 'vertical' ? 100 - percent() + '%' : null"
    ></span>
  `,
})
export class ZSliderRangeComponent {
  readonly percent = input(0);
 
  readonly orientation = input<'horizontal' | 'vertical'>('horizontal');
  readonly class = input<ClassValue>('');
 
  protected readonly classes = computed(() => mergeClasses(sliderRangeVariants({ zOrientation: this.orientation() }), this.class()));
}
 
@Component({
  selector: 'z-slider-thumb',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [],
  template: `
    <span
      #thumb
      data-slot="slider-thumb"
      [attr.role]="'slider'"
      [attr.aria-valuemin]="min()"
      [attr.aria-valuemax]="max()"
      [attr.aria-valuenow]="value()"
      [attr.aria-disabled]="disabled() ? true : null"
      [class]="classes()"
      tabindex="0"
    ></span>
  `,
  host: {
    '[class]': 'orientationClasses()',
    '[style.left]': 'orientation() === "horizontal" ? "calc(" + percent() + "% + " + offset() + "px)" : null',
    '[style.bottom]': 'orientation() === "vertical" ? "calc(" + percent() + "% + " + offset() + "px)" : null',
  },
})
export class ZSliderThumbComponent {
  readonly value = input(0);
  readonly min = input(0);
  readonly max = input(100);
  readonly disabled = input(false);
  readonly percent = input(0);
  readonly offset = input(0);
 
  readonly orientation = input<'horizontal' | 'vertical'>('horizontal');
  readonly class = input<ClassValue>('');
 
  protected readonly classes = computed(() => mergeClasses(sliderThumbVariants(), this.class()));
  protected readonly orientationClasses = computed(() => mergeClasses(sliderOrientationVariants({ zOrientation: this.orientation() })));
 
  private readonly thumbEl = viewChild.required<ElementRef<HTMLElement>>('thumb');
 
  get nativeElement(): HTMLElement {
    return this.thumbEl().nativeElement;
  }
}
 
@Component({
  selector: 'z-slider',
  exportAs: 'zSlider',
  standalone: true,
  changeDetection: ChangeDetectionStrategy.OnPush,
  encapsulation: ViewEncapsulation.None,
  imports: [ZSliderTrackComponent, ZSliderRangeComponent, ZSliderThumbComponent],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => ZardSliderComponent),
      multi: true,
    },
  ],
  template: `
    <span
      data-slot="slider"
      [attr.data-orientation]="zOrientation()"
      class="flex data-[orientation=horizontal]:items-center data-[orientation=vertical]:justify-center data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full"
    >
      <z-slider-track [orientation]="zOrientation()">
        <z-slider-range [orientation]="zOrientation()" [percent]="percentValue()"></z-slider-range>
      </z-slider-track>
 
      <z-slider-thumb
        tabindex="0"
        [orientation]="zOrientation()"
        [percent]="percentValue()"
        [offset]="thumbOffset()"
        [value]="lastEmittedValue()"
        [min]="zMin()"
        [max]="zMax()"
        [disabled]="disabled()"
        (keydown)="handleKeydown($event)"
      ></z-slider-thumb>
    </span>
  `,
  host: {
    '[class]': 'classes()',
    '[attr.data-orientation]': 'zOrientation()',
    '[attr.aria-disabled]': 'disabled() ? true : null',
    '[attr.data-disabled]': 'disabled() ? true : null',
  },
})
export class ZardSliderComponent implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy {
  readonly zMin = input(0, { transform: numberAttribute });
  readonly zMax = input(100, { transform: numberAttribute });
  readonly zDefault = input(0, { transform: numberAttribute });
  readonly zValue = input(null, { transform: numberAttribute });
  readonly zStep = input(1, { transform: numberAttribute });
  readonly zDisabled = input(false, { transform: booleanAttribute });
 
  readonly zOrientation = input<'horizontal' | 'vertical'>('horizontal');
  readonly class = input<ClassValue>('');
 
  readonly onSlide = output<number>();
 
  readonly thumbRef = viewChild.required(ZSliderThumbComponent);
  readonly trackRef = viewChild.required(ZSliderTrackComponent);
 
  private elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
  private cdr = inject(ChangeDetectorRef);
  private document = inject(DOCUMENT);
 
  protected readonly classes = computed(() => mergeClasses(sliderVariants({ orientation: this.zOrientation() }), this.class()));
  protected disabled = linkedSignal(() => this.zDisabled());
 
  readonly percentValue = signal(50);
  readonly lastEmittedValue = signal(0);
 
  readonly thumbOffset = signal(0);
 
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  private onTouched: OnTouchedType = () => {};
  // eslint-disable-next-line @typescript-eslint/no-empty-function
  private onChange: OnChangeType = () => {};
 
  private destroy$ = new Subject<void>();
 
  ngOnChanges(changes: SimpleChanges): void {
    if ('zValue' in changes && !changes['zValue'].firstChange) {
      const value = this.zValue();
      if (value !== this.lastEmittedValue()) {
        this.setInitialValue();
      }
    }
  }
 
  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
 
  ngAfterViewInit() {
    const pointerDown$ = fromEvent<PointerEvent>(this.elementRef.nativeElement, 'pointerdown').pipe(
      tap(event => {
        if (this.disabled()) return;
 
        const target = event.target as HTMLElement;
        const isThumb = this.thumbRef().nativeElement.contains(target);
        const isTrack = this.trackRef().nativeElement.contains(target);
 
        if (isTrack && !isThumb) {
          const coord = this.zOrientation() === 'vertical' ? event.clientY : event.clientX;
          const clickPercentage = this.calculatePercentage(coord);
          this.updateSliderFromPercentage(clickPercentage);
          this.onTouched();
          requestAnimationFrame(() => {
            this.thumbRef().nativeElement.focus();
          });
        }
      }),
    );
 
    const pointerMove$ = fromEvent<PointerEvent>(this.document, 'pointermove');
    const pointerUp$ = fromEvent<PointerEvent>(this.document, 'pointerup');
 
    pointerDown$
      .pipe(
        switchMap(() =>
          pointerMove$.pipe(
            takeUntil(pointerUp$),
            takeUntil(this.destroy$),
            map(event => {
              const coord = this.zOrientation() === 'vertical' ? event.clientY : event.clientX;
              return this.calculatePercentage(coord);
            }),
          ),
        ),
        takeUntil(this.destroy$),
      )
      .subscribe(percentage => {
        if (this.disabled()) return;
        this.updateSliderFromPercentage(percentage);
        this.onTouched();
      });
 
    this.setInitialValue();
  }
 
  writeValue(value: number): void {
    if (value == null) {
      this.setInitialValue();
      return;
    }
 
    const min = this.zMin();
    const max = this.zMax();
    const step = this.zStep();
 
    const clampedValue = clamp(value, [min, max]);
    const roundedValue = roundToStep(clampedValue, min, step);
 
    if (roundedValue === this.lastEmittedValue()) return;
 
    this.percentValue.set(convertValueToPercentage(roundedValue, min, max));
    this.lastEmittedValue.set(roundedValue);
    this.cdr.markForCheck();
  }
 
  registerOnChange(fn: (value: number) => void): void {
    this.onChange = fn;
  }
 
  registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
 
  setDisabledState(isDisabled: boolean): void {
    this.disabled.set(isDisabled);
    this.cdr.markForCheck();
  }
 
  handleKeydown(event: KeyboardEvent): void {
    if (this.disabled()) return;
 
    const percent = this.percentValue();
    const rawValue = this.zMin() + ((this.zMax() - this.zMin()) * percent) / 100;
    const currentValue = roundToStep(rawValue, this.zMin(), this.zStep());
 
    let newValue = currentValue;
 
    switch (event.key) {
      case 'Home':
        newValue = this.zMin();
        break;
      case 'End':
        newValue = this.zMax();
        break;
      case 'ArrowLeft':
        newValue = Math.max(currentValue - this.zStep(), this.zMin());
        break;
      case 'ArrowRight':
        newValue = Math.min(currentValue + this.zStep(), this.zMax());
        break;
      case 'ArrowDown':
        newValue = Math.max(currentValue - this.zStep(), this.zMin());
        break;
      case 'ArrowUp':
        newValue = Math.min(currentValue + this.zStep(), this.zMax());
        break;
      default:
        return;
    }
 
    this.percentValue.set(convertValueToPercentage(newValue, this.zMin(), this.zMax()));
    this.onSlide.emit(newValue);
    this.lastEmittedValue.set(newValue);
    this.onChange(newValue);
    this.cdr.markForCheck();
    event.preventDefault();
  }
 
  private updateSliderFromPercentage(percentage: number): void {
    const clamped = clamp(percentage, [0, 1]);
    const rawValue = this.zMin() + (this.zMax() - this.zMin()) * clamped;
    const value = roundToStep(rawValue, this.zMin(), this.zStep());
 
    if (value !== this.lastEmittedValue()) {
      this.percentValue.set(convertValueToPercentage(value, this.zMin(), this.zMax()));
      this.onSlide.emit(value);
      this.lastEmittedValue.set(value);
      this.onChange(value);
      this.cdr.markForCheck();
    }
  }
 
  private calculatePercentage(clientCoord: number): number {
    const rect = this.elementRef.nativeElement.getBoundingClientRect();
    if (this.zOrientation() === 'vertical') {
      const relativeY = (clientCoord - rect.top) / rect.height;
      return clamp(1 - relativeY, [0, 1]);
    }
    const relativeX = (clientCoord - rect.left) / rect.width;
    return clamp(relativeX, [0, 1]);
  }
 
  private setInitialValue(): void {
    const min = this.zMin();
    const max = this.zMax();
    const step = this.zStep();
 
    const def = clamp(this.zDefault(), [min, max]);
    const raw = this.zValue();
    const value = raw != null && raw >= min && raw <= max ? raw : def;
 
    const initial = roundToStep(value, min, step);
    this.percentValue.set(convertValueToPercentage(initial, min, max));
    this.lastEmittedValue.set(initial);
    this.thumbOffset.set(0);
  }
}
 
slider.variants.ts
slider.variants.ts
import { cva, type VariantProps } from 'class-variance-authority';
 
export const sliderVariants = cva(
  'relative flex w-full touch-none items-center select-none data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col',
  {
    variants: {
      orientation: {
        horizontal: 'items-center',
        vertical: 'flex-col h-full min-h-44 w-auto',
      },
      disabled: {
        true: 'opacity-50 pointer-events-none',
        false: '',
      },
    },
    defaultVariants: {
      orientation: 'horizontal',
      disabled: false,
    },
  },
);
 
export type SliderVariants = VariantProps<typeof sliderVariants>;
 
export const sliderTrackVariants = cva(
  'flex bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5',
  {
    variants: {
      zOrientation: {
        horizontal: 'h-1.5 w-full',
        vertical: 'w-1.5 h-full min-h-44',
      },
    },
    defaultVariants: {
      zOrientation: 'horizontal',
    },
  },
);
 
export type SliderTrackVariants = VariantProps<typeof sliderTrackVariants>;
 
export const sliderRangeVariants = cva('bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full', {
  variants: {
    zOrientation: {
      horizontal: 'h-full',
      vertical: 'w-full',
    },
  },
  defaultVariants: {
    zOrientation: 'horizontal',
  },
});
 
export type SliderRangeVariants = VariantProps<typeof sliderRangeVariants>;
 
export const sliderThumbVariants = cva(
  'border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50',
);
 
export type SliderThumbVariants = VariantProps<typeof sliderThumbVariants>;
 
export const sliderOrientationVariants = cva('absolute', {
  variants: {
    zOrientation: {
      horizontal: 'translate-x-[-50%]',
      vertical: 'translate-y-[50%]',
    },
  },
  defaultVariants: {
    zOrientation: 'horizontal',
  },
});
 
export type SliderOrientationVariants = VariantProps<typeof sliderOrientationVariants>;
 

Examples

default

import { Component } from '@angular/core';
 
import { ZardSliderComponent } from '../slider.component';
 
@Component({
  selector: 'z-demo-slider-default',
  standalone: true,
  imports: [ZardSliderComponent],
  template: `
    <div class="preview flex min-h-[350px] w-full items-center justify-center p-10">
      <z-slider [class]="'w-[60%]'" zDefault="50" />
    </div>
  `,
})
export class ZardDemoSliderDefaultComponent {}
 

disabled

import { Component } from '@angular/core';
 
import { ZardSliderComponent } from '../slider.component';
 
@Component({
  selector: 'z-demo-slider-disabled',
  standalone: true,
  imports: [ZardSliderComponent],
  template: `
    <div class="preview flex min-h-[350px] w-full items-center justify-center p-10">
      <z-slider [class]="'w-[60%]'" zDefault="50" zDisabled="true" />
    </div>
  `,
})
export class ZardDemoSliderDisabledComponent {}
 

min max

import { Component } from '@angular/core';
 
import { ZardSliderComponent } from '../slider.component';
 
@Component({
  selector: 'z-demo-slider-min-max',
  standalone: true,
  imports: [ZardSliderComponent],
  template: `
    <div class="preview flex min-h-[350px] w-full items-center justify-center p-10">
      <z-slider [class]="'w-[60%]'" zStep="10" zMin="30" zMax="120" />
    </div>
  `,
})
export class ZardDemoSliderMinMaxComponent {}
 

vertical

import { Component } from '@angular/core';
 
import { ZardSliderComponent } from '../slider.component';
 
@Component({
  selector: 'z-demo-slider-vertical',
  standalone: true,
  imports: [ZardSliderComponent],
  template: `
    <div class="preview flex h-[350px] w-full items-center justify-center p-10">
      <z-slider [class]="'w-[60%]'" zDefault="100" zMin="30" zMax="120" zOrientation="vertical" />
    </div>
  `,
})
export class ZardDemoSliderVerticalComponent {}
 

API

[z-slider] Component

z-slider is a flexible and accessible component that allows users to select a numeric value from within a configurable range using pointer or keyboard interaction.

Property Description Type Default
[class] Custom CSS classes string ''
[zMin] Minimum selectable value number 0
[zMax] Maximum selectable value number 100
[zDefault] Default value when zValue is absent number 0
[zValue] Controlled value input number | null null
[zStep] Step increment for the value number 1
[zDisabled] Disables slider interaction boolean false
[zOrientation] Slider orientation horizontal | vertical 'horizontal'

Events

Event Description Type
(onSlide) Emitted when the slider value changes number