How to Add a Sidebar Panel to a Home Assistant Integration

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:

  1. Frontend file - A JavaScript web component (custom element)
  2. Panel registration - Code in __init__.py to register the panel
  3. WebSocket API - Backend communication for data operations
  4. Manifest dependencies - frontend and panel_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:

  1. Use Shadow DOM - this.attachShadow({ mode: "open" }) for style isolation
  2. Set hass property - Home Assistant automatically sets this with the hass object
  3. Set panel property - Contains panel configuration
  4. 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
  5. 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

  1. Check manifest.json - Must have "dependencies": ["http", "frontend", "panel_custom"]
  2. Check custom element name - webcomponent_name must match customElements.define()
  3. Restart Home Assistant - Required after changes to __init__.py
  4. Check browser console - Look for JavaScript errors

Panel Shows But Is Blank

  1. Check JS file path - Verify module_url path is correct
  2. Check for JS errors - Open browser developer tools (F12)
  3. Verify custom element - Ensure customElements.define() is called

WebSocket Calls Failing

  1. Check command type - Must be "domain/command_name" format
  2. Verify registration - Ensure websocket_api.async_register_command() is called
  3. Check parameters - Verify required parameters are passed

Styles Not Applying

  1. Use Shadow DOM - Styles must be inside the shadow root
  2. 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: true and composed: true let 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 registration
  • custom_components/textnow/frontend/textnow-panel.js - Panel component
  • custom_components/textnow/websocket.py - WebSocket API
  • custom_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.

1 Like

Thank you for putting together this awesome guide! It’s clear, complete and to the point. Super usefull stuff :+1:

I wish I had this back when I started my Zigbee Map custom panel project. It would have saved me a ton of time.

1 Like

Oh man i know what you mean :slight_smile:

I posted this because im working on a project that registers multiple sidebar panels it quite interesting. I think folks would like this secret project of mine lol

1 Like