Sliders are like onions: three gradient layers of a single slider
Tags: angular, typescript, javascript, webdev
Published: 2022-07-05 14:59:22

Our library [Taiga UI](https://github.com/Tinkoff/taiga-ui) contains hundreds of useful components, directives, and services. The code maintainability is a pivotal issue for our UI Kit library. That is why we always try to write as little code as possible and look for native solutions.

In the article, I will tell you about such example – the development of Angular-component [Slider](https://taiga-ui.dev/components/slider) using built-in browser tools and as few lines of JavaScript as possible. You will read about accessibility, an interesting solution with multi-layer gradient background, and even a bit about Change Detection in Angular.

---

## It cannot be this hard, right?
We have a task to develop a UI-element which helps users to select a number from an ordered range. Input consists of two parts:

- **thumb** – the interactive element of the input. Users can move it horizontally to increase or decrease the selected number
- **track** – the space along which the thumb moves

![Built-in slider in Chrome](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7ux3o6mwfxzui5tikpfb.png)

At first glance, the task seems simple. Listen to `MouseMove` / `TouchMove` events, calculate the distance how far from the beginning of the track they occurred, move the slider to there, and, finally, provide the user with a numerical value.

It could even mistakenly seem that there is no sense in devoting the article to this subject because of its simplicity. However, we have to look into all the in-depth details before making such a conclusion. Let’s kick it off by getting acquainted with W3C documentation.

W3C is a worldwide organization that maintains standards for the development of web applications and its primary goal is to make the Web accessible and understandable. To make things less abstract, imagine that one visits a website and opens a modal dialog. Which keyboard key would you intuitively like to press to close this dialog? It is an Esc button. In this sense, if a web application is developed by an experienced team, then the popup will close when you press this key.

This example illustrates one the W3C principles, let’s dive deeper into [W3C guidelines about sliders](https://www.w3.org/WAI/ARIA/apg/patterns/slider/):

**Keyboard navigation**. Pressed `ArrowUp`/`ArrowDown` and `ArrowRight`/`ArrowLeft` keys should increase/decrease (respectively) the value of the slider by one step. Pressing `Home`/`End` should set the slider to the first/last allowed value in its range. Also, there are recommendations about keys `PageUp`/`PageDown`: they should increase/decrease the slider value by an amount larger than the step change made by `ArrowUp`/`ArrowDown`. If you develop your own slider, you should be ready to write 8 event handlers for the mentioned keys with different behaviors.

**Accessibility**. Any website can be used by people with disabilities, for instance, a user may experience issues using the keyboard and mouse. In such cases, voice-recognition and other assistive technologies are used. The user pronounces to the computer what it should do on the screen, however, for that the website should be correctly marked up.

Moving back to our task, let’s see the requirements for an accessible slider:

- The element serving as the focusable slider control has `role="slider"`
- The slider element has the `aria-valuenow` property set to the current value of the slider
- The slider element has the `aria-valuemin` / `aria-valuemax` property set to the minimum/maximum allowed value of the slider
- If the value of `aria-valuenow` is not user-friendly, the `aria-valuetext` property is set to a string that makes the slider value understandable. A simple example from the W3C’s documentation: if you decide to use a slider to select the days of the week (from the first to the seventh), don't forget to provide the slider with a text description of the selected value (e.g., "Monday")
- Finally, we should always keep `aria-label` in mind!

**Possibility to configure the slider step**. For instance, a developer can restrict to selecting only integer values. Also, they may allow the user to input values with `0.0001`-precision.

Considering all the nuances and accessibility principles might be perceived as tons of extra work, that is why everything is not as simple as it might seem at first glance. 

But the browser has already done it! There is a built-in `<input type="range" />` slider that follows all mentioned standards. It is not surprising to anyone that such a built-in solution is not fancy, and even looks different in each browser. Therefore, I will show you how to customize the built-in slider to one’s needs to ensure cross-browser support.

> We will write the component for Angular. But the solution contains so little Javascript code that you can easily transfer it to your favorite framework.


## Anatomy of `<input type="range" />`

If you enable the shadow DOM in your DevTools (e.g., in Chrome’s settings, one can find option “Show user agent shadow DOM”), you can notice that the built-in `<input type="range" />` is not a single tag. It contains other tags inside (but we did not add them!). Also, the internal structure of nested tags may vary in different browsers. Somewhere you can find a flat structure with 3 `div`-containers, and somewhere – additional depth of the structure.

Each browser engine has a different approach to writing its own slider, so they all have a different structure. But the basic concepts are the same: there is always a tag with a slider track and a container with a thumb. You just need to know how to access these nested tags.

> You can't access the HTML tags inside an `<input />` via [combinator CSS selector](https://www.w3schools.com/css/css_combinators.asp) (for instance, `input[type="range"] > div`). All slider markup is encapsulated inside the shadow DOM and is not accessible from the outside. But browsers provide API (pseudo-classes) via which you can reach the necessary tags.

All browsers with the WebKit/Blink engines (e.g., Safari and Chrome) have this anatomy:

![Structure of slider in WebKit/Blink browsers](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/pi3di8h4rs1azh5a21ry.png)

Firefox gives more opportunities for customization: besides the pseudo-classes for the track and the thumb, there is an additional pseudo-class to customize the progress indicator of the slider.

![Structure of slider in Firefox](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/djvpqdviagv1guxc9xfa.png)

That's all you need to know to write your own simple slider. However, if you need more flexibility, something more complex than tag coloring, you will face some problems. I'll tell you about possible problems and how to deal with them.

## This’ll be fun: writing a basic slider

Firstly, let's use previous information to customize the basic version of our slider.

Declare some [less-variables](https://lesscss.org/features/#variables-feature) for self-documentation of the code:

{% embed https://gist.github.com/nsbarsukov/519a1be3e725b34e8ad805ee55a6c1f0 %}

Declare [less-mixin](https://lesscss.org/features/#mixins-feature) to customize track of the slider. It will be simple for now, but it will be considerably extended in the following sections.

{% embed https://gist.github.com/nsbarsukov/0dec3546c95ecbb9027697e70c9995c1 %}

We also need mixin to customize the thumb of the slider:

{% embed https://gist.github.com/nsbarsukov/920f97a2d46c7002599955e67c74b519 %}

> If you don't quite understand how the `&`-symbol works, I advise you to read this [Less-documentation section](https://lesscss.org/features/#parent-selectors-feature).

Finally, let’s create a special mixin only for webkit browsers. We need it if the slider’s thumb is larger/smaller than the height of the slider track. Webkit browsers don’t align it to the center. Multiple ways to vertically center the content won't help here, but the problem can be solved in this way:

{% embed https://gist.github.com/nsbarsukov/bbd345b9d44a40597336b5260ba161b8 %}

Now you can create an Angular–component. Our component has no additional layout, but it has an attribute selector with the native element `<input type="range" />`. Using attribute selector is a good practice to augment the behavior of native elements. Official Angular documentation [claims](https://angular.io/guide/accessibility#augmenting-native-elements): "When authoring Angular components, you should re-use these native elements directly when possible, rather than re-implementing well-supported behaviors". You can find many such examples in our [Taiga UI library](https://github.com/Tinkoff/taiga-ui).

The component uses a change-detection strategy `OnPush`. If you don’t know it or hesitate to use it, I recommend reading the article of my colleague:

{% embed https://medium.com/angularwave/onpush-your-new-default-ba3fd5bc9f6e %}

Typescript-file of our component contains:

{% embed https://gist.github.com/nsbarsukov/37e738c85557703e0aaafa3d805b2f08 %}


> Our component's selector forces the user to specify the input type (type="range"). You could suggest improving the developer experience and set this static property through `host`-property in the component's metadata. But unfortunately, this breaks the logic of the built-in [controlValueAccessor from the Angular team](https://github.com/angular/angular/blob/main/packages/forms/src/directives/range_value_accessor.ts#L45), which is applied via the `input[type=range]` CSS selector.

In the Less file, we apply all previously created mixins to the `:host` – selector (just a reminder: in this case, it is `<input type="range" />`– element):

{% embed https://gist.github.com/nsbarsukov/0a3cf4756cf7f985b1ecb3157f775da5 %}

Voilà! We have got a basic cross-browser solution for customizing the built-in slider. But there are more nuances that are currently overlooked.

## Webkit customization problems with the progress of the slider

Firefox takes care of the developer's desire to customize the progress indicator of the slider – the `moz-range-progress` pseudo-class. Unfortunately, there is no such functionality for WebKit/Blink browsers. But [linear gradients](https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/linear-gradient) can solve this problem.

Previously, we just filled the entire slider track in gray. And now, we will fill part of the track in the color of progress, and leave the rest of it gray.

We add some simple math to the class of the component. It will calculate how many percents the slider are filled, and save this value to the `--slider-fill-percentage` [CSS-variable](https://developer.mozilla.org/en-US/docs/Web/CSS/Using_CSS_custom_properties):

{% embed https://gist.github.com/nsbarsukov/50944b67d5f29310e1ca81a64ef86615 %}

Now, we need to improve the previously created mixin `customize-track`:

{% embed https://gist.github.com/nsbarsukov/5dc5e7162418cdf778f1509e4e9af631 %}

Let's try the resulting component in action:

```html
<input type="range" tuiSlider value="30" />
```

We're using `changeDetectionStrategy.OnPush`, so it's expected that the slider doesn’t recalculate the value of the `--slider-fill-percentage` CSS-variable while the user moves the slider's thumb. We need to tell Angular when to run the change detection. Achieving this is simple: we need to trigger change detection when the [InputEvent](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/input_event) fires. We set an empty event handler in the component metadata:

{% embed https://gist.github.com/nsbarsukov/a1834a796a25a731a3cc5e6994fb40cf %}

Now, the value of the `--slider-fill-percentage` CSS-variable is recalculated when the user interacts with the slider.

One problem less, and now our slider is **completely** identical in all modern browsers.

## Adding ticks via multi-layer gradient

We have a working solution. Let's imagine that we wanted to use a slider with the following combination of native attributes:

```html
<input type="range" tuiSlider min="0" max="100" step="20"/>
```

This combination means that the user can only move the slider by a step that is a multiple of 20. Users can only select five values in the range, or in other words, the slider consists of five segments. A popular practice for such a case is to highlight segments visually through ticks/marks on the slider track.

![Slider ticks example](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fhed3afpylbuxj0t61p0.png)

We can try to achieve this via additional (pseudo-)elements inside the input. But only webkit browsers support this behavior, because it's not a commonly accepted standard. Another option is to try to implement the ticks through a gradient, but in the last chapter, a gradient was already used to implement a progress indicator for webkit browsers (so the place seems to be already taken).

No worries! There is a solution: gradients can be stacked on each other in several layers. We can create a new layer using [repeating-linear-gradient](https://developer.mozilla.org/en-US/docs/Web/CSS/gradient/repeating-linear-gradient) and apply it on top of the previous one.

Firstly, let's add these lines to the component code:

{% embed https://gist.github.com/nsbarsukov/16739441fe730a4711825ad2cda753f2 %}

We have added new input-property `segments`, with which the developer can set the number of visual segments to be shown on the slider track. And the new getter of the component performs a simple math operation – it calculates how many percent of the total length each segment should occupy and saves the result in the `--slider-segment-width` CSS-variable.

It remains to upgrade the `.customize-track()` mixin. To apply several gradient layers to the same background, just separate them by commas in the `background-image` property. For example:

{% embed https://gist.github.com/nsbarsukov/bebe77ace87dc708d1e21a846d556622 %}


![An illustration of how multiple gradient layers are stacked on top of each other](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/aghhvk5ttz0vfi96jbvw.png)

The main thing is not to get confused in what order the layers stack on each other. The first specified gradient will be layered on top of all others, and the last one will become the bottom layer of the background. In other words, the stacking of layers on top of each other begins at the end of the list enumeration.

All other `background-*` properties have a similar behavior. You can specify only one set of parameters for all layers, or you can list the parameters separated by commas (in the same order as in the `background-image` property) to set your own values for each layer.

Upgraded version of mixin `customize-track`:

{% embed https://gist.github.com/nsbarsukov/a97a8fedc7cc07bbc71119e644bc15da %}

An important feature of native sliders is that when the user moves the thumb to the start/end of the track, the thumb touches the end of the track not with its center, but with its edge. So, the thumb of the slider never goes beyond the visual boundaries of the slider track. Therefore, the first mark does not start at the very beginning of the track, but with a shift, which allows it to be in the center of the thumb (when it is in the leftmost position), and also correctly places the rest of the marks.

In addition to the `background-image` layers, we also set a value for `background-color`. The logic here is as follows: `background-color` acts as the last fallback layer. The entire surface of the track, which is not covered by any of the layers, will be filled in the color of this property’s value.

That's all. This solution is a good illustration of how sometimes a frontend-task can be solved without additional nested HTML tags.

## Wrapping up

I reproduced the final version of the slider in StackBlitz example:

{% embed https://stackblitz.com/edit/ng-slider?embed=1&file=src/slider/slider.component.ts&hideNavigation=1 %}

In the [Taiga UI library](https://github.com/Tinkoff/taiga-ui), we use exactly the same solution with minor improvements. On the [showcase of the Slider component](https://taiga-ui.dev/components/slider), you can see examples of its usage.

It is not always necessary to invent your own solutions – sometimes, we can re-use native alternatives with little modifications. When you ignore a built-in solution, you expand your codebase, increasing the complexity of its support. No wonder one modern wisdom says: the code easiest to maintain is the code that was never written.