How to Add a Sidebar Panel to a Home Assistant Integration
This guide explains how to add a custom sidebar panel to a Home Assistant integration. The official HA documentation is incomplete, so this provides a complete, working example.
Overview
A sidebar panel requires:
- Frontend file - A JavaScript web component (custom element)
- Panel registration - Code in
__init__.pyto register the panel - WebSocket API - Backend communication for data operations
- Manifest dependencies -
frontendandpanel_custom
File Structure
custom_components/
└── your_integration/
├── __init__.py # Panel registration
├── manifest.json # Add dependencies
├── websocket.py # WebSocket API endpoints
└── frontend/
└── your-panel.js # Panel web component
Step 1: Update manifest.json
Add frontend and panel_custom to dependencies:
{
"domain": "your_integration",
"name": "Your Integration",
"dependencies": ["http", "frontend", "panel_custom"],
...
}
Step 2: Create the Panel Web Component
Create frontend/your-panel.js:
/**
* Custom Panel for Home Assistant
*/
class YourPanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._hass = null;
}
// Home Assistant will set this property with the hass object
set hass(hass) {
this._hass = hass;
this._render();
}
// Home Assistant will set this property with panel config
set panel(panel) {
this._config = panel.config;
}
connectedCallback() {
this._render();
}
_render() {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
height: 100%;
background: var(--primary-background-color, #1c1c1c);
color: var(--primary-text-color, #fff);
font-family: var(--paper-font-body1_-_font-family, 'Roboto', sans-serif);
}
.container {
padding: 16px;
}
.header {
background: var(--primary-color, #03a9f4);
padding: 16px 24px;
margin: -16px -16px 16px -16px;
}
.header h1 {
margin: 0;
font-size: 20px;
font-weight: 500;
color: white;
}
.card {
background: var(--card-background-color, #1e1e1e);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.btn {
padding: 8px 16px;
border-radius: 4px;
border: none;
cursor: pointer;
background: var(--primary-color, #03a9f4);
color: white;
}
</style>
<div class="container">
<div class="header">
<h1>Your Panel</h1>
</div>
<div class="card">
<h3>Welcome to your custom panel!</h3>
<p>Hass object available: ${this._hass ? 'Yes' : 'No'}</p>
<button class="btn" id="test-btn">Test Button</button>
</div>
</div>
`;
this._attachEventListeners();
}
_attachEventListeners() {
const testBtn = this.shadowRoot.querySelector("#test-btn");
if (testBtn) {
testBtn.addEventListener("click", () => {
alert("Button clicked!");
});
}
}
}
// IMPORTANT: Register the custom element
customElements.define("your-panel", YourPanel);
Key Points:
- Use Shadow DOM -
this.attachShadow({ mode: "open" })for style isolation - Set
hassproperty - Home Assistant automatically sets this with the hass object - Set
panelproperty - Contains panel configuration - Use CSS variables - Use HA’s CSS variables for theming:
--primary-background-color--primary-text-color--primary-color--card-background-color--secondary-text-color--divider-color
- Register custom element - Must call
customElements.define()
Step 3: Register the Panel in init.py
"""The Your Integration integration."""
from __future__ import annotations
import os
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.components import frontend, panel_custom
from homeassistant.components.http import StaticPathConfig
from .const import DOMAIN
# Panel configuration
PANEL_ICON = "mdi:your-icon"
PANEL_TITLE = "Your Panel"
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up from a config entry."""
# ... your existing setup code ...
# Register sidebar panel (only once)
await async_register_panel(hass)
return True
async def async_register_panel(hass: HomeAssistant) -> None:
"""Register the sidebar panel."""
# Check if panel is already registered (avoid duplicates)
if DOMAIN in hass.data.get("frontend_panels", {}):
return
# Get the path to our panel JS file
panel_path = os.path.join(os.path.dirname(__file__), "frontend")
panel_url = f"/{DOMAIN}_panel"
# Register static path for the panel files
await hass.http.async_register_static_paths([
StaticPathConfig(panel_url, panel_path, cache_headers=False)
])
# Register the custom panel
await panel_custom.async_register_panel(
hass,
webcomponent_name="your-panel", # Must match customElements.define() name
frontend_url_path=DOMAIN, # URL path: /your_integration
sidebar_title=PANEL_TITLE, # Sidebar display name
sidebar_icon=PANEL_ICON, # MDI icon
module_url=f"{panel_url}/your-panel.js", # Path to JS file
embed_iframe=False, # False for custom elements
require_admin=False, # True to require admin access
)
Key Parameters:
| Parameter | Description |
|---|---|
webcomponent_name |
Must exactly match the name in customElements.define() |
frontend_url_path |
URL path after / (e.g., “textnow” = /textnow) |
sidebar_title |
Display name in sidebar |
sidebar_icon |
MDI icon (e.g., “mdi:message-text”) |
module_url |
Full path to the JS file |
embed_iframe |
False for custom elements, True for iframes |
require_admin |
True to restrict to admin users |
Step 4: WebSocket API for Data Operations
Create websocket.py:
"""WebSocket API for your panel."""
from __future__ import annotations
from typing import Any
import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.core import HomeAssistant, callback
from .const import DOMAIN
@callback
def async_setup(hass: HomeAssistant) -> None:
"""Set up WebSocket API."""
websocket_api.async_register_command(hass, websocket_get_data)
websocket_api.async_register_command(hass, websocket_do_action)
@websocket_api.websocket_command(
{
"type": "your_integration/get_data",
}
)
@websocket_api.async_response
async def websocket_get_data(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any]
) -> None:
"""Get data for the panel."""
# Your logic here
result = {"items": ["item1", "item2"]}
connection.send_result(msg["id"], result)
@websocket_api.websocket_command(
{
"type": "your_integration/do_action",
vol.Required("action"): str,
vol.Optional("data"): dict,
}
)
@websocket_api.async_response
async def websocket_do_action(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any]
) -> None:
"""Perform an action."""
action = msg["action"]
data = msg.get("data", {})
try:
# Your logic here
connection.send_result(msg["id"], {"success": True})
except Exception as e:
connection.send_error(msg["id"], "action_failed", str(e))
Calling WebSocket from Panel:
// In your panel JS
async _loadData() {
try {
const result = await this._hass.callWS({
type: "your_integration/get_data",
});
console.log("Got data:", result);
} catch (e) {
console.error("Failed to load data:", e);
}
}
async _doAction(action, data) {
try {
const result = await this._hass.callWS({
type: "your_integration/do_action",
action: action,
data: data,
});
return result.success;
} catch (e) {
console.error("Action failed:", e);
return false;
}
}
Step 5: Initialize WebSocket in init.py
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up from a config entry."""
# ... your existing setup code ...
# Register WebSocket API
from .websocket import async_setup as async_setup_websocket
async_setup_websocket(hass)
# Register sidebar panel
await async_register_panel(hass)
return True
Common Issues & Solutions
Panel Not Showing in Sidebar
- Check manifest.json - Must have
"dependencies": ["http", "frontend", "panel_custom"] - Check custom element name -
webcomponent_namemust matchcustomElements.define() - Restart Home Assistant - Required after changes to
__init__.py - Check browser console - Look for JavaScript errors
Panel Shows But Is Blank
- Check JS file path - Verify
module_urlpath is correct - Check for JS errors - Open browser developer tools (F12)
- Verify custom element - Ensure
customElements.define()is called
WebSocket Calls Failing
- Check command type - Must be
"domain/command_name"format - Verify registration - Ensure
websocket_api.async_register_command()is called - Check parameters - Verify required parameters are passed
Styles Not Applying
- Use Shadow DOM - Styles must be inside the shadow root
- Use CSS variables - Use HA’s CSS variables for theming
Advanced: Multiple Tabs
class YourPanel extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
this._activeTab = "tab1";
}
_render() {
this.shadowRoot.innerHTML = `
<style>
.tabs {
display: flex;
border-bottom: 1px solid var(--divider-color);
}
.tab {
padding: 12px 16px;
cursor: pointer;
border: none;
background: transparent;
color: var(--secondary-text-color);
border-bottom: 2px solid transparent;
}
.tab.active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
</style>
<div class="tabs">
<button class="tab ${this._activeTab === 'tab1' ? 'active' : ''}" data-tab="tab1">Tab 1</button>
<button class="tab ${this._activeTab === 'tab2' ? 'active' : ''}" data-tab="tab2">Tab 2</button>
</div>
<div class="content">
${this._activeTab === 'tab1' ? this._renderTab1() : this._renderTab2()}
</div>
`;
// Attach tab click listeners
this.shadowRoot.querySelectorAll(".tab").forEach(tab => {
tab.addEventListener("click", (e) => {
this._activeTab = e.target.dataset.tab;
this._render();
});
});
}
_renderTab1() {
return `<div class="card">Tab 1 Content</div>`;
}
_renderTab2() {
return `<div class="card">Tab 2 Content</div>`;
}
}
Add a Hamburger Menu to Your Panel (Mobile)
On narrow viewports, users need a way to open Home Assistant’s main sidebar (navigation, settings, etc.) from inside your panel. You can add a hamburger button that does exactly that by firing HA’s built-in sidebar toggle event.
1. Markup: Menu button in the header
In your panel’s header/toolbar, add a button that will only be used on narrow viewports:
<button class="menu-btn" id="menu-btn" title="Menu">
<svg viewBox="0 0 24 24"><path d="M3,6H21V8H3V6M3,11H21V13H3V11M3,16H21V18H3V16Z"/></svg>
</button>
Place it wherever your header lives (e.g. next to the panel title). The important parts are the id="menu-btn" (for the click handler) and the class="menu-btn" (for the responsive visibility).
2. CSS: Show the button only on small screens
In your panel’s shared or panel-specific styles:
.menu-btn {
display: none;
width: 40px;
height: 40px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--primary-text-color);
cursor: pointer;
align-items: center;
justify-content: center;
margin-right: 8px;
flex-shrink: 0;
}
.menu-btn svg {
width: 24px;
height: 24px;
fill: currentColor;
}
.menu-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
@media (max-width: 870px) {
.menu-btn {
display: flex;
}
}
So: hidden by default, visible only at 870px and below. Adjust the breakpoint if you want (e.g. 768px). The rest keeps the button consistent with HA’s theme.
3. Behavior: Toggle HA’s sidebar via event
Home Assistant’s frontend listens for a single event to open/close the main sidebar. Your panel only has to fire that event; it does not implement the sidebar itself.
Attach the listener (call this after you render the panel, e.g. from _attachEventListeners() or similar):
_attachMenuButton() {
const menuBtn = this.shadowRoot.querySelector('#menu-btn');
if (menuBtn) {
menuBtn.addEventListener('click', () => {
this._toggleSidebar();
});
}
}
_toggleSidebar() {
const event = new Event('hass-toggle-menu', { bubbles: true, composed: true });
this.dispatchEvent(event);
}
bubbles: trueandcomposed: truelet the event cross shadow DOM so the HA app shell can receive it.- HA then toggles its main navigation drawer; no extra code in your panel is needed.
Where to call it: In the same place you attach other button listeners after _render() (e.g. _attachMenuButton(); inside your existing _attachEventListeners() or equivalent).
Summary
| Piece | Purpose |
|---|---|
#menu-btn button + SVG |
Visible “hamburger” control in the panel header. |
.menu-btn CSS + @media (max-width: 870px) |
Show the button only on small screens. |
hass-toggle-menu event |
Tells Home Assistant to open/close its sidebar; your panel does not render the sidebar. |
Complete Working Example
See the TextNow integration for a complete working example:
custom_components/textnow/__init__.py- Panel registrationcustom_components/textnow/frontend/textnow-panel.js- Panel componentcustom_components/textnow/websocket.py- WebSocket APIcustom_components/textnow/manifest.json- Dependencies
Useful Resources
This guide was created because the official Home Assistant documentation is incomplete and scattered across multiple pages. This consolidates everything needed to create a working sidebar panel.