Skip to content

Instantly share code, notes, and snippets.

@sorvell
Last active September 18, 2025 13:52
Show Gist options
  • Save sorvell/db12f54bc5c9f73d01b993cac99f80e8 to your computer and use it in GitHub Desktop.
Save sorvell/db12f54bc5c9f73d01b993cac99f80e8 to your computer and use it in GitHub Desktop.
<!DOCTYPE html>
<html lang="en">
<head>
<script>
console.clear();
</script>
<!-- <script type="module" src="./range-group.js"></script> -->
<script type="module" src="./range-group2.js"></script>
<style>
:root {
font-family: system-ui;
}
h3 { margin: 0;}
</style>
</head>
<body>
<link rel="stylesheet" href="style.css">
<style>
/* A demonstration of styling the web component using CSS Parts */
range-group {
/* These are custom properties the component uses internally */
/*--track-height: 8px;*/
/*--thumb-size: 28px;*/
/*--thumb-bg: oklch(70% 0.28 260); !* Bright Indigo *!*/
/*--tick-color: oklch(60% 0.02 230);*/
/*--tick-label-color: oklch(70% 0.02 230);*/
/* General component styling */
width: 100%;
}
range-group {
--transition: all 0.2s;
&:state(active) {
--transition: none;
}
&::part(segment), &::part(thumb) {
transition: var(--transition);
}
}
/* Styling the component parts from the outside */
range-group::part(track) {
background-color: oklch(25% 0.02 230);
border-radius: 9999px;
}
range-group::part(thumb) {
border: 2px solid oklch(95% 0.01 230);
border-radius: 50%;
}
</style>
<div class="container-wrapper">
<div class="main-container">
<h3>range-group</h3>
<div class="sections-container">
<section>
<p class="description">
A common use case for selecting a minimum and maximum value. The
segment between the handles is styled differently.
</p>
<div class="card">
<style>
#price-range::part(segment-1) {
background-color: oklch(35% 0.02 230);
}
#price-range::part(segment-2) {
background-color: var(--color-purple);
}
#price-range::part(segment-3) {
background-color: oklch(35% 0.02 230);
}
</style>
<range-group id="price-range" min="0" max="1000">
<legend slot="legend" class="color-purple">
Dual-Handle Base Example
</legend>
<label for="price-min-input" class="visually-hidden"
>Minimum Price</label
>
<input
type="range"
id="price-min-input"
name="price-min"
value="250"
/>
<label for="price-max-input" class="visually-hidden"
>Maximum Price</label
>
<input
type="range"
id="price-max-input"
name="price-max"
value="750"
/>
</range-group>
</div>
</section>
<section>
<p class="description">
Demonstrates two-way data binding. Drag the handles to update the
input fields, or type in the fields to move the handles.
</p>
<div class="card">
<style>
#interactive-price-range::part(segment-2) {
background: var(--color-teal);
}
#interactive-price-range::part(thumb) {
--thumb-bg: var(--color-teal);
}
</style>
<range-group id="interactive-price-range" min="0" max="1000">
<legend slot="legend" class="color-teal">
Interactive Price Range
</legend>
<label for="interactive-price-min-input" class="visually-hidden"
>Minimum Price</label
>
<input
type="range"
id="interactive-price-min-input"
name="price-min"
value="250"
/>
<label for="interactive-price-max-input" class="visually-hidden"
>Maximum Price</label
>
<input
type="range"
id="interactive-price-max-input"
name="price-max"
value="750"
/>
</range-group>
<div class="price-input-container">
<div class="price-input-group">
<label for="price-min-num-input">Min Price</label>
<input
type="number"
id="price-min-num-input"
min="0"
max="1000"
/>
</div>
<div class="price-input-group">
<label for="price-max-num-input">Max Price</label>
<input
type="number"
id="price-max-num-input"
min="0"
max="1000"
/>
</div>
</div>
</div>
</section>
<section>
<p class="description">
You could use stepbetween to prevent thumbs form colliding.
</p>
<div class="card">
<style>
#stepbetween-example::part(segment-1) {
background-color: oklch(0.554 0.104 233.66);
}
#stepbetween-example::part(segment-2) {
background-color: oklch(0.849 0.288 142.427);
}
#stepbetween-example::part(segment-3) {
background-color: oklch(0.554 0.104 233.66);
}
</style>
<range-group
id="stepbetween-example"
min="0"
max="100"
stepbetween="10"
>
<legend slot="legend" class="color-pink">
Dual-Handle with 10 stepbetween
</legend>
<label for="price-min-input-stepbetween" class="visually-hidden"
>Minimum Price</label
>
<input
type="range"
id="price-min-input-stepbetween"
name="price-min"
value="50"
/>
<label for="price-max-input-stepbetween" class="visually-hidden"
>Maximum Price</label
>
<input
type="range"
id="price-max-input-stepbetween"
name="price-max"
value="80"
/>
</range-group>
</div>
</section>
<section>
<p class="description">
Demonstrates multiple handles and custom-colored segments for
different temperature zones.
</p>
<div class="card">
<style>
#temp-range::part(segment-1) {
background-color: oklch(65% 0.25 260);
}
#temp-range::part(segment-2) {
background-color: oklch(80% 0.25 180);
}
#temp-range::part(segment-3) {
background-color: oklch(85% 0.25 90);
}
#temp-range::part(segment-4) {
background-color: oklch(70% 0.28 25);
}
#temp-range::part(thumb-1) {
--thumb-bg: oklch(65% 0.25 260);
border: 2px solid oklch(98% 0.01 230);
box-shadow: 0 0 12px 4px oklch(65% 0.25 260 / 0.75);
}
#temp-range::part(thumb-2) {
--thumb-bg: oklch(85% 0.25 90);
border: 2px solid oklch(98% 0.01 230);
box-shadow: 0 0 12px 4px oklch(85% 0.25 90 / 0.75);
}
#temp-range::part(thumb-3) {
--thumb-bg: oklch(70% 0.28 25);
border: 2px solid oklch(98% 0.01 230);
box-shadow: 0 0 12px 4px oklch(70% 0.28 25 / 0.75);
}
</style>
<range-group id="temp-range" min="-100" max="100">
<legend slot="legend" class="color-indigo">
Multi-Handle Temperature Control
</legend>
<label for="temp-cold-input" class="visually-hidden"
>Cold temperature bound</label
>
<input
type="range"
id="temp-cold-input"
name="temp-cold"
value="-50"
/>
<label for="temp-mild-input" class="visually-hidden"
>Mild temperature bound</label
>
<input
type="range"
id="temp-mild-input"
name="temp-mild"
value="0"
/>
<label for="temp-hot-input" class="visually-hidden"
>Hot temperature bound</label
>
<input
type="range"
id="temp-hot-input"
name="temp-hot"
value="50"
/>
</range-group>
</div>
</section>
<section>
<p class="description">
Demonstrates using invisible handles with the CSS Anchoring API to
position custom tooltips. The tooltips appear to be the draggable
handles, and their values are updated via JavaScript.
</p>
<div class="card" style="position: relative">
<style>
#floating-tooltip-range {
--track-height: 12px;
--thumb-size: 40px;
margin-top: 2rem;
margin-bottom: 2rem;
}
#floating-tooltip-range::part(track) {
background-color: oklch(40% 0.02 230);
}
#floating-tooltip-range::part(segment-2) {
background: var(--color-pink);
}
#floating-tooltip-range::part(thumb) {
anchor-name: var(--anchor-name);
background: transparent;
border: none;
box-shadow: none;
width: 40px;
height: 20px;
padding: 0;
border-radius: 9999px;
}
#floating-tooltip-range::part(thumb-1) {
--thumb-transform: translate(
-50%,
calc(-100% - var(--track-height) / 2 - 5px)
);
--anchor-name: --floating-thumb-1;
transform: var(--thumb-transform);
}
#floating-tooltip-range::part(thumb-2) {
--thumb-transform: translate(
-50%,
calc(0% + var(--track-height) / 2 + 5px)
);
--anchor-name: --floating-thumb-2;
transform: var(--thumb-transform);
}
/* Tooltip styling */
.floating-tooltip {
position: absolute;
position-anchor: var(--anchor-name);
pointer-events: none;
/* These styles create the tooltip shape */
border: none;
border-radius: 9999px;
background: white;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.3),
0 2px 4px -1px rgb(0 0 0 / 0.1);
width: 40px;
height: 28px;
color: oklch(10% 0.01 230);
font-weight: 700;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
translate: -50% -20%;
z-index: 10;
}
#floating-tooltip-1 {
--anchor-name: --floating-thumb-1;
bottom: anchor(top);
left: anchor(left);
}
#floating-tooltip-2 {
--anchor-name: --floating-thumb-2;
top: anchor(center);
left: anchor(left);
}
</style>
<range-group id="floating-tooltip-range" min="0" max="100">
<legend slot="legend" class="color-purple">
Floating Tooltip Handles
</legend>
<label for="floating-tooltip-input-1" class="visually-hidden"
>Value Handle 1</label
>
<input
type="range"
id="floating-tooltip-input-1"
name="floating-tooltip-1"
value="40"
/>
<label for="floating-tooltip-input-2" class="visually-hidden"
>Value Handle 2</label
>
<input
type="range"
id="floating-tooltip-input-2"
name="floating-tooltip-2"
value="65"
/>
</range-group>
<div
id="floating-tooltip-1"
class="floating-tooltip"
aria-hidden="true"
>
40
</div>
<div
id="floating-tooltip-2"
class="floating-tooltip"
aria-hidden="true"
>
65
</div>
</div>
</section>
<section>
<p class="description">
Shows integration with a <code>&lt;datalist&gt;</code> for tick
marks and labels, and enforces a minimum distance of 25 between
handles via <code>stepbetween="25"</code>.
</p>
<div class="card">
<range-group
min="0"
max="100"
list="rating-ticks"
stepbetween="25"
>
<legend slot="legend" class="color-teal">
Datalist Integration & Step Between
</legend>
<label for="rating-min-input" class="visually-hidden"
>Minimum rating</label
>
<input
type="range"
id="rating-min-input"
name="rating-min"
value="20"
/>
<label for="rating-max-input" class="visually-hidden"
>Maximum rating</label
>
<input
type="range"
id="rating-max-input"
name="rating-max"
value="80"
/>
</range-group>
<datalist id="rating-ticks">
<option value="0" label="0%"></option>
<option value="25"></option>
<option value="50" label="50%"></option>
<option value="75"></option>
<option value="100" label="100%"></option>
</datalist>
</div>
</section>
<section>
<p class="description">
A complete visual transformation to create a budget allocation
tool. The component's segments are styled as colored bars, and the
thumbs as draggable separators. All labels and percentages are
updated via JavaScript as well as an aria-polite to give feedback
to the user about the current value of the slider..
</p>
<div class="card" style="position: relative">
<style>
#budget-range {
--track-height: 50px;
--thumb-size: 40px;
--thumb-margin: calc(var(--track-height) - var(--thumb-size));
legend {
display: flex;
align-items: center;
justify-content: space-between;
label {
font-size: 1rem;
font-weight: normal;
display: flex;
align-items: center;
}
}
}
#budget-range::part(track) {
border-radius: 25px;
overflow: hidden;
}
#budget-range::part(thumb) {
box-sizing: border-box;
border: 4px solid var(--color-card-bg);
background-color: white;
background-image: url("data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --%3E%3Csvg width='800px' height='800px' viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill:none;stroke:%23000000;stroke-linecap:round;stroke-linejoin:bevel;stroke-width:1.5px;%7D%3C/style%3E%3C/defs%3E%3Cg id='ic-chevron-left-right'%3E%3Cpath class='cls-1' d='M15.3,16.78l4.11-4.11a1,1,0,0,0,0-1.41l-4-4'/%3E%3Cpath class='cls-1' d='M8.7,7.22,4.59,11.33a1,1,0,0,0,0,1.41l4,4'/%3E%3C/g%3E%3C/svg%3E");
background-position: center;
background-repeat: no-repeat;
background-size: 60%;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3),
0 2px 4px -1px rgba(0, 0, 0, 0.1);
}
/* Set anchor-name on segments via a CSS variable, matching the working tooltip pattern */
#budget-range::part(segment) {
anchor-name: var(--anchor-name);
}
#budget-range:has(input:checked) {
&::part(thumb) {
--extent: calc(100% - (var(--thumb-size) + var(--thumb-margin)));
left: calc(var(--thumb-margin)/2 + var(--thumb-position) * var(--extent));
}
&::part(segment) {
--o: calc(var(--thumb-size) + var(--thumb-margin));
--extent: calc(100% - (var(--thumb-size) + var(--thumb-margin)));
left: calc(var(--o)/2 + var(--segment-position) * var(--extent));
width: calc((var(--thumb-size) + var(--thumb-margin))/2 + var(--segment-size) * var(--extent));
}
&::part(segment-1) {
--o: 0px;
}
}
#budget-range::part(segment-1) {
background-color: #e53935;
--anchor-name: --budget-segment-1;
}
#budget-range::part(segment-2) {
background-color: #8e24aa;
--anchor-name: --budget-segment-2;
}
#budget-range::part(segment-3) {
background-color: #fdd835;
--anchor-name: --budget-segment-3;
}
#budget-range::part(segment-4) {
background-color: #3949ab;
--anchor-name: --budget-segment-4;
}
.budget-label {
color: white;
/* Anchor the label to its corresponding segment part */
position-anchor: var(--anchor);
position: fixed;
left: anchor(left);
right: anchor(right);
top: anchor(top);
bottom: anchor(bottom);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
text-align: center;
font-size: 0.875rem;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
line-height: 1.2;
pointer-events: none; /* Allow clicks to pass through to the slider */
br { display: contents; }
}
/* Assign the correct anchor name to each label using their specific IDs */
#budget-label-0 {
--anchor: --budget-segment-1;
}
#budget-label-1 {
--anchor: --budget-segment-2;
}
#budget-label-2 {
--anchor: --budget-segment-3;
}
#budget-label-3 {
--anchor: --budget-segment-4;
}
.budget-label strong {
font-size: 1rem;
font-weight: 700;
}
</style>
<div class="budget-range-wrapper">
<range-group id="budget-range" min="0" max="100">
<legend slot="legend" class="color-pink">
Budget Allocator <label>keep thumbs in track <input type="checkbox"></label>
</legend>
<label for="budget-1" class="visually-hidden"
>Groceries/Rent separator</label
>
<input type="range" id="budget-1" value="25" />
<label for="budget-2" class="visually-hidden"
>Rent/Transport separator</label
>
<input type="range" id="budget-2" value="50" />
<label for="budget-3" class="visually-hidden"
>Transport/Internet separator</label
>
<input type="range" id="budget-3" value="75" />
</range-group>
<div id="budget-labels" class="budget-labels">
<div id="budget-label-0" class="budget-label"></div>
<div id="budget-label-1" class="budget-label"></div>
<div id="budget-label-2" class="budget-label"></div>
<div id="budget-label-3" class="budget-label"></div>
</div>
</div>
<div
id="budget-announcer"
class="visually-hidden"
aria-live="polite"
role="status"
></div>
</div>
</section>
<section>
<p class="description">
Recreating a classic slider look. This demonstrates using the CSS
Anchoring API for tooltips with heavy custom styling on all
component parts.
</p>
<div class="card" style="position: relative">
<style>
#classic-range {
--track-height: 4px;
--thumb-size: 24px;
}
#classic-range::part(track) {
background: #444;
border: 1px solid #111;
border-radius: 0;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.5);
}
#classic-range::part(segment-2) {
background: #3b82f6;
}
#classic-range::part(thumb) {
anchor-name: var(--anchor-name);
background: linear-gradient(to bottom, #f9fafb, #d1d5db);
border: 1px solid #6b7280;
border-radius: 3px;
width: 14px;
height: var(--thumb-size);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
#classic-range::part(thumb-1) {
--anchor-name: --classic-thumb-1;
}
#classic-range::part(thumb-2) {
--anchor-name: --classic-thumb-2;
}
.classic-tooltip {
position: absolute;
position-anchor: var(--anchor-name);
position-area: top;
translate: -20% -60%;
background: #222;
color: white;
padding: 2px 8px;
border-radius: 3px;
border: 1px solid #555;
font-size: 0.875rem;
font-weight: 600;
pointer-events: none;
}
#classic-tooltip-1 {
--anchor-name: --classic-thumb-1;
}
#classic-tooltip-2 {
--anchor-name: --classic-thumb-2;
}
</style>
<range-group id="classic-range" min="0" max="100">
<legend slot="legend" class="color-indigo">
Classic Look with anchored Tooltips
</legend>
<label for="classic-1" class="visually-hidden"
>Classic Handle 1</label
>
<input
type="range"
id="classic-1"
name="classic-1"
value="20"
/>
<label for="classic-2" class="visually-hidden"
>Classic Handle 2</label
>
<input
type="range"
id="classic-2"
name="classic-2"
value="80"
/>
</range-group>
<div
id="classic-tooltip-1"
class="classic-tooltip"
aria-hidden="true"
>
20
</div>
<div
id="classic-tooltip-2"
class="classic-tooltip"
aria-hidden="true"
>
80
</div>
</div>
</section>
<section>
<p class="description">
Use the component's public methods and properties to get and set
values programmatically.
</p>
<div class="card">
<range-group id="api-range" min="0" max="500">
<legend slot="legend" class="color-purple">
JavaScript API Interaction
</legend>
<style>
#api-range::part(segment-2) {
background: linear-gradient(
to right,
var(--color-purple),
var(--color-pink)
);
}
</style>
<label for="api-1-input" class="visually-hidden"
>API Handle 1</label
>
<input type="range" id="api-1-input" name="api-1" value="100" />
<label for="api-2-input" class="visually-hidden"
>API Handle 2</label
>
<input type="range" id="api-2-input" name="api-2" value="400" />
</range-group>
<div class="button-group">
<button id="getValuesBtn" class="btn btn-indigo">
Get Values
</button>
<button id="setValuesBtn" class="btn btn-pink">
Set Random Values
</button>
</div>
<pre id="apiOutput" class="api-output"></pre>
</div>
</section>
<section>
<p class="description">
This example shows how to style individual tick marks and their
labels using CSS Parts. The '50%' tick is highlighted with a
different color and height.
</p>
<div class="card">
<style>
#custom-ticks-example::part(tick) {
background: var(--color-text-muted);
height: 8px;
transition: all 0.2s ease;
}
/* Style the 3rd tick (value="50") */
#custom-ticks-example::part(tick-3) {
background: var(--color-pink);
height: 16px; /* Taller */
width: 3px;
transform: translateX(-1.5px); /* Re-center */
}
/* Style the 3rd tick's label */
#custom-ticks-example::part(tick-label-3) {
color: var(--color-pink);
font-weight: bold;
}
</style>
<range-group
id="custom-ticks-example"
min="0"
max="100"
list="custom-ticks-datalist"
>
<legend slot="legend" class="color-pink">
Custom Tick Styling
</legend>
<label for="custom-tick-1" class="visually-hidden"
>Value 1</label
>
<input type="range" id="custom-tick-1" value="25" />
<label for="custom-tick-2" class="visually-hidden"
>Value 2</label
>
<input type="range" id="custom-tick-2" value="75" />
</range-group>
<datalist id="custom-ticks-datalist">
<option value="0" label="0%"></option>
<option value="25"></option>
<option value="50" label="50%"></option>
<option value="75"></option>
<option value="100" label="100%"></option>
</datalist>
</div>
</section>
<section>
<p class="description">
A more creative example styling ticks to look like milestones on a
project timeline. Key milestones are larger and have distinct
colors.
</p>
<div class="card">
<style>
#timeline-example {
--track-height: 2px;
}
#timeline-example::part(track) {
background: #555;
}
#timeline-example::part(thumb) {
--thumb-bg: var(--color-teal);
}
#timeline-example::part(segment-2) {
background: var(--color-teal);
}
#timeline-example::part(tick) {
background: #888;
width: 2px;
height: 10px;
border-radius: 2px;
}
/* Highlight the start (1st), middle (3rd), and end (5th) ticks */
#timeline-example::part(tick-1),
#timeline-example::part(tick-3),
#timeline-example::part(tick-5) {
background: var(--color-teal);
height: 20px;
width: 4px;
transform: translate(
-2px,
-7px
); /* Adjust position for height */
}
#timeline-example::part(tick-label) {
color: var(--color-text-muted);
}
#timeline-example::part(tick-label-1),
#timeline-example::part(tick-label-3),
#timeline-example::part(tick-label-5) {
color: var(--color-teal);
font-weight: bold;
}
</style>
<range-group
id="timeline-example"
min="0"
max="100"
list="timeline-datalist"
>
<legend slot="legend" class="color-teal">
Styled Timeline
</legend>
<label for="timeline-1" class="visually-hidden"
>Start Date</label
>
<input type="range" id="timeline-1" value="10" />
<label for="timeline-2" class="visually-hidden">End Date</label>
<input type="range" id="timeline-2" value="90" />
</range-group>
<datalist id="timeline-datalist">
<option value="0" label="Q1"></option>
<option value="25"></option>
<option value="50" label="Q3"></option>
<option value="75"></option>
<option value="100" label="End"></option>
</datalist>
</div>
</section>
<section>
<p class="description">
This example uses three handles and styled ticks to represent
different phases in a process, with each major tick mark having a
unique color.
</p>
<div class="card">
<style>
#multiphase-example::part(tick) {
height: 12px;
width: 2px;
}
#multiphase-example::part(tick-1) {
background: #aaa;
}
#multiphase-example::part(tick-label-1) {
color: #aaa;
}
#multiphase-example::part(tick-2) {
background: #fdd835;
} /* Yellow */
#multiphase-example::part(tick-label-2) {
color: #fdd835;
}
#multiphase-example::part(tick-3) {
background: #4caf50;
} /* Green */
#multiphase-example::part(tick-label-3) {
color: #4caf50;
}
#multiphase-example::part(tick-4) {
background: #3b82f6;
} /* Blue */
#multiphase-example::part(tick-label-4) {
color: #3b82f6;
}
</style>
<range-group
id="multiphase-example"
min="0"
max="100"
list="multiphase-datalist"
>
<legend slot="legend" class="color-indigo">
Three-Handle Process Flow
</legend>
<label for="multiphase-1" class="visually-hidden"
>Phase 1 End</label
>
<input type="range" id="multiphase-1" value="20" />
<label for="multiphase-2" class="visually-hidden"
>Phase 2 End</label
>
<input type="range" id="multiphase-2" value="50" />
<label for="multiphase-3" class="visually-hidden"
>Phase 3 End</label
>
<input type="range" id="multiphase-3" value="80" />
</range-group>
<datalist id="multiphase-datalist">
<option value="0" label="Start"></option>
<option value="33" label="Review"></option>
<option value="66" label="Approved"></option>
<option value="100" label="Complete"></option>
</datalist>
</div>
</section>
</div>
</div>
</div>
<script type="module">
document.addEventListener("DOMContentLoaded", () => {
// --- Interactive Price Range Example ---
const priceRange = document.getElementById("interactive-price-range");
const minPriceInput = document.getElementById("price-min-num-input");
const maxPriceInput = document.getElementById("price-max-num-input");
if (priceRange && minPriceInput && maxPriceInput) {
const syncInputsToSlider = () => {
const [minVal, maxVal] = priceRange.values.map((v) =>
Math.round(v)
);
minPriceInput.value = minVal;
maxPriceInput.value = maxVal;
minPriceInput.max = maxVal;
maxPriceInput.min = minVal;
};
minPriceInput.addEventListener("input", () => {
let value = parseInt(minPriceInput.value, 10);
if (isNaN(value)) return;
priceRange.setRangeValue(0, value);
syncInputsToSlider(); // Re-sync to handle clamping
});
maxPriceInput.addEventListener("input", () => {
let value = parseInt(maxPriceInput.value, 10);
if (isNaN(value)) return;
priceRange.setRangeValue(1, value);
syncInputsToSlider(); // Re-sync to handle clamping
});
priceRange.addEventListener("change", syncInputsToSlider);
priceRange.addEventListener("input", syncInputsToSlider);
syncInputsToSlider(); // Initial sync
}
// --- Budget Allocator Example ---
const budgetRange = document.getElementById("budget-range");
const budgetLabels = [
document.getElementById("budget-label-0"),
document.getElementById("budget-label-1"),
document.getElementById("budget-label-2"),
document.getElementById("budget-label-3"),
];
const budgetCategories = ["Groceries", "Rent", "Transport", "Internet"];
const budgetAnnouncer = document.getElementById("budget-announcer");
if (budgetRange && budgetAnnouncer && budgetLabels.every((l) => l)) {
const updateBudget = () => {
const values = [0, ...budgetRange.values, 100];
let announcementText = "Budget updated. ";
for (let i = 0; i < budgetLabels.length; i++) {
const start = values[i];
const end = values[i + 1];
const percentage = Math.round(end - start);
const label = budgetLabels[i];
label.innerHTML = `<strong>${budgetCategories[i]}</strong><br>${percentage}%`;
announcementText += `${budgetCategories[i]}: ${percentage}%. `;
}
// Update the live region for screen readers
budgetAnnouncer.textContent = announcementText.trim();
};
updateBudget();
budgetRange.addEventListener("input", updateBudget);
}
// --- Floating Tooltip Example ---
const floatingRange = document.getElementById("floating-tooltip-range");
const floatingTooltip1 = document.getElementById("floating-tooltip-1");
const floatingTooltip2 = document.getElementById("floating-tooltip-2");
if (floatingRange && floatingTooltip1 && floatingTooltip2) {
const tooltips = [floatingTooltip1, floatingTooltip2];
const updateFloatingTooltips = () => {
const values = floatingRange.values;
if (values && values.length >= 2) {
tooltips[0].textContent = Math.round(values[0]);
tooltips[1].textContent = Math.round(values[1]);
}
};
updateFloatingTooltips();
floatingRange.addEventListener("change", updateFloatingTooltips);
}
// --- Classic Tooltip Example ---
const classicRange = document.getElementById("classic-range");
const classicTooltip1 = document.getElementById("classic-tooltip-1");
const classicTooltip2 = document.getElementById("classic-tooltip-2");
if (classicRange && classicTooltip1 && classicTooltip2) {
const updateClassicTooltips = () => {
const values = classicRange.values;
classicTooltip1.textContent = Math.round(values[0]);
classicTooltip2.textContent = Math.round(values[1]);
};
updateClassicTooltips();
classicRange.addEventListener("change", updateClassicTooltips);
}
// --- API Example ---
const apiRange = document.getElementById("api-range");
const getValuesBtn = document.getElementById("getValuesBtn");
const setValuesBtn = document.getElementById("setValuesBtn");
const apiOutput = document.getElementById("apiOutput");
if (apiRange && getValuesBtn && setValuesBtn && apiOutput) {
getValuesBtn.addEventListener("click", () => {
apiOutput.textContent = `Values: [${apiRange.values.join(", ")}]`;
});
setValuesBtn.addEventListener("click", () => {
const val1 = Math.round(Math.random() * 500);
const val2 = Math.round(Math.random() * 500);
apiRange.setRangeValue(0, Math.min(val1, val2));
apiRange.setRangeValue(1, Math.max(val1, val2));
apiOutput.textContent = `Set to: [${apiRange.values.join(", ")}]`;
});
}
});
</script>
</body>
</html>
{
"dependencies": {
"lit": "^3.0.0",
"@lit/reactive-element": "^2.0.0",
"lit-element": "^4.0.0",
"lit-html": "^3.0.0"
}
}
import { LitElement, html, css, PropertyValueMap } from "lit";
import {
customElement,
property,
state,
queryAssignedElements,
} from "lit/decorators.js";
@customElement("range-group")
export class RangeGroup extends LitElement {
declare shadowRoot: ShadowRoot;
declare dispatchEvent: (event: Event) => boolean;
declare hasAttribute: (name: string) => boolean;
declare requestUpdate: (name?: PropertyKey, oldValue?: unknown) => void;
@property({ type: Number }) min: number;
@property({ type: Number }) max: number;
@property({ type: Number }) stepbetween: number;
@property({ type: String }) list: string;
@state() private _values: number[];
@state() private _datalistOptions: { value: string; label: string }[];
@queryAssignedElements({ selector: 'input[type="range"]' })
private _inputs!: HTMLInputElement[];
@queryAssignedElements({ slot: "legend", selector: "legend" })
private _legendElements!: HTMLLegendElement[];
private _activeThumbIndex: number | null = null;
private _containerRect: DOMRect | null = null;
private _pendingActivation: { indices: number[]; initialX: number } | null =
null;
private _uniqueId = Math.random().toString(36).substring(2, 9);
constructor() {
super();
this.min = 0;
this.max = 100;
this.stepbetween = 0;
this.list = "";
this._values = [];
this._datalistOptions = [];
}
// Public API
get values(): number[] {
return this._values;
}
get inputs(): HTMLInputElement[] {
return this._inputs;
}
getRangeInput(index: number): HTMLInputElement | undefined {
return this._inputs[index];
}
setRangeValue(index: number, value: number) {
if (this._inputs[index]) {
this._inputs[index].value = String(this._normalizeValue(value, index));
this._handleInputChange();
}
}
connectedCallback() {
super.connectedCallback();
window.addEventListener("pointermove", this._handlePointerMove);
window.addEventListener("pointerup", this._handlePointerUp);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("pointermove", this._handlePointerMove);
window.removeEventListener("pointerup", this._handlePointerUp);
}
protected firstUpdated(
_changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
): void {
this._initializeInputs();
this._parseDatalist();
this.requestUpdate(); // Request update to process legend
}
private _onSlotChange() {
this._initializeInputs();
this.requestUpdate();
}
private _getAccessibleName(input: HTMLInputElement, index: number): string {
let controlLabel: string | null = null;
if (input) {
// 1. aria-labelledby
const labelledby = input.getAttribute("aria-labelledby");
if (labelledby) {
const labelElement = document.getElementById(labelledby);
controlLabel = labelElement?.textContent?.trim() || null;
}
// 2. aria-label
if (!controlLabel) {
controlLabel = input.getAttribute("aria-label");
}
// 3. <label for="...">
if (!controlLabel && input.id) {
const label = document.querySelector<HTMLLabelElement>(
`label[for="${input.id}"]`
);
controlLabel = label?.textContent?.trim() || null;
}
// 4. Fallback to name attribute
if (!controlLabel) {
controlLabel = input.name;
}
}
// 5. Final fallback
const finalControlLabel = controlLabel || `value ${index + 1}`;
const legendText = this._legendElements?.[0]?.textContent?.trim();
if (legendText) {
// Combine legend and control label for better context in screen readers.
// Using a comma provides a natural pause.
return `${legendText}, ${finalControlLabel}`;
}
return finalControlLabel;
}
private _initializeInputs() {
if (this._inputs.length === 0) return;
// Must sort by attribute value, as the `value` property may have been clamped
// by the browser before our `min` and `max` attributes have been propagated.
this._inputs.sort(
(a, b) =>
Number(a.getAttribute("value")) - Number(b.getAttribute("value"))
);
this._inputs.forEach((input) => {
// Propagate min/max from this component to the underlying inputs.
if (this.hasAttribute("min")) input.min = String(this.min);
if (this.hasAttribute("max")) input.max = String(this.max);
// Crucially, re-apply the value from the attribute *after* setting min/max.
// This corrects any clamping the browser did with the default min=0.
const initialValue = input.getAttribute("value");
if (initialValue !== null) {
input.value = initialValue;
}
// Set up event listeners
input.removeEventListener("input", this._handleInputChange);
input.removeEventListener("change", this._handleInputChange);
input.addEventListener("input", this._handleInputChange);
input.addEventListener("change", this._handleInputChange);
});
// Initialize the component's internal state from the now-correct input values.
this._handleInputChange();
}
private _parseDatalist() {
if (!this.list) return;
const datalist = document.getElementById(this.list);
if (datalist instanceof HTMLDataListElement) {
this._datalistOptions = Array.from(datalist.options).map((opt) => ({
value: opt.value,
label: opt.label || opt.value,
}));
}
}
private _handleInputChange = () => {
this._values = this._inputs.map((input, index) =>
this._normalizeValue(Number(input.value), index)
);
this.dispatchEvent(
new CustomEvent("change", { detail: { values: this._values } })
);
this.requestUpdate();
};
private _handlePointerDown(e: PointerEvent, index: number) {
if (e.button !== 0) return; // Only main button
e.preventDefault();
this._containerRect =
this.shadowRoot?.querySelector(".container")?.getBoundingClientRect() ??
null;
const currentValue = this._values[index];
const overlappingIndices = this._values.reduce((acc, v, i) => {
if (Math.abs(v - currentValue) < 0.001) {
// Use a tolerance for float comparison
acc.push(i);
}
return acc;
}, [] as number[]);
if (overlappingIndices.length > 1) {
// Overlap detected: defer activation and focus until pointer move.
this._pendingActivation = {
indices: overlappingIndices,
initialX: e.clientX,
};
this._activeThumbIndex = null;
} else {
// No overlap: activate and focus immediately.
(e.target as HTMLElement).focus();
this._activeThumbIndex = index;
}
(e.target as HTMLElement).setPointerCapture(e.pointerId);
}
private _handlePointerMove = (e: PointerEvent) => {
// If a thumb activation is pending, determine which thumb to activate based on drag direction.
if (this._pendingActivation) {
const dx = e.clientX - this._pendingActivation.initialX;
// Wait for a clear move to determine direction to avoid accidental activation.
if (Math.abs(dx) > 2) {
const direction = dx > 0 ? "right" : "left";
const indices = this._pendingActivation.indices;
// On left drag, grab the leftmost thumb of the stack.
// On right drag, grab the rightmost thumb of the stack.
this._activeThumbIndex =
direction === "left" ? Math.min(...indices) : Math.max(...indices);
// Now that the thumb is active, find its element and focus it.
const thumbElements =
this.shadowRoot?.querySelectorAll<HTMLElement>(".thumb");
if (thumbElements && thumbElements[this._activeThumbIndex]) {
thumbElements[this._activeThumbIndex].focus();
}
this._pendingActivation = null; // Activation is decided for this drag session.
}
}
if (this._activeThumbIndex === null || !this._containerRect) return;
e.preventDefault();
const percent = Math.max(
0,
Math.min(
1,
(e.clientX - this._containerRect.left) / this._containerRect.width
)
);
const value = this.min + percent * (this.max - this.min);
this.setRangeValue(this._activeThumbIndex, value);
};
private _handlePointerUp = (e: PointerEvent) => {
if (this._activeThumbIndex !== null || this._pendingActivation !== null) {
e.preventDefault();
try {
(e.target as HTMLElement).releasePointerCapture(e.pointerId);
} catch (err) {
// This can happen if pointer capture is lost for other reasons; it's safe to ignore.
}
}
this._activeThumbIndex = null;
this._pendingActivation = null;
this._containerRect = null;
};
private _handleKeyDown(e: KeyboardEvent, index: number) {
const input = this._inputs[index];
if (!input) return;
let step = Number(input.step) || 1;
let newValue = Number(input.value);
// If using a datalist, step becomes the next/previous option
if (this.list && this._datalistOptions.length > 0) {
const sortedValues = this._datalistOptions
.map((opt) => Number(opt.value))
.sort((a, b) => a - b);
const currentIndex = sortedValues.indexOf(newValue);
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
newValue = sortedValues[Math.max(0, currentIndex - 1)];
} else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
newValue =
sortedValues[Math.min(sortedValues.length - 1, currentIndex + 1)];
}
} else {
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
newValue -= step;
} else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
newValue += step;
}
}
if (e.key === "Home") {
newValue = this.min;
} else if (e.key === "End") {
newValue = this.max;
} else if (
e.key !== "ArrowLeft" &&
e.key !== "ArrowDown" &&
e.key !== "ArrowRight" &&
e.key !== "ArrowUp"
) {
return;
}
e.preventDefault();
this.setRangeValue(index, newValue);
}
private _snapToDataList(value: number): number {
if (
!this.list ||
!this._datalistOptions ||
this._datalistOptions.length === 0
) {
return value;
}
const validValues = this._datalistOptions.map((opt) => Number(opt.value));
// Find the closest value in the datalist
const closest = validValues.reduce((prev, curr) => {
return Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev;
});
return closest;
}
private _normalizeValue(value: number, index: number): number {
const input = this._inputs[index];
if (!input) return value;
let targetValue = value;
// Snap to datalist first if it exists
if (this.list && this._datalistOptions.length > 0) {
targetValue = this._snapToDataList(targetValue);
}
// Use component's min/max as the source of truth for clamping.
// This is more reliable than reading from the child input element during initialization.
const minVal = this.min;
const maxVal = this.max;
let finalValue = Math.max(minVal, Math.min(maxVal, targetValue));
const step = this.stepbetween || 0;
// Check against previous handle
const prevInput = this._inputs[index - 1];
if (prevInput) {
const minAllowed = Number(prevInput.value) + step;
if (finalValue < minAllowed) {
// If datalist exists, find the next valid option. Otherwise, just use the calculated min.
if (this.list && this._datalistOptions.length > 0) {
const sortedValues = this._datalistOptions
.map((opt) => Number(opt.value))
.sort((a, b) => a - b);
finalValue = sortedValues.find((v) => v >= minAllowed) ?? finalValue;
} else {
finalValue = minAllowed;
}
}
}
// Check against next handle
const nextInput = this._inputs[index + 1];
if (nextInput) {
const maxAllowed = Number(nextInput.value) - step;
if (finalValue > maxAllowed) {
if (this.list && this._datalistOptions.length > 0) {
const sortedValues = this._datalistOptions
.map((opt) => Number(opt.value))
.sort((a, b) => a - b);
finalValue =
[...sortedValues].reverse().find((v) => v <= maxAllowed) ??
finalValue;
} else {
finalValue = maxAllowed;
}
}
}
// Re-clamp (to ensure we didn't go out of bounds)
return Math.max(minVal, Math.min(maxVal, finalValue));
}
private _valueToPercent(value: number): number {
return ((value - this.min) / (this.max - this.min)) * 100;
}
render() {
const segmentPoints = [
0,
...this._values.map((v) => this._valueToPercent(v)),
100,
];
const legend = this._legendElements?.[0];
if (legend && !legend.id) {
legend.id = `rg-legend-${this._uniqueId}`;
}
const legendId = legend?.id;
return html`
<fieldset class="wrapper">
<slot name="legend" @slotchange=${this._onSlotChange}></slot>
<div class="container" role="group" aria-labelledby=${legendId || ""}>
<div part="track" class="track">
${segmentPoints.slice(0, -1).map((p, i) => {
const left = p;
const width = segmentPoints[i + 1] - p;
return html`<div
part="segment segment-${i + 1}"
class="segment"
style="left: ${left}%; width: ${width}%;"
></div>`;
})}
</div>
${this._datalistOptions.length > 0
? html`
<div class="ticks-wrapper">
<div class="tick-marks" part="ticks">
${this._datalistOptions.map(
(opt, index) => html`
<div
part="tick tick-${index + 1}"
class="tick"
style="left: ${this._valueToPercent(
Number(opt.value)
)}%"
></div>
`
)}
</div>
<div class="tick-labels" part="tick-labels">
${this._datalistOptions.map(
(opt, index) => html`
<div
part="tick-label tick-label-${index + 1}"
class="tick-label"
style="left: ${this._valueToPercent(
Number(opt.value)
)}%"
>
${opt.label}
</div>
`
)}
</div>
</div>
`
: ""}
${this._values.map(
(value, index) => html`
<button
part="thumb thumb-${index + 1}"
class="thumb"
style="left: ${this._valueToPercent(
value
)}%; z-index: ${index === this._activeThumbIndex ? 12 : 10};"
role="slider"
tabindex="0"
aria-label=${this._getAccessibleName(
this._inputs[index],
index
)}
aria-valuemin=${this.min}
aria-valuemax=${this.max}
aria-valuenow=${Math.round(value)}
@pointerdown=${(e: PointerEvent) =>
this._handlePointerDown(e, index)}
@keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, index)}
></button>
`
)}
</div>
<slot @slotchange=${this._onSlotChange} style="display: none;"></slot>
</fieldset>
`;
}
static styles = css`
:host {
display: block;
position: relative;
padding-bottom: calc(var(--thumb-size, 24px) / 2);
box-sizing: content-box;
--track-height: 6px;
}
.wrapper {
border: 0;
padding: 0;
margin: 0;
}
.container {
position: relative;
width: 100%;
height: var(--thumb-size, 24px);
margin-top: calc(var(--thumb-size, 24px) / 2);
}
.track {
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: var(--track-height);
transform: translateY(-50%);
background-color: #ccc;
}
.segment {
position: absolute;
top: 0;
height: 100%;
background-color: #999;
}
.thumb {
position: absolute;
top: 50%;
width: var(--thumb-size, 24px);
height: var(--thumb-size, 24px);
background-color: var(--thumb-bg, #007bff);
border-radius: 50%;
transform: translate(-50%, -50%);
cursor: pointer;
touch-action: none;
box-shadow: 0 0 8px 0 var(--thumb-bg, #007bff);
transition: transform 0.1s ease-in-out;
border: none;
padding: 0;
}
.thumb::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: calc(var(--thumb-size, 24px) + 20px);
height: calc(var(--thumb-size, 24px) + 20px);
background: transparent;
border-radius: 50%;
}
.thumb:hover {
transform: translate(-50%, -50%) scale(1.1);
}
.thumb:focus-visible {
outline-offset: 4px;
}
.ticks-wrapper {
position: absolute;
top: calc(50% + var(--track-height) / 2 + 4px);
left: 0;
right: 0;
height: 20px;
pointer-events: none;
}
.tick-marks,
.tick-labels {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.tick {
position: absolute;
transform: translateX(-50%);
width: 1px;
height: 6px;
background: var(--tick-color, currentColor);
}
.tick-label {
position: absolute;
top: 10px; /* 6px tick height + 4px gap */
transform: translateX(-50%);
font-size: 0.75rem;
color: var(--tick-label-color, currentColor);
}
`;
}
import { LitElement, html, css, PropertyValueMap } from "lit";
import {
customElement,
property,
state,
queryAssignedElements,
queryAll,
} from "lit/decorators.js";
const shallowEqual = (arr1, arr2) =>
arr1?.length === arr2?.length &&
arr1.every((val, i) => val === arr2[i]);
@customElement("range-group")
export class RangeGroup extends LitElement {
@property({ type: Number })
accessor min = 0;
@property({ type: Number })
accessor max = 100;
@property({ type: Number })
accessor stepBetween = 0;
@property({ type: String })
accessor list = '';
@state()
private accessor _values = [];
// used to determine if an event should fire.
private _previousInputValues = [];
private _previousChangeValues = [];
@state()
private accessor _datalistOptions = [] as { value: string; label: string }[];
@queryAssignedElements({ selector: 'input[type="range"]' })
private _inputs!: HTMLInputElement[];
@queryAll('.thumb')
private _thumbs!: HTMLButtonElement[];
@queryAssignedElements({ slot: "legend", selector: "legend" })
private _legendElements!: HTMLLegendElement[];
private _activeThumbIndex: number | null = null;
private _containerRect: DOMRect | null = null;
private _pendingActivation: { indices: number[]; initialX: number } | null =
null;
private _uniqueId = Math.random().toString(36).substring(2, 9);
private _internals = this.attachInternals();
// Public API
get values(): number[] {
return this._values;
}
get inputs(): HTMLInputElement[] {
return this._inputs;
}
getRangeInput(index: number): HTMLInputElement | undefined {
return this._inputs[index];
}
setRangeValue(index: number, value: number) {
if (this._inputs[index]) {
this._inputs[index].value = String(this._normalizeValue(value, index));
this._updateValues();
}
}
protected willUpdate(
changedProperties: PropertyValueMap<any> | Map<PropertyKey, unknown>
): void {
if (changedProperties.has('list')) {
this._parseDatalist();
}
}
private _onSlotChange() {
this._initializeInputs();
}
private _getAccessibleName(input: HTMLInputElement, index: number): string {
let controlLabel: string | null = null;
if (input) {
// 1. aria-labelledby
const labelledby = input.getAttribute("aria-labelledby");
if (labelledby) {
const labelElement = document.getElementById(labelledby);
controlLabel = labelElement?.textContent?.trim() || null;
}
// 2. aria-label
if (!controlLabel) {
controlLabel = input.getAttribute("aria-label");
}
// 3. <label for="...">
if (!controlLabel && input.id) {
const label = document.querySelector<HTMLLabelElement>(
`label[for="${input.id}"]`
);
controlLabel = label?.textContent?.trim() || null;
}
// 4. Fallback to name attribute
if (!controlLabel) {
controlLabel = input.name;
}
}
// 5. Final fallback
const finalControlLabel = controlLabel || `value ${index + 1}`;
const legendText = this._legendElements?.[0]?.textContent?.trim();
if (legendText) {
// Combine legend and control label for better context in screen readers.
// Using a comma provides a natural pause.
return `${legendText}, ${finalControlLabel}`;
}
return finalControlLabel;
}
private _initializeInputs() {
if (this._inputs.length === 0) return;
// Must sort by attribute value, as the `value` property may have been clamped
// by the browser before our `min` and `max` attributes have been propagated.
this._inputs.sort(
(a, b) =>
Number(a.getAttribute("value")) - Number(b.getAttribute("value"))
);
this._inputs.forEach((input) => {
// Propagate min/max from this component to the underlying inputs.
if (this.hasAttribute("min")) input.min = String(this.min);
if (this.hasAttribute("max")) input.max = String(this.max);
// Crucially, re-apply the value from the attribute *after* setting min/max.
// This corrects any clamping the browser did with the default min=0.
const initialValue = input.getAttribute("value");
if (initialValue !== null) {
input.value = initialValue;
}
});
// Initialize the component's internal state from the now-correct input values.
this._updateValues();
}
private _parseDatalist() {
if (!this.list) return;
const datalist = document.getElementById(this.list);
if (datalist instanceof HTMLDataListElement) {
this._datalistOptions = Array.from(datalist.options).map((opt) => ({
value: opt.value,
label: opt.label || opt.value,
}));
}
}
private _updateValues() {
this._values = this._inputs.map((input, index) =>
this._normalizeValue(Number(input.value), index)
);
}
private async _dispatch(name = 'change') {
let shouldDispatch = true;
if (name === 'change') {
shouldDispatch = !shallowEqual(this._values, this._previousChangeValues);
this._previousChangeValues = this._values;
} else {
shouldDispatch = !shallowEqual(this._values, this._previousInputValues);
this._previousInputValues = this._values;
}
if (!shouldDispatch) {
return;
}
await this.updateComplete;
this.dispatchEvent(new Event(name, {bubbles: true, composed: true, cancelable: true}));
}
private _findClosest(value) {
return this._values.reduce((x, v, i) => {
const current = Math.abs(v - value);
const min = Math.abs(this._values[x] - value);
return current < min ? i : x;
}, 0);
}
private _pointerToValue(e) {
const percent = Math.max(
0,
Math.min(
1,
(e.clientX - this._containerRect.left) / this._containerRect.width
)
);
return this.min + percent * (this.max - this.min);
}
private _handlePointerDown(e: PointerEvent, index: number) {
if (e.button !== 0) return; // Only main button
e.preventDefault();
this._containerRect =
this.shadowRoot?.querySelector(".container")?.getBoundingClientRect() ??
null;
const pointerValue = this._pointerToValue(e);
// select the right thumb to move...
// 1. if the target is a thumb, use that.
index ??= Array.from(this._thumbs).indexOf(e.target as HTMLButtonElement);
let currentValue = this._values[index];
// 2. otherwise use focused input if the pointer value is in its range
if (index < 0) {
const {activeElement} = this.shadowRoot;
index = Array.from(this._thumbs).indexOf(activeElement as HTMLButtonElement);
const input = this._inputs[index];
if (input) {
const min = Math.max(Number(input.min), this._values[index-1] ?? -Infinity);
const max = Math.min(Number(input.max), this._values[index+1] ?? Infinity);
if (min <= pointerValue && pointerValue <= max) {
currentValue = pointerValue;
} else {
index = -1;
}
}
}
// 3. otherwise use closest thumb.
if (index < 0) {
index = this._findClosest(pointerValue);
currentValue = pointerValue;
}
const target = this._thumbs[index];
const overlappingIndices = this._values.reduce((acc, v, i) => {
if (Math.abs(v - currentValue) < 0.001) {
// Use a tolerance for float comparison
acc.push(i);
}
return acc;
}, [] as number[]);
if (overlappingIndices.length > 1) {
// Overlap detected: defer activation and focus until pointer move.
this._pendingActivation = {
indices: overlappingIndices,
initialX: e.clientX,
};
this._activeThumbIndex = null;
} else {
// No overlap: activate and focus immediately.
target.focus();
this._activeThumbIndex = index;
}
target.setPointerCapture(e.pointerId);
if (this._activeThumbIndex !== null) {
this._handlePointerMove(e);
}
}
private _handlePointerMove = (e: PointerEvent) => {
// If a thumb activation is pending, determine which thumb to activate based on drag direction.
if (this._pendingActivation) {
const dx = e.clientX - this._pendingActivation.initialX;
// Wait for a clear move to determine direction to avoid accidental activation.
if (Math.abs(dx) > 2) {
const direction = dx > 0 ? "right" : "left";
const indices = this._pendingActivation.indices;
// On left drag, grab the leftmost thumb of the stack.
// On right drag, grab the rightmost thumb of the stack.
this._activeThumbIndex =
direction === "left" ? Math.min(...indices) : Math.max(...indices);
// Now that the thumb is active, find its element and focus it.
this._thumbs[this._activeThumbIndex]?.focus();
this._pendingActivation = null; // Activation is decided for this drag session.
}
}
if (this._activeThumbIndex === null || !this._containerRect) {
return;
}
e.preventDefault();
const value = this._pointerToValue(e);
this.setRangeValue(this._activeThumbIndex, value);
if (e.type === 'pointermove') {
this._internals.states.add('active');
}
this._dispatch('input');
};
private _handlePointerUp = (e: PointerEvent) => {
if (this._activeThumbIndex !== null || this._pendingActivation !== null) {
e.preventDefault();
}
this._activeThumbIndex = null;
this._pendingActivation = null;
this._containerRect = null;
this._internals.states.delete('active');
this._dispatch('change');
};
private _handleKeyDown(e: KeyboardEvent, index: number) {
const input = this._inputs[index];
if (!input) return;
let step = Number(input.step) || 1;
let newValue = Number(input.value);
// If using a datalist, step becomes the next/previous option
if (this.list && this._datalistOptions.length > 0) {
const sortedValues = this._datalistOptions
.map((opt) => Number(opt.value))
.sort((a, b) => a - b);
const currentIndex = sortedValues.indexOf(newValue);
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
newValue = sortedValues[Math.max(0, currentIndex - 1)];
} else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
newValue =
sortedValues[Math.min(sortedValues.length - 1, currentIndex + 1)];
}
} else {
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
newValue -= step;
} else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
newValue += step;
}
}
if (e.key === "Home") {
newValue = this.min;
} else if (e.key === "End") {
newValue = this.max;
} else if (
e.key !== "ArrowLeft" &&
e.key !== "ArrowDown" &&
e.key !== "ArrowRight" &&
e.key !== "ArrowUp"
) {
return;
}
e.preventDefault();
this.setRangeValue(index, newValue);
this._dispatch('input');
this._dispatch('change');
}
private _snapToDataList(value: number): number {
if (
!this.list ||
!this._datalistOptions ||
this._datalistOptions.length === 0
) {
return value;
}
const validValues = this._datalistOptions.map((opt) => Number(opt.value));
// Find the closest value in the datalist
const closest = validValues.reduce((prev, curr) => {
return Math.abs(curr - value) < Math.abs(prev - value) ? curr : prev;
});
return closest;
}
private _normalizeValue(value: number, index: number): number {
const input = this._inputs[index];
if (!input) return value;
let targetValue = value;
// Snap to datalist first if it exists
if (this.list && this._datalistOptions.length > 0) {
targetValue = this._snapToDataList(targetValue);
}
// Use component's min/max as the source of truth for clamping.
// This is more reliable than reading from the child input element during initialization.
const minVal = this.min;
const maxVal = this.max;
let finalValue = Math.max(minVal, Math.min(maxVal, targetValue));
const step = this.stepBetween || 0;
// Check against previous handle
const prevInput = this._inputs[index - 1];
if (prevInput) {
const minAllowed = Number(prevInput.value) + step;
if (finalValue < minAllowed) {
// If datalist exists, find the next valid option. Otherwise, just use the calculated min.
if (this.list && this._datalistOptions.length > 0) {
const sortedValues = this._datalistOptions
.map((opt) => Number(opt.value))
.sort((a, b) => a - b);
finalValue = sortedValues.find((v) => v >= minAllowed) ?? finalValue;
} else {
finalValue = minAllowed;
}
}
}
// Check against next handle
const nextInput = this._inputs[index + 1];
if (nextInput) {
const maxAllowed = Number(nextInput.value) - step;
if (finalValue > maxAllowed) {
if (this.list && this._datalistOptions.length > 0) {
const sortedValues = this._datalistOptions
.map((opt) => Number(opt.value))
.sort((a, b) => a - b);
finalValue =
[...sortedValues].reverse().find((v) => v <= maxAllowed) ??
finalValue;
} else {
finalValue = maxAllowed;
}
}
}
// Re-clamp (to ensure we didn't go out of bounds)
return Math.max(minVal, Math.min(maxVal, finalValue));
}
private _valueToPercent(value: number): number {
return ((value - this.min) / (this.max - this.min)) * 100;
}
render() {
const segmentPoints = [
0,
...this._values.map((v) => this._valueToPercent(v)),
100,
];
const legend = this._legendElements?.[0];
if (legend && !legend.id) {
legend.id = `rg-legend-${this._uniqueId}`;
}
const legendId = legend?.id;
return html`
<fieldset class="wrapper">
<slot name="legend" @slotchange=${this._onSlotChange}></slot>
<div class="container" role="group" aria-labelledby=${legendId || ""}
@pointermove=${this._handlePointerMove}
@lostpointercapture=${this._handlePointerUp}
@pointerdown=${this._handlePointerDown}>
<div part="track" class="track">
${segmentPoints.slice(0, -1).map((p, i) => {
const left = p;
const width = segmentPoints[i + 1] - p;
return html`<div
part="segment segment-${i + 1}"
class="segment"
style="--segment-position: ${0.01 * left}; --segment-size: ${0.01 * width};"
></div>`;
})}
</div>
${this._datalistOptions.length > 0
? html`
<div class="ticks-wrapper">
<div class="tick-marks" part="ticks">
${this._datalistOptions.map(
(opt, index) => html`
<div
part="tick tick-${index + 1}"
class="tick"
style="--tick-position: ${this._valueToPercent(
Number(opt.value)
)}%"
></div>
`
)}
</div>
<div class="tick-labels" part="tick-labels">
${this._datalistOptions.map(
(opt, index) => html`
<div
part="tick-label tick-label-${index + 1}"
class="tick-label"
style="--tick-position: ${0.01 * this._valueToPercent(
Number(opt.value)
)}"
>
${opt.label}
</div>
`
)}
</div>
</div>
`
: ""}
${this._values.map(
(value, index) => html`
<button
part="thumb thumb-${index + 1}"
class="thumb"
style="--thumb-position: ${0.01 * this._valueToPercent(
value
)}; z-index: ${index === this._activeThumbIndex ? 12 : 10};"
role="slider"
aria-label=${this._getAccessibleName(
this._inputs[index],
index
)}
aria-valuemin=${this.min}
aria-valuemax=${this.max}
aria-valuenow=${Math.round(value)}
@keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, index)}
></button>
`
)}
</div>
<slot @slotchange=${this._initializeInputs} style="display: none;"></slot>
</fieldset>
`;
}
static styles = css`
:host {
display: flex;
--_thumb-size: var(--thumb-size, 24px);
--_thumb-half-size: calc(var(--_thumb-size) / 2);
--_thumb-bg: var(--thumb-bg, #007bff);
--_track-height: var(--track-height, 6px);
--_thumb-space: calc(var(--_thumb-size + 20px));
padding-block: var(--_thumb-half-size);
flex-direction: column;
gap: var(--_thumb-half-size);
}
.wrapper {
display: contents;
}
.container {
position: relative;
height: var(--_thumb-size);
display: flex;
align-items: center;
}
.track {
position: relative;
width: 100%;
height: var(--_track-height);
background-color: #ccc;
}
.segment {
position: absolute;
height: 100%;
left: calc(100% * var(--segment-position));
width: calc(100% * var(--segment-size));
background-color: #999;
}
.thumb {
position: absolute;
left: calc(100% * var(--thumb-position) - var(--_thumb-half-size));
width: var(--_thumb-size);
height: var(--_thumb-size);
background-color: var(--_thumb-bg);
border-radius: 50%;
cursor: pointer;
touch-action: none;
box-shadow: 0 0 8px 0 var(--_thumb-bg);
transition: transform 0.1s ease-in-out;
border: none;
padding: 0;
}
.thumb::before {
content: "";
position: absolute;
width: var(--_thumb-space);
height: var(--_thumb-space);
background: transparent;
border-radius: 50%;
}
.thumb:hover {
transform: scale(1.1);
}
.thumb:focus-visible {
outline-offset: 4px;
}
.ticks-wrapper {
position: absolute;
top: calc(50% + var(--_track-height) / 2 + 4px);
left: 0;
right: 0;
height: 20px;
pointer-events: none;
}
.tick-marks,
.tick-labels {
position: absolute;
inset: 0;
}
.tick {
position: absolute;
transform: translateX(-50%);
width: 1px;
height: 6px;
left: calc(100% * var(--tick-position));
background: var(--tick-color, currentColor);
}
.tick-label {
position: absolute;
top: 10px; /* 6px tick height + 4px gap */
left: calc(100% * var(--tick-position));
transform: translateX(-50%);
font-size: 0.75rem;
color: var(--tick-label-color, currentColor);
}
`;
}
:root {
--color-bg: oklch(15% 0.01 230);
--color-text: oklch(95% 0.01 230);
--color-text-muted: oklch(70% 0.02 230);
--color-text-description: oklch(85% 0.01 230);
--color-card-bg: oklch(20% 0.02 230);
--color-purple: oklch(70% 0.3 300);
--color-pink: oklch(75% 0.3 340);
--color-teal: oklch(80% 0.25 180);
--color-indigo: oklch(70% 0.28 260);
--color-indigo-strong: oklch(65% 0.28 260);
--color-indigo-hover: oklch(60% 0.28 260);
--color-pink-strong: oklch(70% 0.3 340);
--color-pink-hover: oklch(65% 0.3 340);
}
*, *::before, *::after {
box-sizing: border-box;
}
body {
background-color: var(--color-bg);
color: var(--color-text);
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
margin: 0;
}
.container-wrapper {
padding: 2rem 1rem;
}
.main-container {
margin-left: auto;
margin-right: auto;
}
.header {
text-align: center;
margin-bottom: 3rem;
a {
color: inherit;
text-underline-offset: 3px;
&:where(:hover, :focus) {
color: #ddd;
}
}
}
h1 {
font-size: 3rem;
font-weight: 800;
background-image: linear-gradient(to right, var(--color-purple), var(--color-pink));
background-clip: text;
-webkit-background-clip: text;
color: transparent;
margin: 0;
text-shadow: 0 0 10px oklch(75% 0.3 340 / 0.5);
}
.subtitle {
margin-top: 1rem;
font-size: 1.125rem;
color: #FFF;
}
.subtitle code {
font-size: 1em;
color: var(--color-text);
text-shadow: 0 0 5px currentColor;
}
.sections-container {
display: flex;
flex-direction: column;
gap: 4rem;
}
/* Style the legend which now acts as the section header */
.sections-container legend {
font-size: 1.5rem;
font-weight: 700;
padding: 0; /* Legends have browser-default padding */
Xmargin-bottom: 0.5rem;
}
.description {
color: var(--color-text-description);
Xmargin-top: 0;
Xmargin-bottom: 1.5rem;
}
.color-purple { color: var(--color-purple); }
.color-pink { color: var(--color-pink); }
.color-teal { color: var(--color-teal); }
.color-indigo { color: var(--color-indigo); }
.card {
background-color: var(--color-card-bg);
padding: 2rem;
border-radius: 0.75rem;
border: 1px solid oklch(30% 0.02 230);
box-shadow: 0 10px 15px -3px rgba(0,0,0,0.2), 0 4px 6px -2px rgba(0,0,0,0.1);
}
.button-group {
margin-top: 1.5rem;
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: center;
}
.btn {
color: white;
font-weight: 700;
padding: 0.5rem 1rem;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: background-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
.btn:focus-visible {
outline: 2px solid oklch(85% 0.15 240);
outline-offset: 2px;
}
.btn-indigo {
background-color: var(--color-indigo-strong);
box-shadow: 0 0 10px 0 var(--color-indigo-strong);
}
.btn-indigo:hover {
background-color: var(--color-indigo-hover);
}
.btn-pink {
background-color: var(--color-pink-strong);
box-shadow: 0 0 10px 0 var(--color-pink-strong);
}
.btn-pink:hover {
background-color: var(--color-pink-hover);
}
.api-output {
margin-top: 1rem;
text-align: center;
background-color: var(--color-bg);
padding: 1rem;
border-radius: 0.375rem;
border: 1px solid oklch(30% 0.02 230);
color: var(--color-text-muted);
height: 3rem;
font-family: monospace;
display: flex;
align-items: center;
justify-content: center;
}
/* Styles for Budget Allocator Example */
/*.budget-labels {
position: absolute;
top: 0;
left: 2rem;
right: 2rem;
bottom: 0;
pointer-events: none;
display: flex;
align-items: center;
}
.budget-label {
position: absolute;
transform: translateX(-50%);
color: white;
text-align: center;
font-size: 0.875rem;
text-shadow: 0 1px 3px rgba(0,0,0,0.5);
line-height: 1.2;
}
.budget-label strong {
font-size: 1rem;
font-weight: 700;
}*/
/* Styles for Interactive Price Range Example */
.price-input-container {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 1.5rem;
}
.price-input-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
flex-grow: 1;
}
.price-input-group label {
color: var(--color-text-muted);
font-size: 0.875rem;
font-weight: 600;
}
.price-input-group input[type="number"] {
background-color: var(--color-bg);
border: 1px solid oklch(30% 0.02 230);
border-radius: 0.5rem;
padding: 0.5rem 0.75rem;
color: var(--color-text);
font-size: 1rem;
width: 100%;
}
/* Hide the arrows on number inputs for a cleaner look */
.price-input-group input[type=number]::-webkit-inner-spin-button,
.price-input-group input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.price-input-group input[type=number] {
-moz-appearance: textfield;
}
/* Utility class to hide elements visually but keep them accessible to screen readers */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
{
"files": {
"index.html": {
"position": 0
},
"package.json": {
"position": 1,
"hidden": true
},
"range-group2.ts": {
"position": 2
},
"range-group.ts": {
"position": 3
},
"style.css": {
"position": 4
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment