Building custom cards
A complete guide to creating a custom card renderer for any Home Assistant entity domain, from first line of code to working widget. Uses a robot vacuum as the running example.
- How it works
- Prerequisites
- The BaseCard API
- Writing a renderer
- Creating a renderer pack
- Admin setup: enabling commands
- Embedding on a page
- Sending commands
- Optimistic UI
- CSS parts and theming
- Gesture handling
- Companions, history, and accessibility
- Development and testing
- Complete example: vacuum card
- Checklist
How it works
HArvest groups Home Assistant entity domains into three tiers:
- Tier 1 - domains with built-in card renderers (light, switch, climate, fan, cover, etc.). Commands are pre-approved in a server-side whitelist.
- Tier 2 - all other domains (vacuum, humidifier, valve, siren, etc.). These display with the generic card by default. A custom renderer can replace the generic card for any Tier 2 domain.
- Tier 3 - blocked domains (lock, alarm_control_panel, camera, etc.). Cannot be used with HArvest at all.
Building a custom card has two sides:
- Display - write a renderer class that extends
BaseCardand package it as a renderer pack. The pack is uploaded through the HArvest panel and automatically loaded by the widget for any token that uses the associated theme. No script tags are needed on the embedding page. - Commands - the HA admin registers allowed services for the domain in the HArvest panel under Settings. This opens the server-side whitelist so the card's commands are accepted.
Neither side requires changes to the HArvest integration source code. The renderer pack is managed through the panel, and the service allowlist is configured in Settings.
Custom cards are delivered as renderer packs. A pack is a JavaScript file uploaded through the HArvest panel and tied to a theme. When a token uses that theme, the widget automatically fetches and loads the pack. The person embedding the widget does not need to add any extra <script> tags.
During development, you can use HArvest.registerRenderer() with a local <script> tag for faster iteration. But for production, always package your renderer as a pack. This guide covers both approaches, with pack delivery as the primary path.
Prerequisites
- A running Home Assistant instance with the HArvest integration installed.
- An entity in the domain you want to build a card for (e.g.
vacuum.roomba). - A HArvest token that includes the entity (Tier 2 entities are already available in the entity picker).
- Access to the HArvest panel to upload the renderer pack and configure custom domain services.
- Basic familiarity with JavaScript classes and the DOM API.
No build tools are required. Custom renderers are vanilla JavaScript. They are uploaded as renderer packs through the HArvest panel and loaded automatically by the widget.
The BaseCard API
Every renderer extends BaseCard, which provides the constructor, lifecycle hooks, and helper methods. The widget instantiates your class when an entity definition arrives from the server.
Constructor
You do not write a constructor unless you need to initialize private fields. The base constructor receives four arguments, available as instance properties:
| Property | Type | Description |
|---|---|---|
this.def |
Object | The entity definition from the server. Contains entity_id, domain, friendly_name, capabilities ("badge", "read", or "read-write"), supported_features (array of strings), feature_config (domain-specific ranges and options), icon, icon_state_map, display_hints, gesture_config, companions, and more. Badge entities receive a minimal definition (no gesture_config, companions, or supported_features). |
this.root |
ShadowRoot | The shadow DOM root. Write all HTML and CSS here. Fully isolated from the host page. |
this.config |
Object | Card configuration. Includes tokenId, haUrl, entity, alias, lang, colorScheme, gestureConfig, displayHints, and the card back-reference (used to send commands). |
this.i18n |
Object | Translation helper. Call this.i18n.t("key") to get the translated string for the current language. |
Required methods
| Method | When called | What to do |
|---|---|---|
render() |
Once, when the entity definition arrives. | Build the full shadow DOM structure. Set this.root.innerHTML with your HTML template, query elements, attach event handlers, and call this.renderCompanions() at the end. |
applyState(state, attributes) |
On every state update from the server (and immediately after render()). |
Update DOM elements to reflect the current state. state is a string ("on", "off", "cleaning", "unavailable", etc.). attributes is an object with all entity attributes. |
Optional methods
| Method | Purpose |
|---|---|
predictState(action, data) |
Return { state, attributes } for optimistic UI, or null to skip. Called immediately when a command is sent, before the server confirms. |
destroy() |
Cleanup when the card is removed. Clear timers, cancel listeners. |
updateCompanionState(entityId, state, attributes) |
Override default companion pill rendering with custom logic. |
Inherited helpers
| Method | Description |
|---|---|
| (no helper) | The shared base CSS (card layout, companion zone, history graph, gesture, error/stale states) is adopted into every renderer's shadow root automatically by BaseCard via adoptedStyleSheets. Your renderer's <style> tag only emits renderer-specific CSS. The historical getSharedStyles() helper was removed; do not call it. |
renderIcon(iconName, partName) | Inject an MDI SVG icon into the element with part="<partName>". Caches internally and skips re-render if unchanged. |
resolveIcon(name, fallback) | Returns name if the icon is in the bundled set, otherwise returns fallback. |
isFocused(element) | Returns true if element has focus in the shadow root. Use in applyState() to avoid overwriting user input (e.g. while typing in a number input). |
isSliderActive(element) | Returns true while the user is actively dragging a range slider (pointer down or pending un-grace period after release). Use in applyState() to skip slider updates while the user is interacting. |
guardSlider(slider, debouncedSend) | One-call drag-protection wiring for a <input type="range">. Adds pointer/keyboard handlers that mark the slider active and flush the debounced send on release. Pair with isSliderActive() in applyState(). |
formatStateLabel(state, domain, deviceClass) | Returns the i18n-localised label for a state string, falling back to the raw state if the key is missing. Handles common domain conventions (sensor numeric values, binary_sensor on/off, etc.). |
renderCompanionZoneHTML() | Returns the HTML for the companion pill zone. Empty string if no companions configured. |
renderCompanions() | Populates the companion zone with pill elements. Call at the end of render(). |
renderHistoryZoneHTML() | Returns the HTML for the history graph placeholder. Empty string if history is not configured. |
renderAriaLiveHTML() | Returns a screen-reader announcement region. Include in your template. |
announceState(text) | Push text to the aria-live region for screen reader users. |
_attachGestureHandlers(element, callbacks, actionConfig) | Attach tap, hold, and double-tap handlers. See Gesture handling. |
_runAction(actionConfig) | Execute a gesture action (toggle, trigger-action, call-service, none). |
debounce(fn, ms) | Returns a debounced version of a function. Useful for slider input handlers. |
Writing a renderer
A minimal renderer needs two methods: render() to build the DOM, and applyState() to update it. The class extends BaseCard, which is available at HArvest.renderers.BaseCard inside a renderer pack. Here is a stripped-down vacuum card to illustrate the pattern:
class VacuumCard extends HArvest.renderers.BaseCard {
#statusEl = null;
#startBtn = null;
#stopBtn = null;
render() {
const isWritable = this.def.capabilities === "read-write";
this.root.innerHTML = `
<style>
[part=card-body] {
display: flex;
align-items: center;
gap: var(--hrv-spacing-s);
}
[part=status] {
font-size: var(--hrv-font-size-s);
color: var(--hrv-color-text-secondary);
}
button[part] {
padding: var(--hrv-spacing-xs) var(--hrv-spacing-m);
border: none;
border-radius: var(--hrv-radius-m);
font-size: var(--hrv-font-size-s);
font-weight: var(--hrv-font-weight-medium);
font-family: inherit;
cursor: pointer;
transition: opacity var(--hrv-transition-speed);
}
button[part]:hover { opacity: 0.88; }
button[part]:disabled { opacity: 0.4; cursor: not-allowed; }
[part=start-btn] {
background: var(--hrv-color-success);
color: var(--hrv-color-text-inverse);
}
[part=stop-btn] {
background: var(--hrv-color-error);
color: var(--hrv-color-text-inverse);
}
</style>
<div part="card">
<div part="card-header">
<span part="card-icon" aria-hidden="true"></span>
<span part="card-name">${this._esc(this.def.friendly_name)}</span>
</div>
<div part="card-body">
<span part="status"></span>
${isWritable ? `
<button part="start-btn" type="button">Start</button>
<button part="stop-btn" type="button">Stop</button>
` : ""}
</div>
${this.renderAriaLiveHTML()}
${this.renderCompanionZoneHTML()}
<div part="stale-indicator" aria-hidden="true"></div>
</div>
`;
this.#statusEl = this.root.querySelector("[part=status]");
this.#startBtn = this.root.querySelector("[part=start-btn]");
this.#stopBtn = this.root.querySelector("[part=stop-btn]");
this.renderIcon(
this.resolveIcon("mdi:robot-vacuum", "mdi:help-circle"),
"card-icon",
);
if (this.#startBtn) {
this.#startBtn.addEventListener("click", () => {
this.config.card?.sendCommand("start", {});
});
}
if (this.#stopBtn) {
this.#stopBtn.addEventListener("click", () => {
this.config.card?.sendCommand("stop", {});
});
}
this.renderCompanions();
}
applyState(state, attributes) {
const label = state.replace(/_/g, " ");
if (this.#statusEl) this.#statusEl.textContent = label;
const cleaning = state === "cleaning";
if (this.#startBtn) this.#startBtn.disabled = cleaning;
if (this.#stopBtn) this.#stopBtn.disabled = !cleaning;
const battery = attributes.battery_level;
if (battery != null && this.#statusEl) {
this.#statusEl.textContent = `${label} - ${battery}%`;
}
this.announceState(`${this.def.friendly_name}, ${label}`);
}
_esc(str) {
return String(str ?? "")
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
}
Key patterns
- Check
capabilities- if the entity is"read", do not render buttons, sliders, or any interactive controls. Only show state. Badge entities ("badge") are routed to the badge renderer, not your card, so you do not need to handle that case. - Shared styles are automatic - the card layout, companion zone, history graph, gesture, and error/stale state styles are adopted into your shadow root automatically by
BaseCard. Your<style>block only needs renderer-specific CSS; do not callgetSharedStyles()(it no longer exists). - Use
partattributes - every meaningful element should have apart="..."attribute so theme authors can target it with::part()selectors. - Include the standard zones - your template should include
renderAriaLiveHTML(),renderCompanionZoneHTML(), and a<div part="stale-indicator">. These enable accessibility, companion pills, and the stale-data indicator. - Call
renderCompanions()- at the end ofrender(), this populates the companion zone. - Escape user text - always escape
friendly_nameand other user-provided strings before inserting into HTML. - Guard slider updates - in
applyState(), usethis.isFocused(element)before updating slider values to avoid overwriting while the user is dragging.
Creating a renderer pack
A renderer pack is a JavaScript file that bundles one or more renderer classes and registers them with HArvest. Packs are the standard way to deliver custom cards. They are uploaded through the HArvest panel and tied to a theme. When a token uses that theme, the widget automatically fetches and loads the pack - the person embedding the widget does not need to add any extra script tags.
Pack structure
A pack is wrapped in an IIFE (immediately invoked function expression) that accesses BaseCard from the global HArvest object and registers the renderers under a pack key:
(function () {
"use strict";
const HArvest = window.HArvest;
if (!HArvest || !HArvest.renderers || !HArvest.renderers.BaseCard) {
console.warn("[MyPack] HArvest not loaded.");
return;
}
const BaseCard = HArvest.renderers.BaseCard;
class VacuumCard extends BaseCard {
render() { /* ... */ }
applyState(state, attributes) { /* ... */ }
}
// Register under the pack key (resolved automatically by the widget)
HArvest._packs = HArvest._packs || {};
const _packKey = (document.currentScript && document.currentScript.dataset.packId)
|| window.__HARVEST_PACK_ID__
|| "my-pack";
HArvest._packs[_packKey] = {
"vacuum": VacuumCard,
_capabilities: {
vacuum: { features: ["start", "stop", "fan_speed"] },
},
};
})();
Key points
- The pack key (
_packKey) is resolved automatically by the widget. Use the boilerplate above as-is. - Each entry in the pack object maps a domain key (e.g.
"vacuum") to a renderer class. You can register multiple domains in one pack. - You can also register for a specific device class:
"sensor.temperature": MyTempCard. - The
_capabilitiesobject declares what features the pack's renderers support. The server uses this to populatesupported_featuresin the entity definition. - Registration is last-write-wins. If the key matches a built-in renderer, the pack renderer takes priority for tokens using this pack's theme.
Uploading the pack
- In the HArvest panel, go to Themes.
- Create or edit a theme and enable Renderer pack.
- Upload or paste your pack JavaScript in the pack code editor.
- Assign the theme to a token. Any widget using that token will automatically load the pack.
Lookup order
When the widget needs a renderer for a domain, it checks in this order:
- The active pack's registry (if the token's theme has a renderer pack).
- The global renderer registry (built-in renderers and any
registerRenderer()calls). - The generic card fallback.
This means pack renderers only affect tokens using that pack's theme. Other tokens continue to use the built-in renderers, keeping everything isolated.
Admin setup: enabling commands
Registering a renderer only handles the display side. By default, the server rejects all commands for Tier 2 domains because they are not in the built-in service whitelist. If your card sends commands (like start, stop, set_fan_speed), the HA admin must register the domain's allowed services in the HArvest panel.
- Open the HArvest panel in Home Assistant and go to Settings.
- Scroll to the Custom Domains card.
- Select the domain from the dropdown (e.g.
vacuum). The list shows all Tier 2 domains that have entities in your HA instance. - Toggle on the services your renderer needs (e.g.
start,stop,pause,return_to_base). The available services come directly from HA's service registry for that domain. - Click Add. The domain and its allowed services are saved immediately.
Once registered, commands for those services are accepted by the server. Commands for services that were not toggled on are still rejected with HRV_PERMISSION_DENIED.
Only enable the specific services your renderer actually uses. This follows the principle of least privilege. You can always add more services later if needed. Tier 3 domains (lock, alarm, camera, etc.) cannot be added as custom domains regardless of this setting.
Read-only cards
If your card only displays state and never sends commands, you do not need to register any custom domain services. The generic card already shows state for all Tier 2 domains. Your custom renderer replaces the visual presentation without requiring any server-side changes.
Embedding on a page
Because your renderer is delivered as a pack, the embedding page needs nothing beyond the standard HArvest setup. The widget automatically loads the pack when it connects and finds the token's theme has a renderer pack. No extra script tags, no manual registration.
<script src="https://myhome.example.com/harvest_assets/harvest.min.js"></script>
<script>
HArvest.config({ haUrl: "https://myhome.example.com", token: "hwt_..." });
</script>
<!-- Custom element mode -->
<hrv-card entity="vacuum.roomba"></hrv-card>
<!-- Data attribute mode -->
<div class="hrv-mount" data-entity="vacuum.roomba"></div>
<!-- With alias -->
<hrv-card alias="dJ5x3Apd"></hrv-card>
The token must include the entity with read-write capability if you want commands to work. For display-only cards, read capability is sufficient. For compact status indicators, use badge capability - the entity renders as a pill and does not use your custom renderer.
Mixing with built-in cards
Custom cards work alongside built-in cards on the same page with no conflicts. They share the same token and WebSocket connection:
<hrv-group>
<hrv-card entity="light.living_room"></hrv-card>
<hrv-card entity="climate.thermostat"></hrv-card>
<hrv-card entity="vacuum.roomba"></hrv-card>
</hrv-group>
Sending commands
Commands are sent through the card's back-reference to the HrvCard element:
this.config.card?.sendCommand(action, data);
The action is the HA service name (e.g. "start", "set_fan_speed"). The data is an object with the service call data (e.g. { fan_speed: "max" }). The domain is inferred from the entity.
The server validates the command against two checks:
- Service whitelist - the action must be in the built-in
ALLOWED_SERVICESlist (for Tier 1 domains) or the admin-registered custom domains list (for Tier 2). If not, the command is rejected withHRV_PERMISSION_DENIED. - Data key filtering - for Tier 1 domains, only keys in the hardcoded
_ALLOWED_DATA_KEYSmap are forwarded. For custom domains, the server queries HA's service schema to determine which data keys the service accepts. Unknown keys are silently stripped.
If the entity's capability is "read" or "badge", all commands are rejected regardless of the whitelist.
Optimistic UI
Implement predictState() to update the UI immediately when a command is sent, without waiting for the server round-trip. This makes the card feel responsive.
predictState(action, data) {
if (action === "start") return { state: "cleaning", attributes: {} };
if (action === "stop") return { state: "idle", attributes: {} };
if (action === "return_to_base") return { state: "returning", attributes: {} };
return null; // No prediction - wait for server
}
When predictState() returns a non-null value, applyState() is called immediately with the predicted state. The real state from the server overwrites it when it arrives (typically within a few hundred milliseconds).
Return null for commands where the outcome is ambiguous or depends on external factors.
CSS parts and theming
HArvest uses shadow DOM for style isolation. Theme authors and page designers can target elements inside your card using ::part() selectors. Every meaningful element in your renderer should have a part="..." attribute.
Standard parts
These parts are used by all built-in renderers. Your custom card should include them where applicable so that themes work consistently:
| Part | Element |
|---|---|
card | Outermost container |
card-header | Header row (icon + name) |
card-icon | Entity icon |
card-name | Entity friendly name |
card-body | Main content area |
state-label | State text |
stale-indicator | Stale data indicator (styled by shared styles) |
companion-zone | Companion entity pills (rendered by renderCompanions()) |
history-graph | History graph (rendered by shared styles) |
Add domain-specific parts for controls unique to your card (e.g. part="start-btn", part="battery-bar"). Theme authors can target these with:
hrv-card::part(start-btn) {
background: #22c55e;
border-radius: 20px;
}
CSS custom properties
Use HArvest's CSS custom properties for colours, spacing, typography, and card structure. This ensures your card adapts to theme changes and dark mode automatically. The full list is on the Theming page. The most commonly used:
/* Colours */
var(--hrv-color-primary) /* Accent colour */
var(--hrv-color-surface) /* Card background */
var(--hrv-color-text) /* Primary text */
var(--hrv-color-text-secondary) /* Muted text */
var(--hrv-color-state-on) /* Active/on state */
var(--hrv-color-state-off) /* Inactive/off state */
var(--hrv-color-success) /* Positive actions */
var(--hrv-color-error) /* Negative actions / errors */
var(--hrv-color-text-inverse) /* Text on coloured backgrounds */
/* Spacing and sizing */
var(--hrv-spacing-xs) /* 4px */ var(--hrv-spacing-s) /* 8px */
var(--hrv-spacing-m) /* 16px */ var(--hrv-spacing-l) /* 24px */
var(--hrv-radius-s) /* 4px */ var(--hrv-radius-m) /* 8px */
var(--hrv-radius-l) /* 12px */
/* Typography */
var(--hrv-font-size-xs) /* 11px */ var(--hrv-font-size-s) /* 13px */
var(--hrv-font-size-m) /* 15px */ var(--hrv-font-size-l) /* 18px */
var(--hrv-font-weight-medium) /* 500 */
var(--hrv-font-weight-bold) /* 700 */
/* Card structure */
var(--hrv-card-background) var(--hrv-card-radius)
var(--hrv-card-shadow) var(--hrv-card-padding)
var(--hrv-transition-speed) /* 150ms */
Gesture handling
For the primary interactive element of your card, use _attachGestureHandlers() instead of plain click listeners. This integrates with the server-configured gesture system (tap, hold, double-tap) and respects prefers-reduced-motion.
this._attachGestureHandlers(this.#startBtn, {
onTap: () => {
// Check if server has a custom tap action configured
const tap = this.config.gestureConfig?.tap;
if (tap) { this._runAction(tap); return; }
// Default behaviour
this.config.card?.sendCommand("start", {});
},
});
For secondary controls (individual buttons, sliders), plain click or input listeners are fine. Gesture handlers are most useful for the card's primary action element.
Gesture timing: hold fires after 500ms of continuous contact, double-tap fires when two taps occur within 250ms.
Companions, history, and accessibility
Companion entities
Companion pills (small secondary entities displayed inside the card) are handled automatically. Include this.renderCompanionZoneHTML() in your template and call this.renderCompanions() at the end of render(). The base class handles the rest.
History graphs
Include this.renderHistoryZoneHTML() in your template between the card body and the companion zone. If the admin configures a history graph for the entity, it renders automatically.
Accessibility
- Include
this.renderAriaLiveHTML()in your template. - Call
this.announceState(text)inapplyState()to push state changes to screen readers. - Use
aria-labelon interactive elements. The helperthis.setAriaLabel(el, label)is available. - All CSS animations must respect
prefers-reduced-motion. Use a media query:
@media (prefers-reduced-motion: reduce) {
.spin-animation { animation: none; }
}
Development and testing
During development, uploading a pack after every change is slow. For faster iteration, you can use HArvest.registerRenderer() with a local script tag. This registers the renderer globally (not scoped to a theme) so you can test it immediately.
<script src="https://myhome.example.com/harvest_assets/harvest.min.js"></script>
<script src="vacuum-card.js"></script>
<script>
HArvest.config({ haUrl: "https://myhome.example.com", token: "hwt_..." });
</script>
Inside your development script, register the class directly:
class VacuumCard extends HArvest.renderers.BaseCard {
render() { /* ... */ }
applyState(state, attributes) { /* ... */ }
}
HArvest.registerRenderer("vacuum", VacuumCard);
This approach requires a <script> tag on the embedding page, which is fine for local testing. Once your renderer is working, wrap it in the pack IIFE format (see Creating a renderer pack) and upload it through the panel. The pack format is the production delivery mechanism; the person embedding your widget should never need to add script tags for custom cards.
Both packs and standalone registration support device-class-specific keys: HArvest.registerRenderer("sensor.temperature", MyTempCard). This overrides the renderer only for sensors with device_class: "temperature", leaving other sensors on their default renderer.
Complete example: vacuum card
This is a full, working vacuum card packaged as a renderer pack. It displays state, battery level, and fan speed, with start, stop, and return-to-base buttons. Copy this code into the pack editor in the HArvest panel (Themes, enable Renderer pack, paste code).
/**
* vacuum-pack.js - HArvest renderer pack for robot vacuums.
*
* Displays current status, battery level, and fan speed.
* Controls: Start, Stop, Return to base.
*
* Admin setup: register "vacuum" in Settings > Custom Domains
* with services: start, stop, pause, return_to_base, set_fan_speed
*/
(function () {
"use strict";
const HArvest = window.HArvest;
if (!HArvest || !HArvest.renderers || !HArvest.renderers.BaseCard) {
console.warn("[VacuumPack] HArvest not loaded.");
return;
}
const BaseCard = HArvest.renderers.BaseCard;
function esc(str) {
return String(str ?? "")
.replace(/&/g, "&").replace(/</g, "<")
.replace(/>/g, ">").replace(/"/g, """);
}
const STYLES = `
[part=card-body] {
display: flex;
flex-direction: column;
gap: var(--hrv-spacing-s);
}
[part=status-row] {
display: flex;
align-items: center;
justify-content: space-between;
}
[part=status] {
font-size: var(--hrv-font-size-m);
font-weight: var(--hrv-font-weight-medium);
text-transform: capitalize;
}
[part=battery] {
font-size: var(--hrv-font-size-s);
color: var(--hrv-color-text-secondary);
}
[part=fan-speed] {
font-size: var(--hrv-font-size-xs);
color: var(--hrv-color-text-secondary);
}
[part=controls] {
display: flex;
gap: var(--hrv-spacing-xs);
flex-wrap: wrap;
}
[part=controls] button {
flex: 1;
min-width: 60px;
padding: var(--hrv-spacing-xs) var(--hrv-spacing-s);
border: 1px solid var(--hrv-color-border);
border-radius: var(--hrv-radius-m);
background: var(--hrv-color-surface-alt);
color: var(--hrv-color-text);
font-size: var(--hrv-font-size-xs);
font-weight: var(--hrv-font-weight-medium);
font-family: inherit;
cursor: pointer;
transition: opacity var(--hrv-transition-speed),
background var(--hrv-transition-speed);
}
[part=controls] button:hover { opacity: 0.85; }
[part=controls] button:disabled { opacity: 0.4; cursor: not-allowed; }
[part=controls] button[data-active=true] {
background: var(--hrv-color-primary);
color: var(--hrv-color-on-primary);
border-color: var(--hrv-color-primary);
}
[part=card][data-state=cleaning] [part=status] {
color: var(--hrv-color-success);
}
[part=card][data-state=error] [part=status] {
color: var(--hrv-color-error);
}
@media (prefers-reduced-motion: reduce) {
* { transition-duration: 0s !important; }
}
`;
class VacuumCard extends BaseCard {
#statusEl = null;
#batteryEl = null;
#fanEl = null;
#startBtn = null;
#stopBtn = null;
#dockBtn = null;
#cardEl = null;
render() {
const w = this.def.capabilities === "read-write";
this.root.innerHTML = `
<style>${STYLES}</style>
<div part="card">
<div part="card-header">
<span part="card-icon" aria-hidden="true"></span>
<span part="card-name">${esc(this.def.friendly_name)}</span>
</div>
<div part="card-body">
<div part="status-row">
<span part="status"></span>
<span part="battery"></span>
</div>
<div part="fan-speed"></div>
${w ? `
<div part="controls">
<button part="start-btn" type="button">Start</button>
<button part="stop-btn" type="button">Stop</button>
<button part="dock-btn" type="button">Dock</button>
</div>
` : ""}
</div>
${this.renderHistoryZoneHTML()}
${this.renderAriaLiveHTML()}
${this.renderCompanionZoneHTML()}
<div part="stale-indicator" aria-hidden="true"></div>
</div>
`;
this.#cardEl = this.root.querySelector("[part=card]");
this.#statusEl = this.root.querySelector("[part=status]");
this.#batteryEl = this.root.querySelector("[part=battery]");
this.#fanEl = this.root.querySelector("[part=fan-speed]");
this.#startBtn = this.root.querySelector("[part=start-btn]");
this.#stopBtn = this.root.querySelector("[part=stop-btn]");
this.#dockBtn = this.root.querySelector("[part=dock-btn]");
this.renderIcon(
this.resolveIcon("mdi:robot-vacuum", "mdi:help-circle"),
"card-icon",
);
if (this.#startBtn) {
this.#startBtn.addEventListener("click", () => {
this.config.card?.sendCommand("start", {});
});
}
if (this.#stopBtn) {
this.#stopBtn.addEventListener("click", () => {
this.config.card?.sendCommand("stop", {});
});
}
if (this.#dockBtn) {
this.#dockBtn.addEventListener("click", () => {
this.config.card?.sendCommand("return_to_base", {});
});
}
this.renderCompanions();
}
applyState(state, attributes) {
const label = state.replace(/_/g, " ");
if (this.#statusEl) this.#statusEl.textContent = label;
if (this.#cardEl) this.#cardEl.setAttribute("data-state", state);
const battery = attributes.battery_level;
if (this.#batteryEl) {
this.#batteryEl.textContent = battery != null ? `${battery}%` : "";
}
const fan = attributes.fan_speed;
if (this.#fanEl) {
this.#fanEl.textContent = fan ? `Fan: ${fan}` : "";
}
const cleaning = state === "cleaning";
const docked = state === "docked";
const returning = state === "returning";
if (this.#startBtn) {
this.#startBtn.disabled = cleaning;
this.#startBtn.setAttribute("data-active", String(cleaning));
}
if (this.#stopBtn) {
this.#stopBtn.disabled = !cleaning;
}
if (this.#dockBtn) {
this.#dockBtn.disabled = docked || returning;
this.#dockBtn.setAttribute("data-active", String(returning));
}
this.announceState(
`${this.def.friendly_name}, ${label}` +
(battery != null ? `, battery ${battery} percent` : ""),
);
}
predictState(action, _data) {
if (action === "start") return { state: "cleaning", attributes: {} };
if (action === "stop") return { state: "idle", attributes: {} };
if (action === "return_to_base") return { state: "returning", attributes: {} };
return null;
}
}
// Pack registration
HArvest._packs = HArvest._packs || {};
const _packKey = (document.currentScript && document.currentScript.dataset.packId)
|| window.__HARVEST_PACK_ID__
|| "vacuum-pack";
HArvest._packs[_packKey] = {
"vacuum": VacuumCard,
_capabilities: {
vacuum: { features: ["start", "stop", "return_to_base", "fan_speed"] },
},
};
})();
Setup steps
- In the HArvest panel, go to Themes. Create or edit a theme, enable Renderer pack, and paste the code above into the pack editor.
- Add
vacuum.roombato a HArvest token withread-writecapability. Assign the theme you just configured. - In Settings > Custom Domains, select
vacuumand enable:start,stop,pause,return_to_base,set_fan_speed. - Embed the widget on a page. No extra script tags are needed - the pack loads automatically:
<script src="https://myhome.example.com/harvest_assets/harvest.min.js"></script>
<script>
HArvest.config({
haUrl: "https://myhome.example.com",
token: "hwt_a3F9bC2d114eF5A6b7c8dE",
});
</script>
<hrv-card entity="vacuum.roomba"></hrv-card>
The widget fetches the pack from the server because the token's theme has a renderer pack enabled. The vacuum card renders automatically with no additional setup on the embedding page.
Checklist
Before shipping your custom card:
| Check | Details |
|---|---|
| Read-only mode | Card renders cleanly when capabilities is "read" (no buttons, sliders, or interactive controls). |
| Unavailable state | applyState("unavailable", {}) disables controls and shows a clear status. |
| Part attributes | Every meaningful element has a part="..." so themes can target it. |
| Shared styles | Adopted automatically by BaseCard via adoptedStyleSheets; the renderer's <style> tag only contains renderer-specific CSS. |
| Companion zone | renderCompanionZoneHTML() in template, renderCompanions() called at end of render(). |
| Accessibility | renderAriaLiveHTML() in template, announceState() called in applyState(), interactive elements have aria-label. |
| Stale indicator | <div part="stale-indicator"> included in template. |
| Reduced motion | Animations are disabled when prefers-reduced-motion: reduce is set. |
| HTML escaping | User-provided strings (friendly name, state text) are escaped before insertion. |
| Slider focus guard | If you have sliders, isFocused() is checked in applyState() before updating values. |
| Pack format | Renderer is wrapped in the IIFE pack format with proper _packKey resolution and _capabilities declaration. |
| Custom domain registered | Admin has registered the domain's services in Settings if the card sends commands. |
| CSS custom properties | Colours, spacing, and typography use --hrv-* variables, not hardcoded values. |