After spending way more time than I am willing to admit trying to get Home Assistant dashboards to be easily customizable and work and look the way I wanted I finally said screw it, and decided to delve into the realm of using the Home Assistant API along with Framer. Which, if you haven’t tried Framer yet… it is the greatest User Interface design solution ever. I plan to do many more additional components and more complex solutions but I thought I would post my first iteration to get others going on the possibilities. No more downloading 3,000 different things and bloating your HA installation, now you can just go as bare bones as you want and keep the front end completely separate.
This one is for white lights with or without temperature control (this can be optionally disabled as well). It works via a cloudflare worker (believe me I didnt know what the hell that was either.
1) Create a free framer account and a free cloudflare account.
2) in your cloudflare account create a worker, edit the code and past this code in. Replace the URLs with your own URL, and replace the token with yours, then press deploy. Proceed to step 3.
export default {
async fetch(request) {
const url = "YOUR_HOME_ASSISTANT_URL/api/states";
const controlUrl = "YOUR_HOME_ASSISTANT_URL/api/services/light/turn_on";
const toggleUrl = "YOUR_HOME_ASSISTANT_URL/api/services/light/toggle";
const token = "YOUR_LONG_LIVED_ACCESS_TOKEN";
const headers = {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
};
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
};
if (request.method === "OPTIONS") {
return new Response(null, {
headers: corsHeaders,
});
}
const { searchParams } = new URL(request.url);
const entityId = searchParams.get("entity_id");
if (request.method === "GET") {
if (searchParams.has("entities")) {
try {
const response = await fetch(url, { headers });
const data = await response.json();
const lightEntities = data
.filter((entity) => entity.entity_id.startsWith("light."))
.map((entity) => entity.entity_id);
return new Response(JSON.stringify({ entities: lightEntities }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
} catch (error) {
return new Response(JSON.stringify({ entities: [] }), {
status: 500,
headers: corsHeaders,
});
}
} else if (entityId) {
try {
const response = await fetch(`${url}/${entityId}`, { headers });
const data = await response.json();
const state = data.state;
const brightness = data.attributes.brightness || 0;
const colorTemp = data.attributes.color_temp || 153;
return new Response(
JSON.stringify({ state, brightness, color_temp: colorTemp }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
return new Response(
JSON.stringify({ state: "Error", brightness: 0, color_temp: 153 }),
{ status: 500, headers: corsHeaders }
);
}
}
} else if (request.method === "POST") {
const body = await request.json();
if (body.action === "toggle") {
try {
const toggleResponse = await fetch(toggleUrl, {
method: "POST",
headers,
body: JSON.stringify({ entity_id: body.entity_id }),
});
return new Response(
JSON.stringify({ status: toggleResponse.ok ? "Success" : "Error" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
return new Response(
JSON.stringify({ status: "Error" }),
{ status: 500, headers: corsHeaders }
);
}
} else if (body.action === "adjust") {
try {
const controlResponse = await fetch(controlUrl, {
method: "POST",
headers,
body: JSON.stringify({
entity_id: body.entity_id,
brightness: body.brightness,
color_temp: body.color_temp,
}),
});
return new Response(
JSON.stringify({ status: controlResponse.ok ? "Success" : "Error" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error) {
return new Response(
JSON.stringify({ status: "Error" }),
{ status: 500, headers: corsHeaders }
);
}
}
}
return new Response("Invalid Request", {
status: 400,
headers: corsHeaders,
});
},
};
3) In framer, create a custom code component and past this code in, replacing the URL of your worker with your worker URL and the light entity you want as your default light:
import { useEffect, useState } from "react"
import { Frame, addPropertyControls, ControlType } from "framer"
import * as Icons from "@phosphor-icons/react"
const workerUrl =
"https://universalframerhomeassistantworker.max-ea0.workers.dev"
export function LightControlComponent({
selectedEntity,
showTemperatureControl,
onIcon,
offIcon,
onColor,
offColor,
brightnessTrackColor,
brightnessThumbColor,
tempTrackGradient,
tempThumbColor,
brightnessFillColor,
}) {
const [lightState, setLightState] = useState("off")
const [brightness, setBrightness] = useState(0)
const [colorTemp, setColorTemp] = useState(153)
// Fetch light state
async function fetchState() {
if (!selectedEntity) return
const response = await fetch(`${workerUrl}?entity_id=${selectedEntity}`)
const data = await response.json()
setLightState(data.state)
setBrightness(data.brightness)
setColorTemp(data.color_temp)
}
// Adjust brightness
async function adjustBrightness(value) {
await fetch(workerUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "adjust",
entity_id: selectedEntity,
brightness: value,
color_temp: colorTemp,
}),
})
}
// Adjust temperature
async function adjustTemperature(value) {
await fetch(workerUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "adjust",
entity_id: selectedEntity,
brightness: brightness,
color_temp: value,
}),
})
}
// Toggle light state
async function toggleLight() {
await fetch(workerUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: "toggle",
entity_id: selectedEntity,
}),
})
fetchState()
}
useEffect(() => {
if (selectedEntity) fetchState()
}, [selectedEntity])
// Dynamic Icon and Colors
const IconComponent = Icons[lightState === "on" ? onIcon : offIcon]
const iconColor = lightState === "on" ? onColor : offColor
// Capitalize first letter of each word in the entity name
const formattedName = selectedEntity
.replace("light.", "")
.replace(/_/g, " ")
.replace(/\b\w/g, (char) => char.toUpperCase())
// Slider styles
const brightnessSliderStyle = {
width: "100%",
minWidth: "180px",
height: "50px",
borderRadius: "16px",
appearance: "none",
outline: "none",
border: "none",
backdropFilter: "blur(150px)",
background: `linear-gradient(to right, ${brightnessFillColor} ${brightness / 2.55}%, ${brightnessTrackColor} ${brightness / 2.55}%)`,
}
const brightnessThumbStyle = `
input[type="range"].brightness::-webkit-slider-thumb {
appearance: none;
width: 20px;
height: 44px;
border-radius: 12px;
background: ${brightnessThumbColor};
cursor: pointer;
}
input[type="range"].brightness::-moz-range-thumb {
width: 44px;
height: 44px;
border-radius: 12px;
background: ${brightnessThumbColor};
cursor: pointer;
}
`
const tempSliderStyle = {
width: "100%",
minWidth: "180px",
height: "50px",
borderRadius: "16px",
appearance: "none",
outline: "none",
borderWidth: "0.5px",
borderStyle: "solid",
borderColor: "#E0E0E0",
background: tempTrackGradient,
}
const tempThumbStyle = `
input[type="range"].temp::-webkit-slider-thumb {
appearance: none;
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
cursor: pointer;
border-width: 0.5px;
border-style: solid;
border-color: #E0E0E0;
}
input[type="range"].temp::-moz-range-thumb {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px);
cursor: pointer;
border-width: 0.5px;
border-style: solid;
border-color: #E0E0E0;
}
`
return (
<Frame
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "flex-start",
padding: "16px",
borderRadius: "16px",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
backgroundColor: "rgba(255, 255, 255, 0.7)",
backdropFilter: "blur(20px)",
fontFamily:
"SFNSDisplay-Semibold, SFProDisplay-Semibold, SFUIDisplay-Semibold, SFUIDisplay-Semibold, SF Pro Display, sans-serif",
color: "#333",
border: "1px solid #E0E0E0",
width: "100%",
height: "100%",
}}
>
<style>{brightnessThumbStyle + tempThumbStyle}</style>
<div
style={{
fontSize: "18px",
fontWeight: "bold",
marginBottom: "12px",
textAlign: "center",
}}
>
{formattedName}
</div>
<div
style={{
cursor: "pointer",
marginBottom: "12px",
}}
onClick={toggleLight}
>
<IconComponent size={48} weight="fill" color={iconColor} />
</div>
<input
className="brightness"
type="range"
min="0"
max="255"
value={brightness}
onChange={(e) => {
const value = parseInt(e.target.value)
setBrightness(value)
}}
onMouseUp={() => adjustBrightness(brightness)}
onTouchEnd={() => adjustBrightness(brightness)}
style={brightnessSliderStyle}
/>
{showTemperatureControl && (
<input
className="temp"
type="range"
min="153"
max="500"
value={colorTemp}
onChange={(e) => {
const value = parseInt(e.target.value)
setColorTemp(value)
}}
onMouseUp={() => adjustTemperature(colorTemp)}
onTouchEnd={() => adjustTemperature(colorTemp)}
style={tempSliderStyle}
/>
)}
</Frame>
)
}
// Property Controls
addPropertyControls(LightControlComponent, {
selectedEntity: {
type: ControlType.String,
title: "Light Entity",
defaultValue: "light.YOUR_DEFAULT_LIGHT_ENTITY",
},
showTemperatureControl: {
type: ControlType.Boolean,
title: "Show Temp Control",
defaultValue: true,
},
onIcon: {
type: ControlType.Enum,
title: "On Icon",
options: Object.keys(Icons),
defaultValue: "Lightbulb",
},
offIcon: {
type: ControlType.Enum,
title: "Off Icon",
options: Object.keys(Icons),
defaultValue: "Lightbulb",
},
onColor: {
type: ControlType.Color,
title: "On Icon Color",
defaultValue: "#FFD700",
},
offColor: {
type: ControlType.Color,
title: "Off Icon Color",
defaultValue: "#A9A9A9",
},
brightnessTrackColor: {
type: ControlType.Color,
title: "Brightness Track Color",
defaultValue: "rgba(0,0,0, 0.4)",
},
brightnessThumbColor: {
type: ControlType.Color,
title: "Brightness Thumb Color",
defaultValue: "rgba(0,0,0,0)",
},
brightnessFillColor: {
type: ControlType.Color,
title: "Brightness Fill Color",
defaultValue: "rgba(255,255,255,0.4)",
},
tempTrackGradient: {
type: ControlType.String,
title: "Temp Track Gradient",
defaultValue: "linear-gradient(to right, #FFFDE7, #FFD700)",
},
tempThumbColor: {
type: ControlType.Color,
title: "Temp Thumb Color",
defaultValue: "#FFFFFF",
},
})