diff --git a/gallery/src/pages/components/ha-bar-slider.markdown b/gallery/src/pages/components/ha-bar-slider.markdown
new file mode 100644
index 0000000000..aea201fb5c
--- /dev/null
+++ b/gallery/src/pages/components/ha-bar-slider.markdown
@@ -0,0 +1,3 @@
+---
+title: Bar Sliders
+---
diff --git a/gallery/src/pages/components/ha-bar-slider.ts b/gallery/src/pages/components/ha-bar-slider.ts
new file mode 100644
index 0000000000..b35a3462e4
--- /dev/null
+++ b/gallery/src/pages/components/ha-bar-slider.ts
@@ -0,0 +1,169 @@
+import { css, html, LitElement, TemplateResult } from "lit";
+import { customElement, state } from "lit/decorators";
+import { ifDefined } from "lit/directives/if-defined";
+import { repeat } from "lit/directives/repeat";
+import "../../../../src/components/ha-bar-slider";
+import "../../../../src/components/ha-card";
+
+const sliders: {
+ id: string;
+ label: string;
+ mode?: "start" | "end" | "indicator";
+ class?: string;
+}[] = [
+ {
+ id: "slider-start",
+ label: "Slider (start mode)",
+ mode: "start",
+ },
+ {
+ id: "slider-end",
+ label: "Slider (end mode)",
+ mode: "end",
+ },
+ {
+ id: "slider-indicator",
+ label: "Slider (indicator mode)",
+ mode: "indicator",
+ },
+ {
+ id: "slider-start-custom",
+ label: "Slider (start mode) and custom style",
+ mode: "start",
+ class: "custom",
+ },
+ {
+ id: "slider-end-custom",
+ label: "Slider (end mode) and custom style",
+ mode: "end",
+ class: "custom",
+ },
+ {
+ id: "slider-indicator-custom",
+ label: "Slider (indicator mode) and custom style",
+ mode: "indicator",
+ class: "custom",
+ },
+];
+
+@customElement("demo-components-ha-bar-slider")
+export class DemoHaBarSlider extends LitElement {
+ @state() private value = 50;
+
+ @state() private sliderPosition?: number;
+
+ handleValueChanged(e: CustomEvent) {
+ this.value = e.detail.value as number;
+ }
+
+ handleSliderMoved(e: CustomEvent) {
+ this.sliderPosition = e.detail.value as number;
+ }
+
+ protected render(): TemplateResult {
+ return html`
+
+
+
Slider values
+
+
+
+ position |
+ ${this.sliderPosition ?? "-"} |
+
+
+ value |
+ ${this.value ?? "-"} |
+
+
+
+
+
+ ${repeat(sliders, (slider) => {
+ const { id, label, ...config } = slider;
+ return html`
+
+
+
+
Config: ${JSON.stringify(config)}
+
+
+
+
+ `;
+ })}
+
+
+
Vertical
+
+ ${repeat(sliders, (slider) => {
+ const { id, label, ...config } = slider;
+ return html`
+
+
+ `;
+ })}
+
+
+
+ `;
+ }
+
+ static get styles() {
+ return css`
+ ha-card {
+ max-width: 600px;
+ margin: 24px auto;
+ }
+ pre {
+ margin-top: 0;
+ margin-bottom: 8px;
+ }
+ p {
+ margin: 0;
+ }
+ label {
+ font-weight: 600;
+ }
+ .custom {
+ --slider-bar-color: #ffcf4c;
+ --slider-bar-background: #ffcf4c64;
+ --slider-bar-thickness: 100px;
+ --slider-bar-border-radius: 24px;
+ }
+ .vertical-sliders {
+ height: 300px;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ }
+ p.title {
+ margin-bottom: 12px;
+ }
+ .vertical-sliders > *:not(:last-child) {
+ margin-right: 4px;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "demo-components-ha-bar-slider": DemoHaBarSlider;
+ }
+}
diff --git a/package.json b/package.json
index a0e2a6e0d1..645d8fe7bb 100644
--- a/package.json
+++ b/package.json
@@ -110,6 +110,7 @@
"deep-freeze": "^0.0.1",
"fuse.js": "^6.0.0",
"google-timezones-json": "^1.0.2",
+ "hammerjs": "^2.0.8",
"hls.js": "^1.2.3",
"home-assistant-js-websocket": "^8.0.0",
"idb-keyval": "^5.1.3",
@@ -169,6 +170,7 @@
"@types/chromecast-caf-receiver": "5.0.12",
"@types/chromecast-caf-sender": "^1.0.3",
"@types/glob": "^7",
+ "@types/hammerjs": "^2.0.41",
"@types/js-yaml": "^4",
"@types/leaflet": "^1",
"@types/leaflet-draw": "^1",
diff --git a/src/components/ha-bar-slider.ts b/src/components/ha-bar-slider.ts
new file mode 100644
index 0000000000..a0169918ab
--- /dev/null
+++ b/src/components/ha-bar-slider.ts
@@ -0,0 +1,426 @@
+import "hammerjs";
+import {
+ css,
+ CSSResultGroup,
+ html,
+ LitElement,
+ PropertyValues,
+ TemplateResult,
+} from "lit";
+import { customElement, property, query } from "lit/decorators";
+import { classMap } from "lit/directives/class-map";
+import { styleMap } from "lit/directives/style-map";
+import { fireEvent } from "../common/dom/fire_event";
+
+declare global {
+ interface HASSDomEvents {
+ "slider-moved": { value?: number };
+ }
+}
+
+const A11Y_KEY_CODES = new Set([
+ "ArrowRight",
+ "ArrowUp",
+ "ArrowLeft",
+ "ArrowDown",
+ "PageUp",
+ "PageDown",
+ "Home",
+ "End",
+]);
+
+const getPercentageFromEvent = (e: HammerInput, vertical: boolean) => {
+ if (vertical) {
+ const y = e.center.y;
+ const offset = e.target.getBoundingClientRect().top;
+ const total = e.target.clientHeight;
+ return Math.max(Math.min(1, 1 - (y - offset) / total), 0);
+ }
+ const x = e.center.x;
+ const offset = e.target.getBoundingClientRect().left;
+ const total = e.target.clientWidth;
+ return Math.max(Math.min(1, (x - offset) / total), 0);
+};
+
+@customElement("ha-bar-slider")
+export class HaBarSlider extends LitElement {
+ @property({ type: Boolean })
+ public disabled = false;
+
+ @property()
+ public mode?: "start" | "end" | "indicator" = "start";
+
+ @property({ type: Boolean })
+ public vertical = false;
+
+ @property({ type: Number })
+ public value?: number;
+
+ @property({ type: Number })
+ public step = 1;
+
+ @property({ type: Number })
+ public min = 0;
+
+ @property({ type: Number })
+ public max = 100;
+
+ @property()
+ public label?: string;
+
+ private _mc?: HammerManager;
+
+ @property({ type: Boolean, reflect: true })
+ public pressed = false;
+
+ valueToPercentage(value: number) {
+ return (value - this.min) / (this.max - this.min);
+ }
+
+ percentageToValue(value: number) {
+ return (this.max - this.min) * value + this.min;
+ }
+
+ steppedValue(value: number) {
+ return Math.round(value / this.step) * this.step;
+ }
+
+ boundedValue(value: number) {
+ return Math.min(Math.max(value, this.min), this.max);
+ }
+
+ protected firstUpdated(changedProperties: PropertyValues): void {
+ super.firstUpdated(changedProperties);
+ this.setupListeners();
+ this.setAttribute("role", "slider");
+ if (!this.hasAttribute("tabindex")) {
+ this.setAttribute("tabindex", "0");
+ }
+ }
+
+ protected updated(changedProps: PropertyValues) {
+ super.updated(changedProps);
+ if (changedProps.has("value")) {
+ const valuenow = this.steppedValue(this.value ?? 0);
+ this.setAttribute("aria-valuenow", valuenow.toString());
+ }
+ if (changedProps.has("min")) {
+ this.setAttribute("aria-valuemin", this.min.toString());
+ }
+ if (changedProps.has("max")) {
+ this.setAttribute("aria-valuemax", this.max.toString());
+ }
+ if (changedProps.has("vertical")) {
+ const orientation = this.vertical ? "vertical" : "horizontal";
+ this.setAttribute("aria-orientation", orientation);
+ }
+ }
+
+ connectedCallback(): void {
+ super.connectedCallback();
+ this.setupListeners();
+ }
+
+ disconnectedCallback(): void {
+ super.disconnectedCallback();
+ this.destroyListeners();
+ }
+
+ @query("#slider")
+ private slider;
+
+ setupListeners() {
+ if (this.slider && !this._mc) {
+ this._mc = new Hammer.Manager(this.slider, {
+ touchAction: this.vertical ? "pan-x" : "pan-y",
+ });
+ this._mc.add(
+ new Hammer.Pan({
+ threshold: 10,
+ direction: Hammer.DIRECTION_ALL,
+ enable: true,
+ })
+ );
+
+ this._mc.add(new Hammer.Tap({ event: "singletap" }));
+
+ let savedValue;
+ this._mc.on("panstart", () => {
+ if (this.disabled) return;
+ this.pressed = true;
+ savedValue = this.value;
+ });
+ this._mc.on("pancancel", () => {
+ if (this.disabled) return;
+ this.pressed = false;
+ this.value = savedValue;
+ });
+ this._mc.on("panmove", (e) => {
+ if (this.disabled) return;
+ const percentage = getPercentageFromEvent(e, this.vertical);
+ this.value = this.percentageToValue(percentage);
+ const value = this.steppedValue(this.value);
+ fireEvent(this, "slider-moved", { value });
+ });
+ this._mc.on("panend", (e) => {
+ if (this.disabled) return;
+ this.pressed = false;
+ const percentage = getPercentageFromEvent(e, this.vertical);
+ this.value = this.steppedValue(this.percentageToValue(percentage));
+ fireEvent(this, "slider-moved", { value: undefined });
+ fireEvent(this, "value-changed", { value: this.value });
+ });
+
+ this._mc.on("singletap", (e) => {
+ if (this.disabled) return;
+ const percentage = getPercentageFromEvent(e, this.vertical);
+ this.value = this.steppedValue(this.percentageToValue(percentage));
+ fireEvent(this, "value-changed", { value: this.value });
+ });
+
+ this.addEventListener("keydown", this._handleKeyDown);
+ this.addEventListener("keyup", this._handleKeyUp);
+ }
+ }
+
+ destroyListeners() {
+ if (this._mc) {
+ this._mc.destroy();
+ this._mc = undefined;
+ }
+ this.removeEventListener("keydown", this._handleKeyDown);
+ this.removeEventListener("keyup", this._handleKeyDown);
+ }
+
+ private get _tenPercentStep() {
+ return Math.max(this.step, (this.max - this.min) / 10);
+ }
+
+ _handleKeyDown(e: KeyboardEvent) {
+ if (!A11Y_KEY_CODES.has(e.code)) return;
+ e.preventDefault();
+ switch (e.code) {
+ case "ArrowRight":
+ case "ArrowUp":
+ this.value = this.boundedValue((this.value ?? 0) + this.step);
+ break;
+ case "ArrowLeft":
+ case "ArrowDown":
+ this.value = this.boundedValue((this.value ?? 0) - this.step);
+ break;
+ case "PageUp":
+ this.value = this.steppedValue(
+ this.boundedValue((this.value ?? 0) + this._tenPercentStep)
+ );
+ break;
+ case "PageDown":
+ this.value = this.steppedValue(
+ this.boundedValue((this.value ?? 0) - this._tenPercentStep)
+ );
+ break;
+ case "Home":
+ this.value = this.min;
+ break;
+ case "End":
+ this.value = this.max;
+ break;
+ }
+ fireEvent(this, "slider-moved", { value: this.value });
+ }
+
+ _handleKeyUp(e: KeyboardEvent) {
+ if (!A11Y_KEY_CODES.has(e.code)) return;
+ e.preventDefault();
+ fireEvent(this, "value-changed", { value: this.value });
+ }
+
+ protected render(): TemplateResult {
+ return html`
+
+
+ ${this.mode === "indicator"
+ ? html`
+
+ `
+ : html`
+
+ `}
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ :host {
+ display: block;
+ --slider-bar-color: rgb(var(--rgb-primary-color));
+ --slider-bar-background: rgba(var(--rgb-disabled-color), 0.2);
+ --slider-bar-thickness: 40px;
+ --slider-bar-border-radius: 12px;
+ height: var(--slider-bar-thickness);
+ width: 100%;
+ }
+ :host([vertical]) {
+ width: var(--slider-bar-thickness);
+ height: 100%;
+ }
+ .slider {
+ position: relative;
+ height: 100%;
+ width: 100%;
+ border-radius: var(--slider-bar-border-radius);
+ transform: translateZ(0);
+ overflow: hidden;
+ cursor: pointer;
+ }
+ .slider * {
+ pointer-events: none;
+ }
+ .slider .slider-track-background {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: 100%;
+ width: 100%;
+ background: var(--slider-bar-background);
+ }
+ .slider .slider-track-bar {
+ --border-radius: calc(var(--slider-bar-border-radius) / 2);
+ --handle-size: 4px;
+ --handle-margin: calc(var(--slider-bar-thickness) / 8);
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ background-color: var(--slider-bar-color);
+ transition: transform 180ms ease-in-out;
+ }
+ .slider .slider-track-bar::after {
+ display: block;
+ content: "";
+ position: absolute;
+ margin: auto;
+ border-radius: var(--handle-size);
+ background-color: white;
+ }
+ .slider .slider-track-bar {
+ top: 0;
+ left: 0;
+ transform: translate3d(calc((var(--value, 0) - 1) * 100%), 0, 0);
+ border-radius: 0 var(--border-radius) var(--border-radius) 0;
+ }
+ .slider .slider-track-bar:after {
+ top: 0;
+ bottom: 0;
+ right: var(--handle-margin);
+ height: 50%;
+ width: var(--handle-size);
+ }
+ .slider .slider-track-bar.end {
+ right: 0;
+ left: initial;
+ transform: translate3d(calc(var(--value, 0) * 100%), 0, 0);
+ border-radius: var(--border-radius) 0 0 var(--border-radius);
+ }
+ .slider .slider-track-bar.end::after {
+ right: initial;
+ left: var(--handle-margin);
+ }
+
+ .slider .slider-track-bar.vertical {
+ bottom: 0;
+ left: 0;
+ transform: translate3d(0, calc((1 - var(--value, 0)) * 100%), 0);
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
+ }
+ .slider .slider-track-bar.vertical:after {
+ top: var(--handle-margin);
+ right: 0;
+ left: 0;
+ bottom: initial;
+ width: 50%;
+ height: var(--handle-size);
+ }
+ .slider .slider-track-bar.vertical.end {
+ top: 0;
+ bottom: initial;
+ transform: translate3d(0, calc((0 - var(--value, 0)) * 100%), 0);
+ border-radius: 0 0 var(--border-radius) var(--border-radius);
+ }
+ .slider .slider-track-bar.vertical.end::after {
+ top: initial;
+ bottom: var(--handle-margin);
+ }
+
+ .slider .slider-track-indicator:after {
+ display: block;
+ content: "";
+ background-color: rgb(var(--rgb-secondary-text-color));
+ position: absolute;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ right: 0;
+ margin: auto;
+ border-radius: var(--handle-size);
+ }
+
+ .slider .slider-track-indicator {
+ --indicator-size: calc(var(--slider-bar-thickness) / 4);
+ --handle-size: 4px;
+ position: absolute;
+ background-color: white;
+ border-radius: var(--handle-size);
+ transition: left 180ms ease-in-out, bottom 180ms ease-in-out;
+ top: 0;
+ bottom: 0;
+ left: calc(var(--value, 0) * (100% - var(--indicator-size)));
+ width: var(--indicator-size);
+ }
+ .slider .slider-track-indicator:after {
+ height: 50%;
+ width: var(--handle-size);
+ }
+
+ .slider .slider-track-indicator.vertical {
+ top: initial;
+ right: 0;
+ left: 0;
+ bottom: calc(var(--value, 0) * (100% - var(--indicator-size)));
+ height: var(--indicator-size);
+ width: 100%;
+ }
+ .slider .slider-track-indicator.vertical:after {
+ height: var(--handle-size);
+ width: 50%;
+ }
+
+ :host([pressed]) .slider-track-bar,
+ :host([pressed]) .slider-track-indicator {
+ transition: none;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-bar-slider": HaBarSlider;
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index c3d1701367..c3cfcff482 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3816,6 +3816,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/hammerjs@npm:^2.0.41":
+ version: 2.0.41
+ resolution: "@types/hammerjs@npm:2.0.41"
+ checksum: d16fbd688fc9b18cc270abe8dea8d4c50ef7bd8375e593d92c233d299387933a6b003c8db69819344833052458bc5f9ef1b472001277a49f095928d184356006
+ languageName: node
+ linkType: hard
+
"@types/har-format@npm:*":
version: 1.2.4
resolution: "@types/har-format@npm:1.2.4"
@@ -8823,6 +8830,13 @@ fsevents@^1.2.7:
languageName: node
linkType: hard
+"hammerjs@npm:^2.0.8":
+ version: 2.0.8
+ resolution: "hammerjs@npm:2.0.8"
+ checksum: b092da7d1565a165d7edb53ef0ce212837a8b11f897aa3cf81a7818b66686b0ab3f4747fbce8fc8a41d1376594639ce3a054b0fd4889ca8b5b136a29ca500e27
+ languageName: node
+ linkType: hard
+
"handle-thing@npm:^2.0.0":
version: 2.0.0
resolution: "handle-thing@npm:2.0.0"
@@ -9032,6 +9046,7 @@ fsevents@^1.2.7:
"@types/chromecast-caf-receiver": 5.0.12
"@types/chromecast-caf-sender": ^1.0.3
"@types/glob": ^7
+ "@types/hammerjs": ^2.0.41
"@types/js-yaml": ^4
"@types/leaflet": ^1
"@types/leaflet-draw": ^1
@@ -9085,6 +9100,7 @@ fsevents@^1.2.7:
gulp-merge-json: ^1.3.1
gulp-rename: ^2.0.0
gulp-zopfli-green: ^3.0.1
+ hammerjs: ^2.0.8
hls.js: ^1.2.3
home-assistant-js-websocket: ^8.0.0
html-minifier: ^4.0.0