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"
}
FieldTypeRequiredDescription
namestringYesDisplay name in the panel theme picker
harvest_versionintegerYesMust be 1
variablesobjectYesCSS custom property key-value pairs for light mode
authorstringNoTheme author name
versionstringNoTheme version string
dark_variablesobjectNoVariable overrides for dark mode. Only changed keys needed.
renderer_packstring | booleanNoPack 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_settingsstring[]NoPack-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.
capabilitiesobjectNoDomain-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

VariableDefault (light)Description
--hrv-color-primary#6366f1Primary accent - buttons, active controls, sliders
--hrv-color-primary-dim#e0e7ffMuted primary - graph fill, tag backgrounds
--hrv-color-on-primary#ffffffText/icon on primary-colored backgrounds
--hrv-color-surface#ffffffCard background
--hrv-color-surface-alt#f3f4f6Inactive buttons, input backgrounds
--hrv-color-border#e5e7ebCard edges, dividers, inputs
--hrv-color-text#111827Primary text
--hrv-color-text-secondary#6b7280State labels, slider labels
--hrv-color-text-inverse#ffffffText on dark overlays
--hrv-color-icon#374151Default icon color (overridden by on/off state colors)
--hrv-color-state-on#f59e0bIcon and indicator color when entity is on
--hrv-color-state-off#9ca3afIcon and indicator color when entity is off
--hrv-color-state-unavailable#d1d5dbUnavailable state color
--hrv-color-warning#f59e0bWarning indicator
--hrv-color-error#ef4444Error state
--hrv-color-success#22c55eSuccess indicator
--hrv-color-overlayrgba(0,0,0,0.7)Background for error/stale overlays
--hrv-color-overlay-text#ffffffText on overlay backgrounds

Card structure

VariableDefaultDescription
--hrv-card-backgroundvar(--hrv-color-surface)Card background color (defaults to surface)
--hrv-card-radius12pxCard corner radius
--hrv-card-shadow0 1px 3px rgba(0,0,0,0.1)Card box-shadow
--hrv-card-padding16pxCard inner padding

Typography

VariableDefaultDescription
--hrv-font-familysystem-ui, -apple-system, sans-serifCard font family. Use inherit to match host page.
--hrv-font-size-xs11pxExtra small text
--hrv-font-size-s13pxSmall text - state labels, slider labels
--hrv-font-size-m15pxMedium text - entity name
--hrv-font-size-l18pxLarge text - temperature display
--hrv-font-weight-normal400Normal weight
--hrv-font-weight-medium500Medium weight - entity name, buttons
--hrv-font-weight-bold700Bold weight - large values

Layout and spacing

VariableDefaultDescription
--hrv-spacing-xs4pxExtra small gap
--hrv-spacing-s8pxSmall gap - between controls
--hrv-spacing-m16pxMedium gap - card padding default
--hrv-spacing-l24pxLarge gap
--hrv-radius-s4pxSmall radius - buttons, inputs
--hrv-radius-m8pxMedium radius
--hrv-radius-l12pxLarge radius - card corners
--hrv-icon-size24pxPrimary entity icon size
--hrv-transition-speed150msState 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 nameElement
cardOuter card container
card-headerName and icon row
card-nameEntity friendly name text
card-iconPrimary entity icon (SVG)
card-bodyControls area below the header
state-labelCurrent state text
stale-indicatorOffline/stale full-card overlay
error-messageError text container
companion-zoneContainer for companion entity pills
companion-iconIndividual companion entity icon
history-graphSVG graph container (sensor entities)

Light and switch renderer

Part nameElement
toggle-buttonOn/off toggle
brightness-sliderBrightness range input (0-255)
color-temp-sliderColor temperature range input

Fan renderer

Part nameElement
fan-iconFan SVG icon (spins when the Animate display setting is enabled and entity is on)
speed-sliderSpeed percentage range input
oscillate-buttonOscillate on/off button
direction-buttonsContainer for forward/reverse buttons

Climate renderer

Part nameElement
mode-buttonsContainer for HVAC mode button row
mode-buttonIndividual HVAC mode button
current-tempCurrent temperature read-only display
target-tempTarget temperature numeric display
temp-up-buttonIncrease target temperature
temp-down-buttonDecrease target temperature
fan-mode-selectFan mode dropdown
preset-selectPreset mode dropdown

Cover renderer

Part nameElement
open-buttonOpen action button
close-buttonClose action button
stop-buttonStop action button
position-sliderPosition range input (0-100)
cover-controlsRow container for the three action buttons

Media player renderer

Part nameElement
track-titleCurrent track title
track-artistCurrent track artist
track-sourceCurrent source/input
play-buttonPlay/pause toggle
next-buttonNext track
previous-buttonPrevious track
volume-sliderVolume range input
mute-buttonMute toggle

Other renderers

RendererPart names
Input numbervalue-display, number-slider
Input selectoption-select
Remoteactivity-buttons, activity-button, command-input, send-button
Timertimer-display, state-label, start-button, pause-button, cancel-button, finish-button
harvest_actionaction-button

Bundled themes

HArvest ships five themes. They're available in the panel theme picker without any import needed.

ThemeDescription
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:

MethodWhen calledDescription
render()Once, on card creationBuild the shadow DOM using this.root and this.def. Set up event listeners. Called when the entity definition arrives.
applyState(state, attributes)Every state changeUpdate the UI to reflect the new entity state. Should be fast - avoid full re-renders.
predictState(action, data)When user triggers an actionOptional. 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():

FieldTypeDescription
entity_idstringFull HA entity ID
friendly_namestringHuman-readable name
domainstringEntity domain (light, fan, etc.)
device_classstring | nullHA 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_featuresstring[]Feature flags (e.g. ["brightness", "color_temp"])
feature_configobjectFeature-specific config (e.g. min/max color temp)
gesture_configobjectTap/hold/double-tap action config
companionsstring[]List of companion entity refs
graph"line" | "bar" | nullHistory graph type
hoursnumberHistory graph time window
animatebooleanAnimate 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 / PropertyDescription
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.rootThe card's shadow root. Use to build and query the shadow DOM in render().
this.defThe entity definition object, available once render() is called.
this.lastStateLast known entity state string.
this.lastAttributesLast known entity attributes object.
this.i18n.t(key)Translate a string key to the current language.
Security note

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:

  1. The active pack's renderer for this domain + device class (e.g. "sensor.temperature")
  2. The active pack's renderer for this domain only (e.g. "sensor")
  3. The global registry (renderers registered via HArvest.registerRenderer())
  4. Built-in renderer for the domain
  5. Generic Tier 2 fallback

Pack keys

Keys in the pack object are domain strings or domain.device_class strings:

Key formatExampleMatches
"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:

  1. Server sends the theme's CSS variables to the widget
  2. Server sends a renderer_pack message with the pack URL
  3. Widget fetches and executes the pack script via dynamic <script> tag injection
  4. Pack script registers its renderers under HArvest._packs["my-pack"]
  5. 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:

  1. Go to Settings > Themes in the HArvest panel
  2. Open the Pack Registry section
  3. 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.

Distributing a theme + pack together

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.