Theming and renderer packs
CSS variables, the theme JSON format, custom themes, named parts for surgical CSS overrides, and how to build and distribute renderer packs that replace card layouts entirely.
Three styling tiers
HArvest offers three independent levels of customization. Each is more powerful and more complex than the last - use the simplest one that solves your problem.
Tier 1 - CSS Custom Properties
The primary theming mechanism. Define color, typography, spacing, and radius values via CSS custom properties (variables). These are set server-side per token and pushed to the widget over WebSocket during authentication. No client-side configuration needed.
This covers the vast majority of use cases. Start here.
Tier 2 - ::part() selectors
Every meaningful element inside each card's Shadow DOM has a part="..." attribute. Target these from your host page's stylesheet to override structural CSS that isn't exposed as a variable - for example changing flex direction, removing borders, applying a specific font metric, or hiding elements.
/* Target the toggle button on all light cards */
hrv-card::part(toggle-button) {
background: hotpink;
border-radius: 0;
}
/* Target brightness slider */
hrv-card::part(brightness-slider) {
accent-color: coral;
}
Tier 3 - Custom renderers
Replace the entire card UI for any entity domain with your own class. Full control over HTML structure, CSS, and behavior. Custom renderers are registered via HArvest.registerRenderer() or packaged into a renderer pack. See Custom renderers below.
Theme JSON format
Themes are plain JSON objects stored server-side in the HArvest integration. The panel's Themes tab is where you create and manage them. Theme variables are pushed to connected widgets over WebSocket during auth and update live when a theme is changed.
{
"name": "Midnight Blue",
"author": "your-name",
"version": "1.0",
"harvest_version": 1,
"variables": {
"--hrv-color-primary": "#4a90d9",
"--hrv-color-surface": "#f8f8ff",
"--hrv-color-text": "#1a1a2e",
"--hrv-card-radius": "16px"
},
"dark_variables": {
"--hrv-color-surface": "#1a1a2e",
"--hrv-color-text": "#e0e0ff",
"--hrv-color-border": "#2a2a4e"
},
"renderer_pack": "my-pack-id"
}
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name in the panel theme picker |
harvest_version | integer | Yes | Must be 1 |
variables | object | Yes | CSS custom property key-value pairs for light mode |
author | string | No | Theme author name |
version | string | No | Theme version string |
dark_variables | object | No | Variable overrides for dark mode. Only changed keys needed. |
renderer_pack | string | boolean | No | Pack ID to couple with this theme, or true for bundled packs where the pack script is included alongside the theme. See Wiring a pack to a theme. |
pack_settings | string[] | No | Pack-level card settings this theme supports. The panel uses this list to show or hide controls in Entity Settings. Supported values: "layout" (shows the Layout toggle). Only relevant when renderer_pack is set. |
capabilities | object | No | Domain-specific feature declarations. Controls which per-domain options appear in Entity Settings. See renderer pack documentation. |
Unknown fields and unknown variable names are silently ignored, which makes themes forward-compatible. Only use variable names from the published list below.
CSS variable reference
All variables are prefixed --hrv-. They are applied to the :host element inside each card's Shadow DOM.
Color palette
| Variable | Default (light) | Description |
|---|---|---|
--hrv-color-primary | #6366f1 | Primary accent - buttons, active controls, sliders |
--hrv-color-primary-dim | #e0e7ff | Muted primary - graph fill, tag backgrounds |
--hrv-color-on-primary | #ffffff | Text/icon on primary-colored backgrounds |
--hrv-color-surface | #ffffff | Card background |
--hrv-color-surface-alt | #f3f4f6 | Inactive buttons, input backgrounds |
--hrv-color-border | #e5e7eb | Card edges, dividers, inputs |
--hrv-color-text | #111827 | Primary text |
--hrv-color-text-secondary | #6b7280 | State labels, slider labels |
--hrv-color-text-inverse | #ffffff | Text on dark overlays |
--hrv-color-icon | #374151 | Default icon color (overridden by on/off state colors) |
--hrv-color-state-on | #f59e0b | Icon and indicator color when entity is on |
--hrv-color-state-off | #9ca3af | Icon and indicator color when entity is off |
--hrv-color-state-unavailable | #d1d5db | Unavailable state color |
--hrv-color-warning | #f59e0b | Warning indicator |
--hrv-color-error | #ef4444 | Error state |
--hrv-color-success | #22c55e | Success indicator |
--hrv-color-overlay | rgba(0,0,0,0.7) | Background for error/stale overlays |
--hrv-color-overlay-text | #ffffff | Text on overlay backgrounds |
Card structure
| Variable | Default | Description |
|---|---|---|
--hrv-card-background | var(--hrv-color-surface) | Card background color (defaults to surface) |
--hrv-card-radius | 12px | Card corner radius |
--hrv-card-shadow | 0 1px 3px rgba(0,0,0,0.1) | Card box-shadow |
--hrv-card-padding | 16px | Card inner padding |
Typography
| Variable | Default | Description |
|---|---|---|
--hrv-font-family | system-ui, -apple-system, sans-serif | Card font family. Use inherit to match host page. |
--hrv-font-size-xs | 11px | Extra small text |
--hrv-font-size-s | 13px | Small text - state labels, slider labels |
--hrv-font-size-m | 15px | Medium text - entity name |
--hrv-font-size-l | 18px | Large text - temperature display |
--hrv-font-weight-normal | 400 | Normal weight |
--hrv-font-weight-medium | 500 | Medium weight - entity name, buttons |
--hrv-font-weight-bold | 700 | Bold weight - large values |
Layout and spacing
| Variable | Default | Description |
|---|---|---|
--hrv-spacing-xs | 4px | Extra small gap |
--hrv-spacing-s | 8px | Small gap - between controls |
--hrv-spacing-m | 16px | Medium gap - card padding default |
--hrv-spacing-l | 24px | Large gap |
--hrv-radius-s | 4px | Small radius - buttons, inputs |
--hrv-radius-m | 8px | Medium radius |
--hrv-radius-l | 12px | Large radius - card corners |
--hrv-icon-size | 24px | Primary entity icon size |
--hrv-transition-speed | 150ms | State change transition duration. Set to 0ms to disable all animations. |
Dark mode
Cards automatically follow prefers-color-scheme with no configuration. The Default theme includes appropriate dark overrides built in.
In a custom theme JSON, dark_variables specifies only the values that differ from variables - everything else falls through to the light-mode value:
{
"name": "My Theme",
"harvest_version": 1,
"variables": {
"--hrv-color-surface": "#f5f5f5",
"--hrv-color-text": "#1a1a1a",
"--hrv-color-primary": "#0066cc"
},
"dark_variables": {
"--hrv-color-surface": "#1a1a2e",
"--hrv-color-text": "#e0e0ff"
}
}
If dark_variables is omitted entirely, the same values are used in both modes.
Named parts (::part() selectors)
Use hrv-card::part(name) in your host page CSS to target elements inside the Shadow DOM. Parts that aren't listed below are implementation details and may change between versions.
Universal parts - all renderers
| Part name | Element |
|---|---|
card | Outer card container |
card-header | Name and icon row |
card-name | Entity friendly name text |
card-icon | Primary entity icon (SVG) |
card-body | Controls area below the header |
state-label | Current state text |
stale-indicator | Offline/stale full-card overlay |
error-message | Error text container |
companion-zone | Container for companion entity pills |
companion-icon | Individual companion entity icon |
history-graph | SVG graph container (sensor entities) |
Light and switch renderer
| Part name | Element |
|---|---|
toggle-button | On/off toggle |
brightness-slider | Brightness range input (0-255) |
color-temp-slider | Color temperature range input |
Fan renderer
| Part name | Element |
|---|---|
fan-icon | Fan SVG icon (spins when the Animate display setting is enabled and entity is on) |
speed-slider | Speed percentage range input |
oscillate-button | Oscillate on/off button |
direction-buttons | Container for forward/reverse buttons |
Climate renderer
| Part name | Element |
|---|---|
mode-buttons | Container for HVAC mode button row |
mode-button | Individual HVAC mode button |
current-temp | Current temperature read-only display |
target-temp | Target temperature numeric display |
temp-up-button | Increase target temperature |
temp-down-button | Decrease target temperature |
fan-mode-select | Fan mode dropdown |
preset-select | Preset mode dropdown |
Cover renderer
| Part name | Element |
|---|---|
open-button | Open action button |
close-button | Close action button |
stop-button | Stop action button |
position-slider | Position range input (0-100) |
cover-controls | Row container for the three action buttons |
Media player renderer
| Part name | Element |
|---|---|
track-title | Current track title |
track-artist | Current track artist |
track-source | Current source/input |
play-button | Play/pause toggle |
next-button | Next track |
previous-button | Previous track |
volume-slider | Volume range input |
mute-button | Mute toggle |
Other renderers
| Renderer | Part names |
|---|---|
| Input number | value-display, number-slider |
| Input select | option-select |
| Remote | activity-buttons, activity-button, command-input, send-button |
| Timer | timer-display, state-label, start-button, pause-button, cancel-button, finish-button |
| harvest_action | action-button |
Bundled themes
HArvest ships five themes. They're available in the panel theme picker without any import needed.
| Theme | Description |
|---|---|
| Default | Clean, neutral. Follows OS dark/light preference automatically. Uses system font. This is applied when no theme is configured on a token. |
| Glass | Frosted-glass aesthetic with semi-transparent surface and stronger shadow. Designed for pages with full-bleed background images or gradients. |
| Access | High-contrast, WCAG AA compliant. Larger font sizes. No animations (--hrv-transition-speed: 0ms). Designed for users who need high visibility. |
| Minimus | Reference implementation of theme-pack coupling. Includes "renderer_pack": "minimus" which activates alternative card layouts for supported entity types. The pack's custom CSS variables (--hrv-ex-*) are only defined in this theme, so the pack and theme are always used together. |
| Shrooms | Mushroom-inspired renderer pack theme. Clean horizontal layout with circular colored icon shapes, pill-shaped sliders, and collapsible controls. Covers all 15 card domains. Per-domain accent colors, full dark mode support. Includes "renderer_pack": "shrooms". Inspired by the Mushroom Cards Lovelace card set by Paul Bottein. |
Creating custom themes
The panel's Themes tab is where you create, edit, preview, import, and export custom themes.
To create a new theme, click New theme in the Themes tab. You can start from scratch or duplicate an existing theme as a starting point. The Default theme is the best base for most work.
Edit the raw JSON in the code panel. The live preview updates immediately. Use the renderer type dropdown and Light/Dark/Auto toggle to check different card types in both color modes before saving.
A few practical notes:
- Only include variables you're actually changing. Missing variables fall back to base-card defaults.
"--hrv-font-family": "inherit"makes cards match whatever font your host page uses.- Invalid JSON is rejected on save - the panel will show where the syntax error is.
- Themes can be assigned to multiple tokens. Changing the theme updates all of them live.
Sharing themes
Click Export JSON on any theme to download it as a .json file. Anyone can import it on their own HArvest instance via the Themes tab Import button. This is how third-party themes are distributed.
Custom renderers
A custom renderer replaces the built-in card UI for a specific entity domain or device class. You get full control over the HTML structure, styles, and behavior inside the Shadow DOM.
BaseCard API
All renderers extend HArvest.renderers.BaseCard. Implement three methods:
| Method | When called | Description |
|---|---|---|
render() | Once, on card creation | Build the shadow DOM using this.root and this.def. Set up event listeners. Called when the entity definition arrives. |
applyState(state, attributes) | Every state change | Update the UI to reflect the new entity state. Should be fast - avoid full re-renders. |
predictState(action, data) | When user triggers an action | Optional. Return an optimistic state object for instant UI feedback before the server confirms the action. Return null to skip. |
The entity definition is available as this.def inside render():
| Field | Type | Description |
|---|---|---|
entity_id | string | Full HA entity ID |
friendly_name | string | Human-readable name |
domain | string | Entity domain (light, fan, etc.) |
device_class | string | null | HA device class if set |
capabilities | "badge" | "read" | "read-write" | Whether the token allows control. Badge entities are routed to the badge renderer, not the domain renderer. |
supported_features | string[] | Feature flags (e.g. ["brightness", "color_temp"]) |
feature_config | object | Feature-specific config (e.g. min/max color temp) |
gesture_config | object | Tap/hold/double-tap action config |
companions | string[] | List of companion entity refs |
graph | "line" | "bar" | null | History graph type |
hours | number | History graph time window |
animate | boolean | Animate fan icon |
Extending a built-in renderer
class MyLightCard extends HArvest.renderers.LightCard {
render() {
super.render();
const note = document.createElement("p");
note.textContent = "Custom footer text";
note.style.cssText = "font-size: 11px; opacity: 0.6; margin-top: 8px;";
this.root.querySelector("[part=card-body]").appendChild(note);
}
}
HArvest.registerRenderer("light", MyLightCard);
Writing a renderer from scratch
class MinimalLightCard extends HArvest.renderers.BaseCard {
#toggleBtn = null;
render() {
this.root.innerHTML = `
<style>
:host {
display: block;
padding: var(--hrv-card-padding, 16px);
background: var(--hrv-color-surface);
border-radius: var(--hrv-card-radius);
font-family: var(--hrv-font-family);
}
button {
font-size: var(--hrv-font-size-m);
padding: 8px 16px;
background: var(--hrv-color-primary);
color: var(--hrv-color-on-primary);
border: none;
border-radius: var(--hrv-radius-s);
cursor: pointer;
width: 100%;
}
.name {
font-size: var(--hrv-font-size-s);
color: var(--hrv-color-text-secondary);
margin-bottom: 8px;
}
</style>
<div part="card">
<div part="card-name" class="name">${this.def.friendly_name}</div>
<button part="toggle-button" type="button">Toggle</button>
</div>
`;
this.#toggleBtn = this.root.querySelector("[part=toggle-button]");
this.#toggleBtn.addEventListener("click", () => {
this.config.card?.sendCommand("toggle");
});
}
applyState(state, attributes) {
if (this.#toggleBtn) {
this.#toggleBtn.textContent = state === "on" ? "Turn off" : "Turn on";
}
}
predictState(action, data) {
// Return optimistic state so UI updates instantly before server confirms
if (action === "toggle") {
return { state: this.lastState === "on" ? "off" : "on" };
}
return null;
}
}
HArvest.registerRenderer("light", MinimalLightCard);
BaseCard helper methods
Available on this inside your renderer:
| Method / Property | Description |
|---|---|
this.config.card?.sendCommand(action, data) | Send a service call to HA via the WebSocket. Data is merged with entity_id automatically. |
this.renderIcon(mdiIconPath, partName) | Render an MDI SVG icon path into a [part=partName] element. |
this.renderCompanionZoneHTML() | Returns HTML string for the companion entity zone container. |
this.renderCompanions() | Populates the companion zone after render() has run. |
| (no helper) | Shared base CSS (resets, variable declarations, companion zone, stale overlay) is adopted into every renderer's shadow root automatically by BaseCard via adoptedStyleSheets. The historical getSharedStyles() helper was removed. |
this.debounce(fn, ms) | Returns a debounced version of fn. Use for slider inputs. |
this.root | The card's shadow root. Use to build and query the shadow DOM in render(). |
this.def | The entity definition object, available once render() is called. |
this.lastState | Last known entity state string. |
this.lastAttributes | Last known entity attributes object. |
this.i18n.t(key) | Translate a string key to the current language. |
Custom renderers are display-only. They cannot bypass server-side entity permission enforcement. A custom renderer for alarm_control_panel won't receive data for that entity - the integration blocks Tier 3 domains at the protocol level regardless of what the renderer requests.
Renderer packs
A renderer pack is a JavaScript file that registers multiple custom renderers at once. Packs are designed to be distributed independently of the widget library and loaded on demand by the server.
The key difference from calling HArvest.registerRenderer() directly in a page:
- Pack renderers apply only to tokens that have the pack enabled, not to all cards on the page.
- Packs are enabled server-side by associating them with a theme - no page-level HTML change needed.
- The server tells the widget which pack to load during authentication. The widget fetches and executes the pack script automatically.
Pack registry namespace
Pack renderers are registered into window.HArvest._packs[packId] - a separate namespace from the global renderer registry. This keeps pack renderers scoped to the tokens using that pack:
// The IIFE pattern is recommended to avoid polluting the global scope
(function () {
"use strict";
// Define your renderer classes
class DialLightCard extends HArvest.renderers.BaseCard {
render() {
// ... custom dial-based light UI
}
applyState(state, attributes) {
// ... update dial position
}
}
class MinimalFanCard extends HArvest.renderers.BaseCard {
render() {
// ... compact fan card
}
applyState(state, attributes) {
// ...
}
}
// Register all renderers under your pack ID
window.HArvest = window.HArvest || {};
window.HArvest._packs = window.HArvest._packs || {};
window.HArvest._packs["my-pack"] = {
"light": DialLightCard,
"fan": MinimalFanCard,
// Add more domain keys as needed
"sensor.temperature": MyTempCard, // device-class specific key
};
})();
Renderer lookup order
When a card needs to render, it checks in this order:
- The active pack's renderer for this domain + device class (e.g.
"sensor.temperature") - The active pack's renderer for this domain only (e.g.
"sensor") - The global registry (renderers registered via
HArvest.registerRenderer()) - Built-in renderer for the domain
- Generic Tier 2 fallback
Pack keys
Keys in the pack object are domain strings or domain.device_class strings:
| Key format | Example | Matches |
|---|---|---|
"domain" | "light" | All entities in that domain |
"domain.device_class" | "sensor.temperature" | Only entities with that exact domain and device class |
Device-class-specific keys take priority over plain domain keys within a pack.
Re-render on pack load
When a pack is loaded, the widget automatically re-renders all active cards using the new renderers. Cached state and history data are replayed so cards populate immediately without waiting for the next state update.
Wiring a pack to a theme
Themes and packs are coupled via the renderer_pack field in the theme JSON. When a widget connects and the server sends the theme, it also sends the associated pack URL. When the theme changes, the pack changes with it.
{
"name": "Dial Theme",
"harvest_version": 1,
"variables": {
"--hrv-color-primary": "#00bcd4",
"--hrv-color-surface": "#1a1a2e",
"--hrv-color-text": "#e0e0ff"
},
"renderer_pack": "my-pack"
}
The value of renderer_pack is a pack ID - a short string you define. The pack ID maps to the URL where the pack script is hosted. This mapping is stored in the HArvest integration's pack registry (configured in the panel Settings).
The flow when a widget connects to a token with a pack-coupled theme:
- Server sends the theme's CSS variables to the widget
- Server sends a
renderer_packmessage with the pack URL - Widget fetches and executes the pack script via dynamic
<script>tag injection - Pack script registers its renderers under
HArvest._packs["my-pack"] - Widget re-renders all active cards using the pack renderers
When the admin changes the token's theme to one without a pack, the pack is deactivated and cards re-render with the default renderers.
Distributing a pack
A renderer pack is just a JavaScript file. Host it anywhere accessible over HTTPS:
- GitHub + jsDelivr - put the file in your repo and use a jsDelivr URL like
https://cdn.jsdelivr.net/gh/username/repo@version/my-pack.js - GitHub Pages - serve it from your own GitHub Pages site
- Any CDN or web host - S3, Netlify, Cloudflare Pages, etc.
To register your pack in a HArvest instance:
- Go to Settings > Themes in the HArvest panel
- Open the Pack Registry section
- Add your pack ID and URL
To pair the pack with a theme, add "renderer_pack": "your-pack-id" to the theme JSON. Any token assigned that theme will load your pack automatically.
Package both the theme JSON and the pack JS file in a GitHub repo. Users import the theme JSON via the Themes tab, register the pack URL in the Pack Registry with the pack ID referenced in the theme's renderer_pack field, and they're done. The theme and pack link up automatically because they share the same pack ID.