Slider
Picks a number, or a range with two thumbs, by dragging along a track or with the arrow keys.
- v1.0.0
- WAI-ARIA: Slider
- SSR
- Zoneless
- 3.6 kB gzip
import { Component } from '@angular/core';
import {
Slider,
SliderRange,
SliderThumb,
SliderTrack,
} from '@/ui/slider/slider';
/** A single value from 0 to 100. */
@Component({
selector: 'example-slider-demo',
imports: [Slider, SliderRange, SliderThumb, SliderTrack],
template: `
<div ndSlider class="w-3/5">
<div ndSliderTrack><div ndSliderRange></div></div>
<span ndSliderThumb aria-label="Volume" [defaultValue]="50"></span>
</div>
`,
})
export class SliderDemoExample {}
A slider picks a number from a range by dragging a thumb along a track,
or a range with two thumbs, such as a price filter. Each thumb is a
slider with its own value, which the arrow keys change by a step and
Page Up and Page Down by ten; range thumbs can't pass each other.
Horizontal sliders run from the start side, so they mirror in
right-to-left layouts, and vertical ones run bottom to top.
Installation
npx @ndop/cli add sliderpnpm dlx @ndop/cli add slideryarn dlx @ndop/cli add sliderbunx @ndop/cli add sliderInstall the dependencies
npm install @ndop/primitives @ndop/corepnpm add @ndop/primitives @ndop/coreyarn add @ndop/primitives @ndop/corebun add @ndop/primitives @ndop/coreCopy the code into your project
At the paths your
ndop.jsonaliases point to; these use the defaults.src/app/ui/slider/slider.tsimport { computed, Directive, inject, input } from '@angular/core'; import { ND_SLIDER, NdSlider, NdSliderRange, NdSliderThumb, NdSliderTrack, } from '@ndop/primitives/slider'; import { cn } from '@/lib/utils'; /** * A slider (`@ndop/primitives/slider`): pick a number, or a range with two * thumbs, by dragging or with the arrow keys. The root sets `min`, `max` * and `step`; each `ndSliderThumb` holds one value, so bind each thumb * with `[(value)]` or a forms library, and name it with `aria-label`. * * @example * ```html * <div ndSlider [max]="100" [step]="1"> * <div ndSliderTrack><div ndSliderRange></div></div> * <span ndSliderThumb aria-label="Volume" [(value)]="volume"></span> * </div> * ``` */ @Directive({ selector: '[ndSlider]', hostDirectives: [ { directive: NdSlider, inputs: ['min', 'max', 'step', 'orientation', 'disabled'], }, ], host: { 'data-slot': 'slider', '[class]': 'classes()' }, }) export class Slider { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col', this.class(), ), ); } /** * The track of a {@link Slider}, with a `ndSliderRange` inside. * * @example * ```html * <div ndSliderTrack><div ndSliderRange></div></div> * ``` */ @Directive({ selector: '[ndSliderTrack]', hostDirectives: [NdSliderTrack], host: { 'data-slot': 'slider-track', '[class]': 'classes()' }, }) export class SliderTrack { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly classes = computed(() => cn( 'relative grow overflow-hidden rounded-full bg-muted data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5', this.class(), ), ); } /** * The filled part of a {@link Slider}'s track: from the start to a single * thumb, or between two. * * @example * ```html * <div ndSliderRange></div> * ``` */ @Directive({ selector: '[ndSliderRange]', hostDirectives: [NdSliderRange], host: { 'data-slot': 'slider-range', '[style.inset-inline-start.%]': 'horizontal() ? range().start : null', '[style.inset-inline-end.%]': 'horizontal() ? 100 - range().end : null', '[style.bottom.%]': 'horizontal() ? null : range().start', '[style.top.%]': 'horizontal() ? null : 100 - range().end', '[class]': 'classes()', }, }) export class SliderRange { /** Extra classes, merged last so they win. */ readonly class = input(''); private readonly slider = inject(ND_SLIDER); protected readonly range = this.slider.range; protected readonly horizontal = computed( () => this.slider.orientation() === 'horizontal', ); protected readonly classes = computed(() => cn( 'absolute bg-primary data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full', this.class(), ), ); } /** * A thumb of a {@link Slider}: one value, bound with `[(value)]` or a forms * library. Name it with `aria-label`; add a second one for a range. It * stays inside the track at both ends. * * @example * ```html * <span ndSliderThumb aria-label="Volume" [(value)]="volume"></span> * ``` */ @Directive({ selector: '[ndSliderThumb]', hostDirectives: [ { directive: NdSliderThumb, inputs: ['value', 'defaultValue', 'valueText', 'invalid'], outputs: ['valueChange'], }, ], host: { 'data-slot': 'slider-thumb', // The thumb's edge moves from the track's start to its end, so it // never overhangs: positioned at the value's percentage, then pulled // back by the same percentage of its own size. '[style.inset-inline-start.%]': 'horizontal() ? thumb.percent() : null', '[style.bottom.%]': 'horizontal() ? null : thumb.percent()', '[style.--slider-thumb-offset]': 'thumb.percent() + "%"', '[class]': 'classes()', }, }) export class SliderThumb { /** Extra classes, merged last so they win. */ readonly class = input(''); protected readonly thumb = inject(NdSliderThumb); protected readonly horizontal = computed( () => this.thumb.slider.orientation() === 'horizontal', ); protected readonly classes = computed(() => cn( 'block size-4 shrink-0 rounded-full border border-primary bg-background shadow-sm ring-ring/50 transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50', 'absolute data-[orientation=horizontal]:-translate-x-(--slider-thumb-offset) rtl:data-[orientation=horizontal]:translate-x-(--slider-thumb-offset) data-[orientation=vertical]:translate-y-(--slider-thumb-offset) aria-disabled:pointer-events-none aria-invalid:border-destructive', this.class(), ), ); }src/app/lib/utils.ts/** * Class helpers used by every styled item. They come from `@ndop/core`; * this file is yours, so it is the place to customize them, for example by * wrapping `cn` with an extended tailwind-merge configuration for your own * utilities. * * @packageDocumentation */ export { cn, variants, type ClassValue, type VariantOf } from '@ndop/core';Update the import paths to match your project
The files import each other through the
@/uiand@/libaliases.
Usage
import {
Slider,
SliderRange,
SliderThumb,
SliderTrack,
} from '@/ui/slider/slider';<div ndSlider class="w-3/5">
<div ndSliderTrack><div ndSliderRange></div></div>
<span ndSliderThumb aria-label="Volume" [defaultValue]="50"></span>
</div>Examples
Slider range
A price range with two thumbs, and the values shown beside it.
import { Component, signal } from '@angular/core';
import {
Slider,
SliderRange,
SliderThumb,
SliderTrack,
} from '@/ui/slider/slider';
/**
* A price filter with two thumbs that can't pass each other, and the
* chosen range shown beside it.
*/
@Component({
selector: 'example-slider-range',
imports: [Slider, SliderRange, SliderThumb, SliderTrack],
template: `
<div class="grid w-full max-w-sm gap-3">
<div class="flex items-center justify-between gap-2">
<span id="slider-range-label" class="text-sm font-medium">Price</span>
<span class="text-sm text-muted-foreground">
{{ '$' + min() }} – {{ '$' + max() }}
</span>
</div>
<div ndSlider [max]="500" [step]="10">
<div ndSliderTrack><div ndSliderRange></div></div>
<span
ndSliderThumb
aria-label="Minimum price"
[valueText]="'$' + min()"
[(value)]="min"
></span>
<span
ndSliderThumb
aria-label="Maximum price"
[valueText]="'$' + max()"
[(value)]="max"
></span>
</div>
</div>
`,
})
export class SliderRangeExample {
protected readonly min = signal(100);
protected readonly max = signal(350);
}
Slider steps
Steps of ten, with the value said in words for screen readers.
Quality: 60%, balanced
import { Component, computed, signal } from '@angular/core';
import {
Slider,
SliderRange,
SliderThumb,
SliderTrack,
} from '@/ui/slider/slider';
/** Steps of ten, with the value said in words for screen readers. */
@Component({
selector: 'example-slider-steps',
imports: [Slider, SliderRange, SliderThumb, SliderTrack],
template: `
<div class="grid w-full max-w-sm gap-3">
<div ndSlider [step]="10">
<div ndSliderTrack><div ndSliderRange></div></div>
<span
ndSliderThumb
aria-label="Quality"
[valueText]="label()"
[(value)]="quality"
></span>
</div>
<p class="text-sm text-muted-foreground">Quality: {{ label() }}</p>
</div>
`,
})
export class SliderStepsExample {
protected readonly quality = signal(60);
protected readonly label = computed(() => {
const quality = this.quality();
const trade =
quality < 40
? 'smaller files'
: quality < 80
? 'balanced'
: 'best quality';
return `${quality}%, ${trade}`;
});
}
Slider vertical
A vertical slider, running bottom to top.
import { Component } from '@angular/core';
import {
Slider,
SliderRange,
SliderThumb,
SliderTrack,
} from '@/ui/slider/slider';
/** A vertical slider, running bottom to top. */
@Component({
selector: 'example-slider-vertical',
imports: [Slider, SliderRange, SliderThumb, SliderTrack],
template: `
<div class="flex h-44 items-center gap-6">
<div ndSlider orientation="vertical">
<div ndSliderTrack><div ndSliderRange></div></div>
<span ndSliderThumb aria-label="Bass" [defaultValue]="30"></span>
</div>
<div ndSlider orientation="vertical">
<div ndSliderTrack><div ndSliderRange></div></div>
<span ndSliderThumb aria-label="Treble" [defaultValue]="70"></span>
</div>
</div>
`,
})
export class SliderVerticalExample {}
Slider disabled
A disabled slider.
import { Component } from '@angular/core';
import {
Slider,
SliderRange,
SliderThumb,
SliderTrack,
} from '@/ui/slider/slider';
/** A disabled slider. */
@Component({
selector: 'example-slider-disabled',
imports: [Slider, SliderRange, SliderThumb, SliderTrack],
template: `
<div ndSlider disabled class="w-3/5">
<div ndSliderTrack><div ndSliderRange></div></div>
<span ndSliderThumb aria-label="Brightness" [defaultValue]="40"></span>
</div>
`,
})
export class SliderDisabledExample {}
Accessibility
Implements the Slider pattern of the WAI-ARIA Authoring Practices.
Keyboard
Keys the component handles.
| Key | Action |
|---|---|
| ArrowRightorArrowUp | Increases the focused thumb's value by one step. |
| ArrowLeftorArrowDown | Decreases the focused thumb's value by one step. |
| PageUp | Increases the value by ten steps. |
| PageDown | Decreases the value by ten steps. |
| Home | Sets the value to its minimum: the slider's, or the previous thumb's. |
| End | Sets the value to its maximum: the slider's, or the next thumb's. |
Focus
- Keys and dragging keep focus on the thumb; pressing the slider's track focuses the thumb it moves.
- Leaving a thumb marks its bound field touched.
Right-to-left
Horizontal sliders run from the start side, so the minimum is on the right in right-to-left layouts, and ArrowLeft increases the value while ArrowRight decreases it. Vertical sliders run bottom to top in either direction. Direction comes from the nearest dir attribute, on the slider or an ancestor.
API reference
Slider
[ndSlider]A slider (@ndop/primitives/slider): pick a number, or a range with two
thumbs, by dragging or with the arrow keys. The root sets min, max
and step; each ndSliderThumb holds one value, so bind each thumb
with [(value)] or a forms library, and name it with aria-label.
| Slider binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[min]The lowest value. | number | 0 |
[max]The highest value. | number | 100 |
[step]The distance between allowed values, from min. | number | 1 |
[orientation]Horizontal (from the start side) or vertical (from the bottom). | 'horizontal' | 'vertical' | 'horizontal' |
[disabled]Disables every thumb. | boolean | false |
| Data attribute | Values |
|---|---|
data-slot | slider |
data-orientation | — |
data-disabled | — |
SliderTrack
[ndSliderTrack]The track of a Slider, with a ndSliderRange inside.
| SliderTrack binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | slider-track |
data-orientation | — |
data-disabled | — |
SliderRange
[ndSliderRange]The filled part of a Slider's track: from the start to a single
thumb, or between two.
| SliderRange binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
| Data attribute | Values |
|---|---|
data-slot | slider-range |
data-orientation | — |
SliderThumb
[ndSliderThumb]role sliderA thumb of a Slider: one value, bound with [(value)] or a forms
library. Name it with aria-label; add a second one for a range. It
stays inside the track at both ends.
Manages aria-valuenow, aria-valuemin, aria-valuemax, aria-valuetext, aria-orientation, aria-disabled, aria-invalid.
| SliderThumb binding | Type | Default |
|---|---|---|
[class]Extra classes, merged last so they win. | string | '' |
[defaultValue]Initial value when it isn't bound. | number | undefined | undefined |
[valueText]Text for aria-valuetext, when the number alone doesn't say it. | string | undefined | — |
[invalid]Marks the thumb invalid when no forms library is bound; with one, the
field's state decides. | boolean | false |
[(value)]This thumb's value. Two-way bindable with [(value)]. | number | 0 |
| Data attribute | Values |
|---|---|
data-slot | slider-thumb |
data-orientation | — |
data-disabled | — |
data-invalid | — |
Agent notes
Do
- Name every thumb, such as "Minimum price" and "Maximum price".
- Show the value next to the slider, and set
valueTextwhen a number alone doesn't say it, such as "$40".
Don't
- Don't use a slider when an exact value matters; use an Input of type number.
- Don't use a slider for a few distinct options; use a Radio Group.