Scheduler
Let’s create a very complicated sprinkler-system scheduler!
This recipe/demo is to show you just how far you are able to go with the slider, customising both internal styles/functionality, and also wrapping some other styles/bindings around it to extend the usability to your needs!
Let’s go!
First we are going to set up a basic slider with pips and steps across the (24hr) day. We want to be able to select in a 15-minute (quarter hour) interval step.
Basic pips
We are going to style it almost like a ruler, showing the pips at 15-minute intervals. We put a pip at every 0.25 (15-minute) interval, and then use css to hide the labels which are not on the hour.
<RangeSlider
class="scheduler"
step={0.25}
max={24}
pips
all="label"
/>
.scheduler.rangeSlider {
height: 40px;
border-radius: 0;
}
.scheduler.rangeSlider .rangePips {
--pip-active: black;
top: 0;
bottom: auto;
& > .rsPip {
height: 10px;
top: 0;
}
& .rsPipVal {
display: none;
top: -2em;
font-size: 12px;
font-family: monospace;
}
& > :nth-child(2n+1) {
height: 20px;
--pip: #999;
}
& > :nth-child(4n+1) {
height: 40px;
--pip: #888;
& > .rsPipVal {
display: block;
}
}
}
.scheduler.rangeSlider .rangeHandle {
top: auto;
bottom: 0;
transform: translateX(-50%);
}
Basic handles
Now we have the pips styled as we want, let’s now work on the handles. We are going to be using some SVG icons, applied with CSS.
<RangeSlider
class="scheduler handle"
values={[5, 18]}
step={0.25}
max={24}
pips
all="label"
/>
.handle.rangeSlider .rangeHandle {
--icon: url('data:image/svg+xml,%3Csvg%20%20xmlns=%22http://www.w3.org/2000/svg%22%20%20width=%2224%22%20%20height=%2224%22%20%20viewBox=%220%200%2024%2024%22%20%20fill=%22currentColor%22%20%20class=%22icon%20icon-tabler%20icons-tabler-filled%20icon-tabler-arrow-badge-up%22%3E%3Cpath%20stroke=%22none%22%20d=%22M0%200h24v24H0z%22%20fill=%22none%22/%3E%3Cpath%20d=%22M11.375%206.22l-5%204a1%201%200%200%200%20-.375%20.78v6l.006%20.112a1%201%200%200%200%201.619%20.669l4.375%20-3.501l4.375%203.5a1%201%200%200%200%201.625%20-.78v-6a1%201%200%200%200%20-.375%20-.78l-5%20-4a1%201%200%200%200%20-1.25%200z%22%20/%3E%3C/svg%3E');
transform: translateY(100%) translateX(-50%);
&:before {
display: none;
}
& .rangeNub {
background: var(--icon);
}
}
Ranges
Now we need to get a bit more complex. Let’s add a couple of ranges, with the concept borrowed from the Multi-Range recipe.
<script>
// set up the slider's state
let slider;
const max = 24;
let values = [4.75, 6.5, 19, 21];
let rangeStyle = '';
let pipsStyle = '';
/**
* get the range gradient stops for each range pair
*/
const getRangeStop = (range) => {
const rangePercents = range.map(v => v / max * 100);
return `
transparent ${rangePercents[0]}%,
var(--slider-accent) ${rangePercents[0]}%,
var(--slider-accent) ${rangePercents[1]}%,
transparent ${rangePercents[1]}%
`;
}
/**
* update the range style when the slider values change
* so that we can show the ranges as a gradient
*/
const updateRangeStyle = (e) => {
const { values } = e.detail;
const l = values.length;
// chunk the array into range pairs
const ranges = Array.from({ length: l / 2 }, (_, i) => values.slice(i * 2, i * 2 + 2));
// get the range gradient stops for each range pair
const rangeStops = ranges.map(getRangeStop).join(',');
// set the range style
rangeStyle = `background-image: linear-gradient(to right, transparent, ${rangeStops}, transparent);`;
setPipsInRange(ranges);
}
/**
* apply a css style for each pip that is in a range,
* we can't use `style=` here like the gradient, because the style is
* applied to the slider parent, not the pips
*/
const setPipsInRange = (ranges) => {
pipsStyle = '';
const pips = slider.querySelectorAll('.rsPip');
pips.forEach((pip, i) => {
const pipValue = parseFloat(pip.dataset.val);
if ( ranges.some(range => pipValue >= range[0] && pipValue <= range[1]) ) {
pipsStyle += `
#ranges.rangeSlider .rangePips .rsPip[data-val="${pipValue}"] {
background-color: var(--pip-active);
color: var(--pip-active-text);
font-weight: 600;
}
`;
}
});
}
/**
* update the range styles when the component mounts
*/
onMount(() => {
updateRangeStyle({ detail: { values } });
});
</script>
<RangeSlider
id="ranges"
bind:slider
bind:values
class="scheduler handle ranges"
style={rangeStyle}
step={0.25}
max={max}
pips
all="label"
on:change={updateRangeStyle}
/>
<button type="button" on:click|once={() => {
values = ([...values, 9, 12]).sort((a, b) => a - b);
updateRangeStyle({ detail: { values } });
}}>
Add a range
</button>
<!-- Render the pips styles for pips in ranges -->
{@html `<style>${pipsStyle}</style>`}
.ranges.rangeSlider {
--slider-accent: rgba(114, 255, 168, 0.75);
/* create a start/end icon for the range handles */
/* we have to do it like this because svg cannot use var() */
--icon-start: url('data:image/svg+xml,%3Csvg%20%20xmlns=%22http://www.w3.org/2000/svg%22%20%20width=%2224%22%20%20height=%2224%22%20%20viewBox=%220%200%2024%2024%22%20%20fill=%22%230cb44c%22%20%20class=%22icon%20icon-tabler%20icons-tabler-filled%20icon-tabler-arrow-badge-up%22%3E%3Cpath%20stroke=%22none%22%20d=%22M0%200h24v24H0z%22%20fill=%22none%22/%3E%3Cpath%20d=%22M11.375%206.22l-5%204a1%201%200%200%200%20-.375%20.78v6l.006%20.112a1%201%200%200%200%201.619%20.669l4.375%20-3.501l4.375%203.5a1%201%200%200%200%201.625%20-.78v-6a1%201%200%200%200%20-.375%20-.78l-5%20-4a1%201%200%200%200%20-1.25%200z%22%20/%3E%3C/svg%3E');
--icon-end: url('data:image/svg+xml,%3Csvg%20%20xmlns=%22http://www.w3.org/2000/svg%22%20%20width=%2224%22%20%20height=%2224%22%20%20viewBox=%220%200%2024%2024%22%20%20fill=%22%23b40c2b%22%20%20class=%22icon%20icon-tabler%20icons-tabler-filled%20icon-tabler-arrow-badge-up%22%3E%3Cpath%20stroke=%22none%22%20d=%22M0%200h24v24H0z%22%20fill=%22none%22/%3E%3Cpath%20d=%22M11.375%206.22l-5%204a1%201%200%200%200%20-.375%20.78v6l.006%20.112a1%201%200%200%200%201.619%20.669l4.375%20-3.501l4.375%203.5a1%201%200%200%200%201.625%20-.78v-6a1%201%200%200%200%20-.375%20-.78l-5%20-4a1%201%200%200%200%20-1.25%200z%22%20/%3E%3C/svg%3E');
& .rangePips {
/* set the pip active color and text color */
--pip-active: color-mix(in srgb, var(--slider-accent) 50%, black);
--pip-active-text: #333;
}
/* set the start/end icon for the range handles */
& .rangeHandle {
& .rangeNub {
background: var(--icon-start);
}
& .rangeFloat {
color: #0cb44c;
}
}
/* set the end icon for every other handle */
& .rangeHandle:nth-child(2n+2) {
& .rangeNub {
background: var(--icon-end);
}
& .rangeFloat {
color: #b40c2b;
}
}
}
Prevent Overlaps
So now the styling is pretty good, and we can add multiple ranges, we need to make sure that two ranges don’t overlap.
Here there’s a bit of code in the on:change
handler to make sure there’s
a minimum distance between the handles in the same range, and a minimum distance
between each range pair.
<script>
// set up the slider's state
let slider;
const min = 0;
const max = 24;
let values = [4.75, 6.5, 19, 21];
let rangeStyle = '';
let pipsStyle = '';
const minRangeSize = 1; // minimum distance between handles in the same range
const minRangeGap = 0.5; // minimum distance between the two ranges
/**
* get the range gradient stops for each range pair
*/
const getRangeStop = (range) => {
const rangePercents = range.map(v => v / max * 100);
return `
transparent ${rangePercents[0]}%,
var(--slider-accent) ${rangePercents[0]}%,
var(--slider-accent) ${rangePercents[1]}%,
transparent ${rangePercents[1]}%
`;
}
/**
* update the range style when the slider values change
* so that we can show the ranges as a gradient
*/
const updateRangeStyle = (e) => {
const { values } = e.detail;
const l = values.length;
// chunk the array into range pairs
const ranges = Array.from({ length: l / 2 }, (_, i) => values.slice(i * 2, i * 2 + 2));
// get the range gradient stops for each range pair
const rangeStops = ranges.map(getRangeStop).join(',');
// set the range style
rangeStyle = `background-image: linear-gradient(to right, transparent, ${rangeStops}, transparent);`;
setPipsInRange(ranges);
}
/**
* apply a css style for each pip that is in a range,
* we can't use `style=` here like the gradient, because the style is
* applied to the slider parent, not the pips
*/
const setPipsInRange = (ranges) => {
pipsStyle = '';
const pips = slider.querySelectorAll('.rsPip');
pips.forEach((pip, i) => {
const pipValue = parseFloat(pip.dataset.val);
if ( ranges.some(range => pipValue >= range[0] && pipValue <= range[1]) ) {
pipsStyle += `
#overlaps.rangeSlider .rangePips .rsPip[data-val="${pipValue}"] {
background-color: var(--pip-active);
color: var(--pip-active-text);
font-weight: 600;
}
`;
}
});
}
/**
* this is the main function that handles the change of the slider
* it checks if the current handle is too close to the previous or next handle
* and if so, it moves the previous or next handle to
* maintain the minRangeSize and minRangeGap
*/
const handleChange = (e) => {
const thisHandle = e.detail.activeHandle;
const currentValue = e.detail.value;
const handleValues = e.detail.values;
const lastHandle = handleValues.length - 1;
// If moving left and would violate minimum distance
if (thisHandle > 0 && currentValue < handleValues[thisHandle - 1] + minRangeSize) {
// Start from the current handle and propagate left
values[thisHandle] = currentValue;
for (let prev = thisHandle - 1; prev >= 0; prev--) {
// Check if we're crossing a range boundary (between even and odd handles)
const isRangeBoundary = prev % 2 === 1; // odd index means we're at the start of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the next handle is too close to the previous handle, move the previous handle back
if (values[prev + 1] < values[prev] + requiredDistance) {
values[prev] = Math.max(min, values[prev + 1] - requiredDistance);
}
}
}
// If moving right and would violate minimum distance
if (thisHandle < lastHandle && currentValue > handleValues[thisHandle + 1] - minRangeSize) {
// Start from the current handle and propagate right
values[thisHandle] = currentValue;
for (let next = thisHandle + 1; next < handleValues.length; next++) {
// Check if we're crossing a range boundary (between even and odd handles)
const isRangeBoundary = next % 2 === 0; // even index means we're at the end of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the previous handle is too close to the next handle, move the next handle forward
if (values[next - 1] > values[next] - requiredDistance) {
values[next] = Math.min(max, values[next - 1] + requiredDistance);
}
}
}
updateRangeStyle({ detail: { values } });
handleStop(e);
}
/**
* we check if the first or last handle is at the min or max value
* and if so, we move the other handles to the left or right to maintain
* the minRangeSize and minRangeGap
*
* this is done on:stop to improve the performance a little bit, but it could
* be done on:change as well to stop the 'rubber banding' effect
*/
const handleStop = (e) => {
const handleValues = e.detail.values;
const lastHandle = handleValues.length - 1;
// if first handle is at min, ensure all handles to the right maintain distance
if (values[0] <= min) {
values[0] = min;
for (let next = 1; next < values.length; next++) {
const isRangeBoundary = next % 2 === 0; // even index means we're at the end of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the next handle is too close to the previous handle, move the previous handle forward
if (values[next] < values[next - 1] + requiredDistance) {
values[next] = values[next - 1] + requiredDistance;
}
}
}
// if last handle is at max, ensure all handles to the left maintain distance
if (values[lastHandle] >= max) {
values[lastHandle] = max;
for (let prev = values.length - 2; prev >= 0; prev--) {
const isRangeBoundary = prev % 2 === 1; // odd index means we're at the start of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the previous handle is too close to the next handle, move the next handle back
if (values[prev] > values[prev + 1] - requiredDistance) {
values[prev] = values[prev + 1] - requiredDistance;
}
}
}
updateRangeStyle({ detail: { values } });
}
/**
* update the range styles when the component mounts
*/
onMount(() => {
updateRangeStyle({ detail: { values } });
});
</script>
<RangeSlider
bind:slider
bind:values
id="overlaps"
class="scheduler handle ranges overlaps"
style={rangeStyle}
step={0.25}
{min}
{max}
pips
all="label"
on:change={handleChange}
on:stop={handleStop}
/>
<button type="button" on:click|once={() => {
values = ([...values, 9, 12]).sort((a, b) => a - b);
updateRangeStyle({ detail: { values } });
}}>
Add a range
</button>
<!-- Render the pips styles for pips in ranges -->
{@html `<style>${pipsStyle}</style>`}
Binding and Floats
The next step is to bind the slider to a list of range-pair inputs, and also
we want to show the selected time for each range. As for floats, we are going to
use the handleFormatter
prop to show the time in a more readable format.
<script>
// set up the slider's state
let slider;
const min = 0;
const max = 24;
let values = [4.75, 6.5, 19, 21];
let rangeStyle = '';
let pipsStyle = '';
const minRangeSize = 1; // minimum distance between handles in the same range
const minRangeGap = 0.5; // minimum distance between the two ranges
/**
* get the range gradient stops for each range pair
*/
const getRangeStop = (range) => {
const rangePercents = range.map(v => v / max * 100);
return `
transparent ${rangePercents[0]}%,
var(--slider-accent) ${rangePercents[0]}%,
var(--slider-accent) ${rangePercents[1]}%,
transparent ${rangePercents[1]}%
`;
}
/**
* update the range style when the slider values change
* so that we can show the ranges as a gradient
*/
const updateRangeStyle = (e) => {
const { values } = e.detail;
const l = values.length;
// chunk the array into range pairs
const ranges = Array.from({ length: l / 2 }, (_, i) => values.slice(i * 2, i * 2 + 2));
// get the range gradient stops for each range pair
const rangeStops = ranges.map(getRangeStop).join(',');
// set the range style
rangeStyle = `background-image: linear-gradient(to right, transparent, ${rangeStops}, transparent);`;
setPipsInRange(ranges);
}
/**
* apply a css style for each pip that is in a range,
* we can't use `style=` here like the gradient, because the style is
* applied to the slider parent, not the pips
*/
const setPipsInRange = (ranges) => {
pipsStyle = '';
const pips = slider.querySelectorAll('.rsPip');
pips.forEach((pip, i) => {
const pipValue = parseFloat(pip.dataset.val);
if ( ranges.some(range => pipValue >= range[0] && pipValue <= range[1]) ) {
pipsStyle += `
#binding.rangeSlider .rangePips .rsPip[data-val="${pipValue}"] {
background-color: var(--pip-active);
color: var(--pip-active-text);
font-weight: 600;
}
`;
}
});
}
/**
* this is the main function that handles the change of the slider
* it checks if the current handle is too close to the previous or next handle
* and if so, it moves the previous or next handle to
* maintain the minRangeSize and minRangeGap
*/
const handleChange = (e) => {
const thisHandle = e.detail.activeHandle;
const currentValue = e.detail.value;
const handleValues = e.detail.values;
const lastHandle = handleValues.length - 1;
// If moving left and would violate minimum distance
if (thisHandle > 0 && currentValue < handleValues[thisHandle - 1] + minRangeSize) {
// Start from the current handle and propagate left
values[thisHandle] = currentValue;
for (let prev = thisHandle - 1; prev >= 0; prev--) {
// Check if we're crossing a range boundary (between even and odd handles)
const isRangeBoundary = prev % 2 === 1; // odd index means we're at the start of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the next handle is too close to the previous handle, move the previous handle back
if (values[prev + 1] < values[prev] + requiredDistance) {
values[prev] = Math.max(min, values[prev + 1] - requiredDistance);
}
}
}
// If moving right and would violate minimum distance
if (thisHandle < lastHandle && currentValue > handleValues[thisHandle + 1] - minRangeSize) {
// Start from the current handle and propagate right
values[thisHandle] = currentValue;
for (let next = thisHandle + 1; next < handleValues.length; next++) {
// Check if we're crossing a range boundary (between even and odd handles)
const isRangeBoundary = next % 2 === 0; // even index means we're at the end of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the previous handle is too close to the next handle, move the next handle forward
if (values[next - 1] > values[next] - requiredDistance) {
values[next] = Math.min(max, values[next - 1] + requiredDistance);
}
}
}
updateRangeStyle({ detail: { values } });
handleStop(e);
}
/**
* we check if the first or last handle is at the min or max value
* and if so, we move the other handles to the left or right to maintain
* the minRangeSize and minRangeGap
*
* this is done on:stop to improve the performance a little bit, but it could
* be done on:change as well to stop the 'rubber banding' effect
*/
const handleStop = (e) => {
const handleValues = e.detail.values;
const lastHandle = handleValues.length - 1;
// if first handle is at min, ensure all handles to the right maintain distance
if (values[0] <= min) {
values[0] = min;
for (let next = 1; next < values.length; next++) {
const isRangeBoundary = next % 2 === 0; // even index means we're at the end of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the next handle is too close to the previous handle, move the previous handle forward
if (values[next] < values[next - 1] + requiredDistance) {
values[next] = values[next - 1] + requiredDistance;
}
}
}
// if last handle is at max, ensure all handles to the left maintain distance
if (values[lastHandle] >= max) {
values[lastHandle] = max;
for (let prev = values.length - 2; prev >= 0; prev--) {
const isRangeBoundary = prev % 2 === 1; // odd index means we're at the start of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the previous handle is too close to the next handle, move the next handle back
if (values[prev] > values[prev + 1] - requiredDistance) {
values[prev] = values[prev + 1] - requiredDistance;
}
}
}
updateRangeStyle({ detail: { values } });
}
/**
* format the handle floats in to a 24hr time format
*/
const handleFormatter = (v) => {
const time = new Date();
time.setHours(Math.floor(v));
time.setMinutes(Math.round((v - Math.floor(v)) * 60));
return new Intl.DateTimeFormat('en', {
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(time);
}
/**
* update the range styles when the component mounts
*/
onMount(() => {
updateRangeStyle({ detail: { values } });
});
</script>
<RangeSlider
bind:slider
bind:values
id="binding"
class="scheduler handle ranges bindings"
style={rangeStyle}
step={0.25}
{min}
{max}
pips
all="label"
float
handleFormatter={handleFormatter}
on:change={handleChange}
on:stop={handleStop}
/>
<section id="inputs">
{#each Array.from({ length: values.length / 2 }, (_, i) => values.slice(i * 2, i * 2 + 2)) as range, i}
<input type="time" min="0:00" max="24:00" value={handleFormatter(values[i * 2])} readonly />
<span> - </span>
<input type="time" min="0:00" max="24:00" value={handleFormatter(values[i * 2 + 1])} readonly />
{/each}
<button type="button" on:click={() => {
values = [...values, 9, 12].sort((a, b) => a - b);
updateRangeStyle({ detail: { values } });
}}>
Add a time range
</button>
</section>
<style>
#inputs {
display: grid;
grid-template-columns: 1fr max-content 1fr;
gap: 1em;
place-items: center;
max-width: 300px;
margin: 5rem auto 0;
}
#inputs input {
width: 100%;
}
#inputs button {
grid-column: span 3;
}
</style>
<!-- Render the pips styles for pips in ranges -->
{@html `<style>${pipsStyle}</style>`}
.bindings.rangeSlider {
& .rangeFloat {
font-family: var(--font-mono);
font-size: 12px;
background: var(--bg);
padding: 0.2em;
opacity: 1;
top: auto;
bottom: 0;
translate: -50% 75% 0.01px!}
}
Jazz it up
So that’s kind of working as we want, now!
Let’s add a bit more styling around the slider by putting in a fancy sunrise/sunset visualisation on the range! We wrap the slider in a container, and add in a sunrise/sunset element using some JS to set the sunrise/sunset times as css variables.
<script>
// set up the slider's state
let slider;
const min = 0;
const max = 24;
let values = [4.75, 6.5, 19, 21];
let rangeStyle = '';
let pipsStyle = '';
const minRangeSize = 1; // minimum distance between handles in the same range
const minRangeGap = 0.5; // minimum distance between the two ranges
/**
* get the range gradient stops for each range pair
*/
const getRangeStop = (range) => {
const rangePercents = range.map(v => v / max * 100);
return `
transparent ${rangePercents[0]}%,
var(--slider-accent) ${rangePercents[0]}%,
var(--slider-accent) ${rangePercents[1]}%,
transparent ${rangePercents[1]}%
`;
}
/**
* update the range style when the slider values change
* so that we can show the ranges as a gradient
*/
const updateRangeStyle = (e) => {
const { values } = e.detail;
const l = values.length;
// chunk the array into range pairs
const ranges = Array.from({ length: l / 2 }, (_, i) => values.slice(i * 2, i * 2 + 2));
// get the range gradient stops for each range pair
const rangeStops = ranges.map(getRangeStop).join(',');
// set the range style
rangeStyle = `background-image: linear-gradient(to right, transparent, ${rangeStops}, transparent);`;
setPipsInRange(ranges);
}
/**
* apply a css style for each pip that is in a range,
* we can't use `style=` here like the gradient, because the style is
* applied to the slider parent, not the pips
*/
const setPipsInRange = (ranges) => {
pipsStyle = '';
const pips = slider.querySelectorAll('.rsPip');
pips.forEach((pip, i) => {
const pipValue = parseFloat(pip.dataset.val);
if ( ranges.some(range => pipValue >= range[0] && pipValue <= range[1]) ) {
pipsStyle += `
#jazzed.rangeSlider .rangePips .rsPip[data-val="${pipValue}"] {
background-color: var(--pip-active);
color: var(--pip-active-text);
font-weight: 600;
}
`;
}
});
}
/**
* this is the main function that handles the change of the slider
* it checks if the current handle is too close to the previous or next handle
* and if so, it moves the previous or next handle to
* maintain the minRangeSize and minRangeGap
*/
const handleChange = (e) => {
const thisHandle = e.detail.activeHandle;
const currentValue = e.detail.value;
const handleValues = e.detail.values;
const lastHandle = handleValues.length - 1;
// If moving left and would violate minimum distance
if (thisHandle > 0 && currentValue < handleValues[thisHandle - 1] + minRangeSize) {
// Start from the current handle and propagate left
values[thisHandle] = currentValue;
for (let prev = thisHandle - 1; prev >= 0; prev--) {
// Check if we're crossing a range boundary (between even and odd handles)
const isRangeBoundary = prev % 2 === 1; // odd index means we're at the start of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the next handle is too close to the previous handle, move the previous handle back
if (values[prev + 1] < values[prev] + requiredDistance) {
values[prev] = Math.max(min, values[prev + 1] - requiredDistance);
}
}
}
// If moving right and would violate minimum distance
if (thisHandle < lastHandle && currentValue > handleValues[thisHandle + 1] - minRangeSize) {
// Start from the current handle and propagate right
values[thisHandle] = currentValue;
for (let next = thisHandle + 1; next < handleValues.length; next++) {
// Check if we're crossing a range boundary (between even and odd handles)
const isRangeBoundary = next % 2 === 0; // even index means we're at the end of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the previous handle is too close to the next handle, move the next handle forward
if (values[next - 1] > values[next] - requiredDistance) {
values[next] = Math.min(max, values[next - 1] + requiredDistance);
}
}
}
updateRangeStyle({ detail: { values } });
handleStop(e);
}
/**
* we check if the first or last handle is at the min or max value
* and if so, we move the other handles to the left or right to maintain
* the minRangeSize and minRangeGap
*
* this is done on:stop to improve the performance a little bit, but it could
* be done on:change as well to stop the 'rubber banding' effect
*/
const handleStop = (e) => {
const handleValues = e.detail.values;
const lastHandle = handleValues.length - 1;
// if first handle is at min, ensure all handles to the right maintain distance
if (values[0] <= min) {
values[0] = min;
for (let next = 1; next < values.length; next++) {
const isRangeBoundary = next % 2 === 0; // even index means we're at the end of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the next handle is too close to the previous handle, move the previous handle forward
if (values[next] < values[next - 1] + requiredDistance) {
values[next] = values[next - 1] + requiredDistance;
}
}
}
// if last handle is at max, ensure all handles to the left maintain distance
if (values[lastHandle] >= max) {
values[lastHandle] = max;
for (let prev = values.length - 2; prev >= 0; prev--) {
const isRangeBoundary = prev % 2 === 1; // odd index means we're at the start of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the previous handle is too close to the next handle, move the next handle back
if (values[prev] > values[prev + 1] - requiredDistance) {
values[prev] = values[prev + 1] - requiredDistance;
}
}
}
updateRangeStyle({ detail: { values } });
}
/**
* format the handle floats in to a 24hr time format
*/
const decimalToTimeString = (v) => {
const time = new Date();
time.setHours(Math.floor(v));
time.setMinutes(Math.round((v - Math.floor(v)) * 60));
return new Intl.DateTimeFormat('en', {
hour: '2-digit',
minute: '2-digit',
hour12: false
}).format(time);
}
const timeStringToDecimal = (timeStr) => {
const [hours, minutes] = timeStr.split(':').map(Number);
return hours + (minutes / 60);
};
const timeStringToPercent = (timeStr) => {
const decimal = timeStringToDecimal(timeStr);
return parseFloat(((decimal - min) / (max - min) * 100).toFixed(2));
};
/**
* set the sunrise and sunset styles, this would probably be gotten from
* a server/api based on lat/long in reality.
*/
const sunrise = '05:33';
const sunset = '20:41';
const sunStyle = `--sunrise: ${timeStringToPercent(sunrise)}%; --sunset: ${timeStringToPercent(sunset)}%;`;
/**
* update the range styles when the component mounts
*/
onMount(() => {
updateRangeStyle({ detail: { values } });
});
</script>
<div class="slider-wrapper" style={`${sunStyle}`}>
<span class="sunrise" title="sunrise time: {sunrise}"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-sunset-2"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 13h1" /><path d="M20 13h1" /><path d="M5.6 6.6l.7 .7" /><path d="M18.4 6.6l-.7 .7" /><path d="M8 13a4 4 0 1 1 8 0" /><path d="M3 17h18" /><path d="M7 20h5" /><path d="M16 20h1" /><path d="M12 5v-1" /></svg></span>
<span class="sunset" title="sunset time: {sunset}"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-haze-moon"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M3 16h18" /><path d="M3 20h18" /><path d="M8.296 16c-2.268 -1.4 -3.598 -4.087 -3.237 -6.916c.443 -3.48 3.308 -6.083 6.698 -6.084v.006h.296c-1.991 1.916 -2.377 5.03 -.918 7.405c1.459 2.374 4.346 3.33 6.865 2.275a6.888 6.888 0 0 1 -2.777 3.314" /></svg></span>
<RangeSlider
bind:slider
bind:values
id="jazzed"
class="scheduler handle ranges bindings jazzed"
style={`${rangeStyle}`}
step={0.25}
{min}
{max}
pips
all="label"
float
handleFormatter={decimalToTimeString}
on:change={handleChange}
on:stop={handleStop}
/>
</div>
<section id="inputs">
{#each Array.from({ length: values.length / 2 }, (_, i) => values.slice(i * 2, i * 2 + 2)) as range, i}
<input type="time" min="0:00" max="24:00" value={decimalToTimeString(values[i * 2])} readonly />
<span> - </span>
<input type="time" min="0:00" max="24:00" value={decimalToTimeString(values[i * 2 + 1])} readonly />
{/each}
<button type="button" on:click={() => {
values = [...values, 9, 12].sort((a, b) => a - b);
updateRangeStyle({ detail: { values } });
}}>
Add a time range
</button>
</section>
<style>
#inputs {
display: grid;
grid-template-columns: 1fr max-content 1fr;
gap: 1em;
place-items: center;
max-width: 300px;
margin: 5rem auto 0;
}
#inputs input {
width: 100%;
}
#inputs button {
grid-column: span 3;
}
</style>
<!-- Render the pips styles for pips in ranges -->
{@html `<style>${pipsStyle}</style>`}
:root {
--day-color: #e8c247;
--sunrise-color: #e87747;
--sunset-color: #5d639d;
--night-color: #0f1772;
}
.jazzed.rangeSlider {
width: 100%;
margin: 5rem 0 2rem!&:before {
content: '';
position: absolute;
top: -3px;
left: 0;
right: 0;
height: 1em;
translate: 0px -100% 0.01px;
background-image:
linear-gradient(
to right,
var(--night-color),
var(--sunset-color) calc(var(--sunrise) - 2px),
var(--sunrise-color) calc(var(--sunrise) + 2px),
var(--day-color),
var(--day-color),
var(--sunrise-color) calc(var(--sunset) - 2px),
var(--sunset-color) calc(var(--sunset) + 2px),
var(--night-color)
);
opacity: 0.5;
mask-image: linear-gradient(to right, transparent, black 10px calc(100% - 10px), transparent);
}
.rangePips .rsPip {
--pip-text: rgba(0, 0, 0, 0.5);
}
.rangePips .rsPipVal {
top: -25px;
text-shadow: 0 1px 0px rgba(255, 255, 255, 0.5);
}
.rangePips .rsPip.rsSelected {
color: var(--pip-active-text);
font-weight: 600;
}
}
.slider-wrapper {
position: relative;
.sunrise, .sunset {
color: color-mix(in srgb, var(--sunrise-color) 85%, black);
font-size: 12px;
position: absolute;
top: -2.5em;
left: var(--sunrise);
translate: -50% -100%;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 1px;
height: 0.75em;
background-color: currentColor;
translate: -50% 75% 0.01px;
}
}
.sunset {
color: color-mix(in srgb, var(--sunset-color) 85%, black);
left: var(--sunset);
}
}
Final Result
Now that we have the styling pretty much completed. I want to get rid of the inputs
and the range adding button. Let’s add a bit of Svelte code to display +
buttons
and x
buttons to add and remove ranges.
So the code is obviously… “a lot” … and I don’t expect you do use all of it, the example is probably not useful for 99.999% of people. But I hope there’s some information, or techniques in there that may be useful to you!
<script>
// set up the slider's state
let slider;
const min = 0;
const max = 24;
let values = [4.75, 6.5, 19, 21];
let rangeStyle = "";
let pipsStyle = "";
const minRangeSize = 1; // minimum distance between handles in the same range
const minRangeGap = 0.5; // minimum distance between the two ranges
/**
* get the range gradient stops for each range pair
*/
const getRangeStop = (range) => {
const rangePercents = range.map((v) => (v / max) * 100);
return `
transparent ${rangePercents[0]}%,
var(--slider-accent) ${rangePercents[0]}%,
var(--slider-accent) ${rangePercents[1]}%,
transparent ${rangePercents[1]}%
`;
};
/**
* update the range style when the slider values change
* so that we can show the ranges as a gradient
*/
const updateRangeStyle = (e) => {
const { values } = e.detail;
const l = values.length;
// chunk the array into range pairs
const ranges = Array.from({ length: l / 2 }, (_, i) => values.slice(i * 2, i * 2 + 2));
// get the range gradient stops for each range pair
const rangeStops = ranges.map(getRangeStop).join(",");
// set the range style
rangeStyle = `background-image: linear-gradient(to right, transparent, ${rangeStops}, transparent);`;
setPipsInRange(ranges);
};
/**
* apply a css style for each pip that is in a range,
* we can't use `style=` here like the gradient, because the style is
* applied to the slider parent, not the pips
*/
const setPipsInRange = (ranges) => {
pipsStyle = "";
const pips = slider.querySelectorAll(".rsPip");
pips.forEach((pip, i) => {
const pipValue = parseFloat(pip.dataset.val);
if (ranges.some((range) => pipValue >= range[0] && pipValue <= range[1])) {
pipsStyle += `
#final.rangeSlider .rangePips .rsPip[data-val="${pipValue}"] {
background-color: var(--pip-active);
color: var(--pip-active-text);
font-weight: 600;
}
`;
}
});
};
/**
* this is the main function that handles the change of the slider
* it checks if the current handle is too close to the previous or next handle
* and if so, it moves the previous or next handle to
* maintain the minRangeSize and minRangeGap
*/
const handleChange = (e) => {
const thisHandle = e.detail.activeHandle;
const currentValue = e.detail.value;
const handleValues = e.detail.values;
const lastHandle = handleValues.length - 1;
// If moving left and would violate minimum distance
if (thisHandle > 0 && currentValue < handleValues[thisHandle - 1] + minRangeSize) {
// Start from the current handle and propagate left
values[thisHandle] = currentValue;
for (let prev = thisHandle - 1; prev >= 0; prev--) {
// Check if we're crossing a range boundary (between even and odd handles)
const isRangeBoundary = prev % 2 === 1; // odd index means we're at the start of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the next handle is too close to the previous handle, move the previous handle back
if (values[prev + 1] < values[prev] + requiredDistance) {
values[prev] = Math.max(min, values[prev + 1] - requiredDistance);
}
}
}
// If moving right and would violate minimum distance
if (thisHandle < lastHandle && currentValue > handleValues[thisHandle + 1] - minRangeSize) {
// Start from the current handle and propagate right
values[thisHandle] = currentValue;
for (let next = thisHandle + 1; next < handleValues.length; next++) {
// Check if we're crossing a range boundary (between even and odd handles)
const isRangeBoundary = next % 2 === 0; // even index means we're at the end of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the previous handle is too close to the next handle, move the next handle forward
if (values[next - 1] > values[next] - requiredDistance) {
values[next] = Math.min(max, values[next - 1] + requiredDistance);
}
}
}
// prevent the handles from pushing past other handles
handleStop(e);
// update the gradient positions
updateRangeStyle({ detail: { values } });
};
/**
* we check if the first or last handle is at the min or max value
* and if so, we move the other handles to the left or right to maintain
* the minRangeSize and minRangeGap
*
* this is done on:stop to improve the performance a little bit, but it could
* be done on:change as well to stop the 'rubber banding' effect
*/
const handleStop = (e) => {
const handleValues = e.detail.values;
const lastHandle = handleValues.length - 1;
// if first handle is at min, ensure all handles to the right maintain distance
if (values[0] <= min) {
values[0] = min;
for (let next = 1; next < values.length; next++) {
const isRangeBoundary = next % 2 === 0; // even index means we're at the end of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the next handle is too close to the previous handle, move the previous handle forward
if (values[next] < values[next - 1] + requiredDistance) {
values[next] = values[next - 1] + requiredDistance;
}
}
}
// if last handle is at max, ensure all handles to the left maintain distance
if (values[lastHandle] >= max) {
values[lastHandle] = max;
for (let prev = values.length - 2; prev >= 0; prev--) {
const isRangeBoundary = prev % 2 === 1; // odd index means we're at the start of a range
const requiredDistance = isRangeBoundary ? minRangeGap : minRangeSize;
// if the previous handle is too close to the next handle, move the next handle back
if (values[prev] > values[prev + 1] - requiredDistance) {
values[prev] = values[prev + 1] - requiredDistance;
}
}
}
};
/**
* format the handle floats in to a 24hr time format
*/
const decimalToTimeString = (v) => {
const time = new Date();
time.setHours(Math.floor(v));
time.setMinutes(Math.round((v - Math.floor(v)) * 60));
return new Intl.DateTimeFormat("en", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(time);
};
/**
* convert a time string to a decimal value
*/
const timeStringToDecimal = (timeStr) => {
const [hours, minutes] = timeStr.split(":").map(Number);
return hours + minutes / 60;
};
/**
* convert a time string to a percentage value
*/
const timeStringToPercent = (timeStr) => {
const decimal = timeStringToDecimal(timeStr);
return parseFloat((((decimal - min) / (max - min)) * 100).toFixed(2));
};
/**
* set the sunrise and sunset styles, this would probably be gotten from
* a server/api based on lat/long in reality.
*/
const sunrise = "05:33";
const sunset = "20:41";
const sunStyle = `--sunrise: ${timeStringToPercent(sunrise)}%; --sunset: ${timeStringToPercent(sunset)}%;`;
/**
* create a derived array of the ranges as percentage pairs
* this is used to show the 'remove range' buttons on the center of each range
*/
$: rangePercentsArray = Array.from({ length: values.length / 2 }, (_, i) =>
values.map((v) => (v / max) * 100).slice(i * 2, i * 2 + 2)
);
/**
* create a derived array of the gaps between the ranges as percentage pairs
* this is used to show the 'add range' buttons between each range
*/
$: rangeGapsPercentsArray = (() => {
const gaps = [];
for (let i = 0; i <= rangePercentsArray.length; i++) {
const gap =
i === 0
? [0, rangePercentsArray[0][0]]
: i === rangePercentsArray.length
? [rangePercentsArray[i - 1][1], 100]
: [rangePercentsArray[i - 1][1], rangePercentsArray[i][0]];
// don't include the edges if the range starts/ends at the edges
if (!(gap[0] === 0 && gap[1] === 0) && !(gap[0] === 100 && gap[1] === 100)) {
gaps.push(gap);
}
}
return gaps;
})();
/**
* delete a range from the values array, this is done by finding the index of the range
* and then removing the range from the values array
*/
const deleteRange = (range) => {
const index = rangePercentsArray.findIndex((r) => r[0] === range[0] && r[1] === range[1]);
values.splice(index * 2, 2);
values = [...values];
// update the gradient positions
updateRangeStyle({ detail: { values } });
};
/**
* add a range to the values array, this is done by finding the gap between the ranges
* and then adding the gap values to the values array
*
* we then find the index of the first handle in the gap and set it as the active handle
* this is done to prevent the handles from pushing past other handles
*/
const addRange = (gap) => {
const index = rangeGapsPercentsArray.findIndex((g) => g[0] === gap[0] && g[1] === gap[1]);
const gapValues = [(gap[0] * (max - min) + min) / 100 + 0.5, (gap[1] * (max - min) + min) / 100 - 0.5];
values = [...values, ...gapValues].sort((a, b) => a - b);
// reposition handles if needed, and update the gradient positions
const firstHandleIndex = values.findIndex((v) => v === gapValues[0]);
handleChange({ detail: { values, activeHandle: firstHandleIndex, value: values[firstHandleIndex] } });
};
/**
* update the range styles when the component mounts
*/
onMount(() => {
updateRangeStyle({ detail: { values } });
});
</script>
<!-- start the HTML/Svelte code -->
<div class="slider-wrapper" style={`${sunStyle}`}>
<span class="sunrise" title="sunrise time: {sunrise}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-sunset-2" ><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 13h1" /><path d="M20 13h1" /><path d="M5.6 6.6l.7 .7" /><path d="M18.4 6.6l-.7 .7" /><path d="M8 13a4 4 0 1 1 8 0" /><path d="M3 17h18" /><path d="M7 20h5" /><path d="M16 20h1" /><path d="M12 5v-1" /></svg>
</span>
<span class="sunset" title="sunset time: {sunset}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-haze-moon" ><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M3 16h18" /><path d="M3 20h18" /><path d="M8.296 16c-2.268 -1.4 -3.598 -4.087 -3.237 -6.916c.443 -3.48 3.308 -6.083 6.698 -6.084v.006h.296c-1.991 1.916 -2.377 5.03 -.918 7.405c1.459 2.374 4.346 3.33 6.865 2.275a6.888 6.888 0 0 1 -2.777 3.314" /></svg>
</span>
<RangeSlider
bind:slider
bind:values
id="final"
class="scheduler handle ranges bindings jazzed final"
style={`${rangeStyle}`}
step={0.25}
{min}
{max}
pips
all="label"
float
handleFormatter={decimalToTimeString}
on:change={handleChange}
/>
<div class="slider-actions">
<!-- buttons to remove each range, placed on the center of each range -->
{#if values.length > 2}
{#each rangePercentsArray as range}
<button
type="button"
title="remove range"
style={`left: ${(range[0] + range[1]) / 2}%`}
on:click={() => {
deleteRange(range);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-square-x" ><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M19 2h-14a3 3 0 0 0 -3 3v14a3 3 0 0 0 3 3h14a3 3 0 0 0 3 -3v-14a3 3 0 0 0 -3 -3zm-9.387 6.21l.094 .083l2.293 2.292l2.293 -2.292a1 1 0 0 1 1.497 1.32l-.083 .094l-2.292 2.293l2.292 2.293a1 1 0 0 1 -1.32 1.497l-.094 -.083l-2.293 -2.292l-2.293 2.292a1 1 0 0 1 -1.497 -1.32l.083 -.094l2.292 -2.293l-2.292 -2.293a1 1 0 0 1 1.32 -1.497z" /></svg>
</button>
{/each}
{/if}
<!-- buttons to add a range, placed between each existing range -->
{#if values.length < 16}
{#each rangeGapsPercentsArray as gap}
<button
type="button"
title="add range"
style={`left: ${(gap[0] + gap[1]) / 2}%`}
on:click={() => {
addRange(gap);
}}
>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-circle-plus" ><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M4.929 4.929a10 10 0 1 1 14.141 14.141a10 10 0 0 1 -14.14 -14.14zm8.071 4.071a1 1 0 1 0 -2 0v2h-2a1 1 0 1 0 0 2h2v2a1 1 0 1 0 2 0v-2h2a1 1 0 1 0 0 -2h-2v-2z" /></svg>
</button>
{/each}
{/if}
</div>
</div>
<!-- Render the pips styles for pips in ranges -->
{@html `<style>${pipsStyle}</style>`}
.scheduler.rangeSlider {
height: 40px;
border-radius: 0;
}
.scheduler.rangeSlider .rangePips {
--pip-active: black;
top: 0;
bottom: auto;
& > .rsPip {
height: 10px;
top: 0;
}
& .rsPipVal {
display: none;
top: -2em;
font-size: 12px;
font-family: monospace;
}
& > :nth-child(2n+1) {
height: 20px;
--pip: #999;
}
& > :nth-child(4n+1) {
height: 40px;
--pip: #888;
& > .rsPipVal {
display: block;
}
}
}
.scheduler.rangeSlider .rangeHandle {
top: auto;
bottom: 0;
transform: translateX(-50%);
}
.handle.rangeSlider .rangeHandle {
--icon: url('data:image/svg+xml,%3Csvg%20%20xmlns=%22http://www.w3.org/2000/svg%22%20%20width=%2224%22%20%20height=%2224%22%20%20viewBox=%220%200%2024%2024%22%20%20fill=%22currentColor%22%20%20class=%22icon%20icon-tabler%20icons-tabler-filled%20icon-tabler-arrow-badge-up%22%3E%3Cpath%20stroke=%22none%22%20d=%22M0%200h24v24H0z%22%20fill=%22none%22/%3E%3Cpath%20d=%22M11.375%206.22l-5%204a1%201%200%200%200%20-.375%20.78v6l.006%20.112a1%201%200%200%200%201.619%20.669l4.375%20-3.501l4.375%203.5a1%201%200%200%200%201.625%20-.78v-6a1%201%200%200%200%20-.375%20-.78l-5%20-4a1%201%200%200%200%20-1.25%200z%22%20/%3E%3C/svg%3E');
transform: translateY(100%) translateX(-50%);
&:before {
display: none;
}
& .rangeNub {
background: var(--icon);
}
}
.ranges.rangeSlider {
--slider-accent: rgba(114, 255, 168, 0.75);
/* create a start/end icon for the range handles */
/* we have to do it like this because svg cannot use var() */
--icon-start: url('data:image/svg+xml,%3Csvg%20%20xmlns=%22http://www.w3.org/2000/svg%22%20%20width=%2224%22%20%20height=%2224%22%20%20viewBox=%220%200%2024%2024%22%20%20fill=%22%230cb44c%22%20%20class=%22icon%20icon-tabler%20icons-tabler-filled%20icon-tabler-arrow-badge-up%22%3E%3Cpath%20stroke=%22none%22%20d=%22M0%200h24v24H0z%22%20fill=%22none%22/%3E%3Cpath%20d=%22M11.375%206.22l-5%204a1%201%200%200%200%20-.375%20.78v6l.006%20.112a1%201%200%200%200%201.619%20.669l4.375%20-3.501l4.375%203.5a1%201%200%200%200%201.625%20-.78v-6a1%201%200%200%200%20-.375%20-.78l-5%20-4a1%201%200%200%200%20-1.25%200z%22%20/%3E%3C/svg%3E');
--icon-end: url('data:image/svg+xml,%3Csvg%20%20xmlns=%22http://www.w3.org/2000/svg%22%20%20width=%2224%22%20%20height=%2224%22%20%20viewBox=%220%200%2024%2024%22%20%20fill=%22%23b40c2b%22%20%20class=%22icon%20icon-tabler%20icons-tabler-filled%20icon-tabler-arrow-badge-up%22%3E%3Cpath%20stroke=%22none%22%20d=%22M0%200h24v24H0z%22%20fill=%22none%22/%3E%3Cpath%20d=%22M11.375%206.22l-5%204a1%201%200%200%200%20-.375%20.78v6l.006%20.112a1%201%200%200%200%201.619%20.669l4.375%20-3.501l4.375%203.5a1%201%200%200%200%201.625%20-.78v-6a1%201%200%200%200%20-.375%20-.78l-5%20-4a1%201%200%200%200%20-1.25%200z%22%20/%3E%3C/svg%3E');
& .rangePips {
/* set the pip active color and text color */
--pip-active: color-mix(in srgb, var(--slider-accent) 50%, black);
--pip-active-text: #333;
}
/* set the start/end icon for the range handles */
& .rangeHandle {
& .rangeNub {
background: var(--icon-start);
}
& .rangeFloat {
color: #0cb44c;
}
}
/* set the end icon for every other handle */
& .rangeHandle:nth-child(2n+2) {
& .rangeNub {
background: var(--icon-end);
}
& .rangeFloat {
color: #b40c2b;
}
}
}
.bindings.rangeSlider {
& .rangeFloat {
font-family: var(--font-mono);
font-size: 12px;
background: var(--bg);
padding: 0.2em;
opacity: 1;
top: auto;
bottom: 0;
translate: -50% 75% 0.01px!}
}
:root {
--day-color: #e8c247;
--sunrise-color: #e87747;
--sunset-color: #5d639d;
--night-color: #0f1772;
}
.jazzed.rangeSlider {
width: 100%;
margin: 5rem 0 2rem!&:before {
content: '';
position: absolute;
top: -3px;
left: 0;
right: 0;
height: 1em;
translate: 0px -100% 0.01px;
background-image:
linear-gradient(
to right,
var(--night-color),
var(--sunset-color) calc(var(--sunrise) - 2px),
var(--sunrise-color) calc(var(--sunrise) + 2px),
var(--day-color),
var(--day-color),
var(--sunrise-color) calc(var(--sunset) - 2px),
var(--sunset-color) calc(var(--sunset) + 2px),
var(--night-color)
);
opacity: 0.5;
mask-image: linear-gradient(to right, transparent, black 10px calc(100% - 10px), transparent);
}
.rangePips .rsPip {
--pip-text: rgba(0, 0, 0, 0.5);
}
.rangePips .rsPipVal {
top: -25px;
text-shadow: 0 1px 0px rgba(255, 255, 255, 0.5);
}
.rangePips .rsPip.rsSelected {
color: var(--pip-active-text);
font-weight: 600;
}
}
.slider-wrapper {
position: relative;
.sunrise, .sunset {
color: color-mix(in srgb, var(--sunrise-color) 85%, black);
font-size: 12px;
position: absolute;
top: -2.5em;
left: var(--sunrise);
translate: -50% -100%;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 1px;
height: 0.75em;
background-color: currentColor;
translate: -50% 75% 0.01px;
}
}
.sunset {
color: color-mix(in srgb, var(--sunset-color) 85%, black);
left: var(--sunset);
}
}
.slider-wrapper {
& .slider-actions {
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
}
& .slider-actions button {
position: absolute;
top: 0;
translate: -50% -50% 0.01px;
background-color: transparent;
border: none;
cursor: pointer;
padding: 0.5em;
font-size: 14px;
box-shadow: none;
opacity: 0.1;
transition: all 0.2s ease-out;
&:hover {
opacity: 1!}
}
&:has(.rsFocus) .slider-actions button,
&:hover .slider-actions button {
opacity: 0.5;
}
}