diff --git a/src/common/color/compute-color.ts b/src/common/color/compute-color.ts
new file mode 100644
index 0000000000..0a97b7aad4
--- /dev/null
+++ b/src/common/color/compute-color.ts
@@ -0,0 +1,44 @@
+import { hex2rgb } from "./convert-color";
+
+export const THEME_COLORS = new Set(["primary", "accent", "disabled"]);
+
+export const COLORS = new Map([
+ ["red", "#f44336"],
+ ["pink", "#e91e63"],
+ ["purple", "#9b27b0"],
+ ["deep-purple", "#683ab7"],
+ ["indigo", "#3f51b5"],
+ ["blue", "#2194f3"],
+ ["light-blue", "#2196f3"],
+ ["cyan", "#03a8f4"],
+ ["teal", "#009688"],
+ ["green", "#4caf50"],
+ ["light-green", "#8bc34a"],
+ ["lime", "#ccdc39"],
+ ["yellow", "#ffeb3b"],
+ ["amber", "#ffc107"],
+ ["orange", "#ff9800"],
+ ["deep-orange", "#ff5722"],
+ ["brown", "#795548"],
+ ["grey", "#9e9e9e"],
+ ["blue-grey", "#607d8b"],
+ ["black", "#000000"],
+ ["white", "ffffff"],
+]);
+
+export function computeRgbColor(color: string): string {
+ if (THEME_COLORS.has(color)) {
+ return `var(--rgb-${color}-color)`;
+ }
+ if (COLORS.has(color)) {
+ return hex2rgb(COLORS.get(color)!).join(", ");
+ }
+ if (color.startsWith("#")) {
+ try {
+ return hex2rgb(color).join(", ");
+ } catch (err) {
+ return "";
+ }
+ }
+ return color;
+}
diff --git a/src/components/tile/ha-tile-icon.ts b/src/components/tile/ha-tile-icon.ts
new file mode 100644
index 0000000000..e730035e93
--- /dev/null
+++ b/src/components/tile/ha-tile-icon.ts
@@ -0,0 +1,54 @@
+import { CSSResultGroup, html, css, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators";
+import "../ha-icon";
+import "../ha-svg-icon";
+
+@customElement("ha-tile-icon")
+export class HaTileIcon extends LitElement {
+ @property() public iconPath?: string;
+
+ @property() public icon?: string;
+
+ protected render(): TemplateResult {
+ return html`
+
+ ${this.icon
+ ? html``
+ : html``}
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ :host {
+ --icon-color: rgb(var(--color));
+ --shape-color: rgba(var(--color), 0.2);
+ --mdc-icon-size: 24px;
+ }
+ .shape {
+ position: relative;
+ width: 40px;
+ height: 40px;
+ border-radius: 20px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background-color: var(--shape-color);
+ transition: background-color 180ms ease-in-out, color 180ms ease-in-out;
+ }
+ .shape ha-icon,
+ .shape ha-svg-icon {
+ display: flex;
+ color: var(--icon-color);
+ transition: color 180ms ease-in-out;
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-tile-icon": HaTileIcon;
+ }
+}
diff --git a/src/components/tile/ha-tile-info.ts b/src/components/tile/ha-tile-info.ts
new file mode 100644
index 0000000000..f211b5490d
--- /dev/null
+++ b/src/components/tile/ha-tile-info.ts
@@ -0,0 +1,59 @@
+import { CSSResultGroup, html, css, LitElement, TemplateResult } from "lit";
+import { customElement, property } from "lit/decorators";
+import "../ha-icon";
+import "../ha-svg-icon";
+
+@customElement("ha-tile-info")
+export class HaTileInfo extends LitElement {
+ @property() public primary?: string;
+
+ @property() public secondary?: string;
+
+ protected render(): TemplateResult {
+ return html`
+
+ ${this.primary}
+ ${this.secondary
+ ? html`${this.secondary}`
+ : null}
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ .info {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ }
+ span {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ width: 100%;
+ }
+ .primary {
+ font-weight: 500;
+ font-size: 14px;
+ line-height: 20px;
+ letter-spacing: 0.1px;
+ color: var(--primary-text-color);
+ }
+ .secondary {
+ font-weight: 400;
+ font-size: 12px;
+ line-height: 16px;
+ letter-spacing: 0.4px;
+ color: var(--secondary-text-color);
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-tile-info": HaTileInfo;
+ }
+}
diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts
new file mode 100644
index 0000000000..e9893555b7
--- /dev/null
+++ b/src/panels/lovelace/cards/hui-tile-card.ts
@@ -0,0 +1,210 @@
+import { mdiHelp } from "@mdi/js";
+import { css, CSSResultGroup, html, LitElement } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { styleMap } from "lit/directives/style-map";
+import { computeRgbColor } from "../../../common/color/compute-color";
+import { DOMAINS_TOGGLE, STATES_OFF } from "../../../common/const";
+import { computeDomain } from "../../../common/entity/compute_domain";
+import { computeStateDisplay } from "../../../common/entity/compute_state_display";
+import { stateIconPath } from "../../../common/entity/state_icon_path";
+import "../../../components/ha-card";
+import "../../../components/tile/ha-tile-icon";
+import "../../../components/tile/ha-tile-info";
+import { ActionHandlerEvent } from "../../../data/lovelace";
+import { HomeAssistant } from "../../../types";
+import { actionHandler } from "../common/directives/action-handler-directive";
+import { findEntities } from "../common/find-entities";
+import { handleAction } from "../common/handle-action";
+import { LovelaceCard, LovelaceCardEditor } from "../types";
+import { ThermostatCardConfig, TileCardConfig } from "./types";
+
+@customElement("hui-tile-card")
+export class HuiTileCard extends LitElement implements LovelaceCard {
+ public static async getConfigElement(): Promise {
+ await import("../editor/config-elements/hui-tile-card-editor");
+ return document.createElement("hui-tile-card-editor");
+ }
+
+ public static getStubConfig(
+ hass: HomeAssistant,
+ entities: string[],
+ entitiesFallback: string[]
+ ): TileCardConfig {
+ const includeDomains = ["sensor", "light", "switch"];
+ const maxEntities = 1;
+ const foundEntities = findEntities(
+ hass,
+ maxEntities,
+ entities,
+ entitiesFallback,
+ includeDomains
+ );
+
+ return {
+ type: "tile",
+ entity: foundEntities[0] || "",
+ };
+ }
+
+ @property({ attribute: false }) public hass?: HomeAssistant;
+
+ @state() private _config?: TileCardConfig;
+
+ public setConfig(config: ThermostatCardConfig): void {
+ if (!config.entity) {
+ throw new Error("Specify an entity");
+ }
+
+ const supportToggle =
+ config.entity && DOMAINS_TOGGLE.has(computeDomain(config.entity));
+
+ this._config = {
+ tap_action: {
+ action: "more-info",
+ },
+ icon_tap_action: {
+ action: supportToggle ? "toggle" : "more-info",
+ },
+ ...config,
+ };
+ }
+
+ public getCardSize(): number {
+ return 1;
+ }
+
+ private _handleAction(ev: ActionHandlerEvent) {
+ handleAction(this, this.hass!, this._config!, ev.detail.action!);
+ }
+
+ private _handleIconAction() {
+ const config = {
+ entity: this._config!.entity,
+ tap_action: this._config!.icon_tap_action,
+ };
+ handleAction(this, this.hass!, config, "tap");
+ }
+
+ render() {
+ if (!this._config || !this.hass) {
+ return html``;
+ }
+ const entityId = this._config.entity;
+ const entity = entityId ? this.hass.states[entityId] : undefined;
+
+ if (!entity) {
+ return html`
+
+
+
+
+
+
+ `;
+ }
+
+ const icon = this._config.icon || entity.attributes.icon;
+ const iconPath = stateIconPath(entity);
+
+ const name = this._config.name || entity.attributes.friendly_name;
+ const stateDisplay = computeStateDisplay(
+ this.hass.localize,
+ entity,
+ this.hass.locale
+ );
+
+ const iconStyle = {};
+ if (this._config.color && !STATES_OFF.includes(entity.state)) {
+ iconStyle["--main-color"] = computeRgbColor(this._config.color);
+ }
+
+ return html`
+
+
+
+
+
+
+ `;
+ }
+
+ static get styles(): CSSResultGroup {
+ return css`
+ :host {
+ --main-color: var(--rgb-disabled-color);
+ --tap-padding: 6px;
+ }
+ ha-card {
+ height: 100%;
+ }
+ ha-card.disabled {
+ background: rgba(var(--rgb-disabled-color), 0.1);
+ }
+ .tile {
+ padding: calc(12px - var(--tap-padding));
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ }
+ ha-tile-icon {
+ padding: var(--tap-padding);
+ flex: none;
+ margin-right: calc(12px - 2 * var(--tap-padding));
+ margin-inline-end: calc(12px - 2 * var(--tap-padding));
+ margin-inline-start: initial;
+ direction: var(--direction);
+ --color: var(--main-color);
+ transition: transform 180ms ease-in-out;
+ }
+ [role="button"] {
+ cursor: pointer;
+ }
+ ha-tile-icon[role="button"]:focus {
+ outline: none;
+ }
+ ha-tile-icon[role="button"]:focus-visible {
+ transform: scale(1.2);
+ }
+ ha-tile-icon[role="button"]:active {
+ transform: scale(1.2);
+ }
+ ha-tile-info {
+ padding: var(--tap-padding);
+ flex: 1;
+ min-width: 0;
+ min-height: 40px;
+ border-radius: calc(var(--ha-card-border-radius, 12px) - 2px);
+ transition: background-color 180ms ease-in-out;
+ }
+ ha-tile-info:focus {
+ outline: none;
+ }
+ ha-tile-info:focus-visible {
+ background-color: rgba(var(--main-color), 0.1);
+ }
+ `;
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "hui-tile-card": HuiTileCard;
+ }
+}
diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts
index 7113ad1d13..ee3a6d68a5 100644
--- a/src/panels/lovelace/cards/types.ts
+++ b/src/panels/lovelace/cards/types.ts
@@ -472,3 +472,12 @@ export interface EnergyFlowCardConfig extends LovelaceCardConfig {
hold_action?: ActionConfig;
double_tap_action?: ActionConfig;
}
+
+export interface TileCardConfig extends LovelaceCardConfig {
+ entity: string;
+ name?: string;
+ icon?: string;
+ color?: string;
+ tap_action?: ActionConfig;
+ icon_tap_action?: ActionConfig;
+}
diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts
index 03f22a9bdd..aaa8de1829 100644
--- a/src/panels/lovelace/create-element/create-card-element.ts
+++ b/src/panels/lovelace/create-element/create-card-element.ts
@@ -10,6 +10,7 @@ import "../cards/hui-light-card";
import "../cards/hui-sensor-card";
import "../cards/hui-thermostat-card";
import "../cards/hui-weather-forecast-card";
+import "../cards/hui-tile-card";
import {
createLovelaceElement,
getLovelaceElementClass,
@@ -27,6 +28,7 @@ const ALWAYS_LOADED_TYPES = new Set([
"sensor",
"thermostat",
"weather-forecast",
+ "tile",
]);
const LAZY_LOAD_TYPES = {
diff --git a/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts
new file mode 100644
index 0000000000..448074f743
--- /dev/null
+++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-editor.ts
@@ -0,0 +1,153 @@
+import { HassEntity } from "home-assistant-js-websocket";
+import { html, LitElement, TemplateResult } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import memoizeOne from "memoize-one";
+import { assert, assign, object, optional, string } from "superstruct";
+import { COLORS, THEME_COLORS } from "../../../../common/color/compute-color";
+import { fireEvent } from "../../../../common/dom/fire_event";
+import { computeDomain } from "../../../../common/entity/compute_domain";
+import { domainIcon } from "../../../../common/entity/domain_icon";
+import { capitalizeFirstLetter } from "../../../../common/string/capitalize-first-letter";
+import "../../../../components/ha-form/ha-form";
+import type { SchemaUnion } from "../../../../components/ha-form/types";
+import type { HomeAssistant } from "../../../../types";
+import type { TileCardConfig } from "../../cards/types";
+import type { LovelaceCardEditor } from "../../types";
+import { actionConfigStruct } from "../structs/action-struct";
+import { baseLovelaceCardConfig } from "../structs/base-card-struct";
+
+const cardConfigStruct = assign(
+ baseLovelaceCardConfig,
+ object({
+ entity: optional(string()),
+ name: optional(string()),
+ icon: optional(string()),
+ color: optional(string()),
+ tap_action: optional(actionConfigStruct),
+ icon_tap_action: optional(actionConfigStruct),
+ })
+);
+
+@customElement("hui-tile-card-editor")
+export class HuiTileCardEditor
+ extends LitElement
+ implements LovelaceCardEditor
+{
+ @property({ attribute: false }) public hass?: HomeAssistant;
+
+ @state() private _config?: TileCardConfig;
+
+ public setConfig(config: TileCardConfig): void {
+ assert(config, cardConfigStruct);
+ this._config = config;
+ }
+
+ private _schema = memoizeOne(
+ (entity: string, icon?: string, entityState?: HassEntity) =>
+ [
+ { name: "entity", selector: { entity: {} } },
+ { name: "name", selector: { text: {} } },
+ {
+ name: "icon",
+ selector: {
+ icon: {
+ placeholder: icon || entityState?.attributes.icon,
+ fallbackPath:
+ !icon && !entityState?.attributes.icon && entityState
+ ? domainIcon(computeDomain(entity), entityState)
+ : undefined,
+ },
+ },
+ },
+ {
+ name: "color",
+ selector: {
+ select: {
+ options: [
+ {
+ label: "Default",
+ value: "default",
+ },
+ ...[
+ ...Array.from(THEME_COLORS),
+ ...Array.from(COLORS.keys()),
+ ].map((color) => ({
+ label: capitalizeFirstLetter(color),
+ value: color,
+ })),
+ ],
+ },
+ },
+ },
+ {
+ name: "tap_action",
+ selector: {
+ "ui-action": {},
+ },
+ },
+ {
+ name: "icon_tap_action",
+ selector: {
+ "ui-action": {},
+ },
+ },
+ ] as const
+ );
+
+ protected render(): TemplateResult {
+ if (!this.hass || !this._config) {
+ return html``;
+ }
+
+ const entity = this.hass.states[this._config.entity ?? ""];
+
+ const schema = this._schema(this._config.entity, this._config.icon, entity);
+
+ const data = {
+ color: "default",
+ ...this._config,
+ };
+
+ return html`
+
+ `;
+ }
+
+ private _valueChanged(ev: CustomEvent): void {
+ const config = {
+ ...ev.detail.value,
+ };
+ if (ev.detail.value.color === "default") {
+ config.color = undefined;
+ }
+ fireEvent(this, "config-changed", { config });
+ }
+
+ private _computeLabelCallback = (
+ schema: SchemaUnion>
+ ) => {
+ switch (schema.name) {
+ case "color":
+ case "icon_tap_action":
+ return this.hass!.localize(
+ `ui.panel.lovelace.editor.card.tile.${schema.name}`
+ );
+ default:
+ return this.hass!.localize(
+ `ui.panel.lovelace.editor.card.generic.${schema.name}`
+ );
+ }
+ };
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "hui-tile-card-editor": HuiTileCardEditor;
+ }
+}
diff --git a/src/panels/lovelace/editor/lovelace-cards.ts b/src/panels/lovelace/editor/lovelace-cards.ts
index df23758f13..3f3addb715 100644
--- a/src/panels/lovelace/editor/lovelace-cards.ts
+++ b/src/panels/lovelace/editor/lovelace-cards.ts
@@ -93,6 +93,10 @@ export const coreCards: Card[] = [
type: "area",
showElement: true,
},
+ {
+ type: "tile",
+ showElement: true,
+ },
{
type: "conditional",
},
diff --git a/src/resources/ha-style.ts b/src/resources/ha-style.ts
index dffa7a2663..5a903b26bd 100644
--- a/src/resources/ha-style.ts
+++ b/src/resources/ha-style.ts
@@ -111,6 +111,7 @@ documentContainer.innerHTML = `
--rgb-secondary-text-color: 114, 114, 114;
--rgb-text-primary-color: 255, 255, 255;
--rgb-card-background-color: 255, 255, 255;
+ --rgb-disabled-color: 189, 189, 189;
/* input components */
--input-idle-line-color: rgba(0, 0, 0, 0.42);
diff --git a/src/resources/styles.ts b/src/resources/styles.ts
index 96af5c42dc..a943e154b2 100644
--- a/src/resources/styles.ts
+++ b/src/resources/styles.ts
@@ -48,6 +48,7 @@ export const darkStyles = {
"energy-grid-return-color": "#a280db",
"map-filter":
"invert(.9) hue-rotate(170deg) brightness(1.5) contrast(1.2) saturate(.3)",
+ "rgb-disabled-color": "111, 111, 111",
};
export const derivedStyles = {
diff --git a/src/translations/en.json b/src/translations/en.json
index 38f543adc8..4803743a96 100755
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -243,6 +243,9 @@
"finish": "finish"
}
},
+ "tile": {
+ "not_found": "Entity not found"
+ },
"vacuum": {
"actions": {
"resume_cleaning": "Resume cleaning",
@@ -4120,6 +4123,12 @@
"name": "Thermostat",
"description": "The Thermostat card gives control of your climate entity. Allowing you to change the temperature and mode of the entity."
},
+ "tile": {
+ "name": "Tile",
+ "description": "The tile card gives you a quick overview of your entity. The card allow you to toggle the entity, show the more info dialog or custom actions.",
+ "color": "Color",
+ "icon_tap_action": "Icon tap action"
+ },
"vertical-stack": {
"name": "Vertical Stack",
"description": "The Vertical Stack card allows you to group multiple cards so they always sit in the same column."