Bubble Card module documentation

August 8, 2026 · View on GitHub

This documentation covers everything you need to write a Bubble Card module: the editor schema that builds your configuration UI, how to read those values from your code, the entity suggestions you can contribute to the Home Assistant card picker, and how to keep your module fast and compatible.

image

Table of contents


Basic structure

A module editor schema is an array of objects, where each object represents a form field:

editor:
  - name: color
    label: "Color"
    selector:
      select:
        options:
          - label: "Red"
            value: "red"
          - label: "Blue"
            value: "blue"
  - name: icon_size
    label: "Icon Size"
    selector:
      number:
        min: 20
        max: 50
        unit_of_measurement: "px"

Accessing configuration values in your module code

When creating a Bubble Card module, you'll need to access the values configured by users through the editor. These values are available in your module's code through the this.config object.

Accessing configuration in JavaScript templates

In your module's code section, you can access the configured values using JavaScript template literals. The configuration values follow a specific structure:

this.config.module_id?.field_name

Where:

  • module_id is the ID of your module as defined in your module definition
  • field_name corresponds directly to the name property of the field in your editor schema
Example

If you have this editor schema:

editor:
  - name: color                # This becomes "color" in your code
    label: "Background Color"
    selector:
      ui_color: {}
  - name: size                 # This becomes "size" in your code
    label: "Icon Size"
    selector:
      number:
        min: 10
        max: 50
        unit_of_measurement: "px"
  - name: show_icon            # This becomes "show_icon" in your code
    label: "Show Icon"
    selector:
      boolean: {}

Then in your module's code section, you would access these values like this:

.bubble-icon-container {
  /* Access the "color" field */
  background: var(--${this.config.module_id?.color}-color) !important;
  
  /* Access the "size" field with a default value if undefined */
  --mdc-icon-size: ${this.config.module_id?.size || 24}px;
  
  /* Access the "show_icon" boolean field */
  display: ${this.config.module_id?.show_icon ? 'flex' : 'none'};
}

Remember to replace module_id with your actual module ID in your code.

Tips for working with configuration values

  • Use optional chaining: Always use the optional chaining operator (?.) when accessing nested configuration properties to prevent errors if the configuration is missing.

  • Provide default values if possible: In some cases, use the OR operator (||) to supply default values in case the configuration value is undefined.

Example: Complete module with editor and code

Complete module example

Here's a complete example of a module definition showing both the editor schema and how to use the values in the code:

icon_container_color:
  name: 'Example: Customize the icon container color'
  version: v1.1
  creator: Clooos
  link: https://github.com/Clooos/Bubble-Card/discussions/1231
  unsupported:
    - horizontal-buttons-stack
    - separator
  description: |
    A list of predefined colors to customize the icon container color.
    Configure this module via the editor or in YAML, for example:
    <br><br>
    <code-block><pre>
    icon_container_color: 
        color: light-blue
    </pre></code-block>
  code: |
    .bubble-icon-container {
      opacity: 1 !important;
      background: var(--${this.config.icon_container_color?.color}-color) !important;
    }
  editor:
    - name: color
      label: Color
      selector:
        ui_color:
          include_none: true

Field properties

Every field in your editor schema can have these common properties:

PropertyTypeDescription
namestringRequired. The key used to store the value in the module configuration
labelstringThe displayed name for the field
requiredbooleanWhether the field is required
disabledbooleanWhether the field is disabled
defaultanyDefault value if no value is provided

Field types

You can define fields using either the legacy type syntax or the modern selector syntax.

Important

Not all selector types have been tested with Bubble Card modules. Some selectors might not work correctly or might not be fully compatible. If you encounter an issue, please report it here.

Selector-based fields

Selector-based fields provide rich UI controls. Simply use the selector property with one of the following selector types:

Text input selectors

- name: title
  label: "Title"
  selector:
    text: {}
Options
OptionTypeDescription
multilinebooleanEnable multiline text input
typestringHTML input type (e.g., "email", "url", "password")
autocompletestringBrowser autocomplete attribute
prefixstringText to display before the input
suffixstringText to display after the input

Number selector

- name: opacity
  label: "Opacity"
  selector:
    number:
      min: 0
      max: 100
      step: 5
      unit_of_measurement: "%"
Options
OptionTypeDescription
minnumberMinimum value
maxnumberMaximum value
stepnumberStep value
modestringDisplay mode: "box" or "slider" (default: "slider")
unit_of_measurementstringUnit label
min_stepnumberMinimum step value

Boolean selector

- name: show_icon
  label: "Show Icon"
  selector:
    boolean: {}

No additional option for this selector.

Select selector

- name: theme
  label: "Theme"
  selector:
    select:
      options:
        - label: "Light"
          value: "light"
        - label: "Dark"
          value: "dark"
        - label: "Auto"
          value: "auto"
      multiple: false
      custom_value: false
      mode: "dropdown"
Options
OptionTypeDescription
optionsarrayList of options with label/value pairs, or simple string arrays
translation_keystringTranslation key for the options
multiplebooleanAllow multiple selection
custom_valuebooleanAllow custom values
modestringDisplay mode: "dropdown" or "list"

Color selector

- name: background_color
  label: "Background Color"
  selector:
    ui_color: {}
Options
OptionTypeDescription
default_colorstringDefault color to use if no color is selected
include_nonebooleanInclude an option to select no color
include_statebooleanInclude a color based on the entity state

Icon selector

- name: custom_icon
  label: "Custom Icon"
  selector:
    icon: {}

No additional option for this selector.

Condition selector

- name: conditions
  label: "Conditions"
  selector:
    condition: {}

The condition selector allows you to define complex conditions based on entity states, numeric values, time, and more. This is especially powerful for creating conditional UI elements or behaviors.

Using conditions in your module code

In your JavaScript code, you can use the checkConditionsMet function to evaluate conditions at runtime:

// Example of checking if conditions are met
if (!badgeConfig?.condition || (badgeConfig?.condition && checkConditionsMet([].concat(badgeConfig.condition), hass))) {
  // The condition is met or there is no condition defined
  // Show or activate your component here
}

Here's a simple example:

# Module configuration example
my_module:
  element_to_show:
    condition:
      - condition: state
        entity_id: light.living_room
        state: 'on'
      - condition: numeric_state
        entity_id: sensor.temperature
        above: 20
// In your module code
const elementConfig = this.config.my_module?.element_to_show;
if (!elementConfig?.condition || checkConditionsMet([].concat(elementConfig.condition), hass)) {
  // Show element when living room light is ON and temperature is above 20
}
Supported condition types

checkConditionsMet evaluates conditions in the browser, where Home Assistant normally evaluates them on the server. Every condition type the native condition builder offers is supported: the Lovelace ones (state, numeric_state, screen, user, time, location, view_columns, and, or, not, plus the Bubble Card template one) and the ones the integrations provide (sun.is_up, light.is_on, select.is_option_selected, climate.is_heating, moon.is_phase, ...) with their target, behavior and for options. The legacy sun and zone conditions, which the builder no longer offers but which remain valid in hand written YAML, are supported too.

Two of them are approximated, since the browser has neither the recorder nor the astral library Home Assistant uses:

  • for: is measured from the last state change, without replaying the history, so a condition that was already true beforehand can read as shorter.
  • the legacy sun condition derives today's sunrise and sunset from the next ones published by sun.sun. It is exact until the event has passed, then off by the few minutes the event drifts in a day.

A condition type Home Assistant adds after a Bubble Card release reads as false and logs a warning in the browser console naming the type, so a condition that never matches is never silent.

No additional selector options.

Entity selector

- name: target_entity
  label: "Target Entity"
  selector:
    entity:
      filter:
        domain: light
Options
OptionTypeDescription
filter.domainstring | string[]Filter by entity domain(s)
filter.device_classstring | string[]Filter by device class(es)
filter.integrationstringFilter by integration
filter.supported_featuresnumber | number[]Filter by supported features flags
include_entitiesstring[]List of entities to include
exclude_entitiesstring[]List of entities to exclude
multiplebooleanAllow multiple selection

Device selector

- name: device
  label: "Device"
  selector:
    device:
      filter:
        integration: zwave
Options
OptionTypeDescription
filter.integrationstring | string[]Filter by integration(s)
filter.manufacturerstring | string[]Filter by manufacturer(s)
filter.modelstring | string[]Filter by model(s)
entity.domainstring | string[]Filter by entity domain(s)
entity.device_classstring | string[]Filter by entity device class(es)
multiplebooleanAllow multiple selection

Area selector

- name: area
  label: "Area"
  selector:
    area: {}
Options
OptionTypeDescription
entity.domainstring | string[]Filter by entities in area with domain(s)
entity.device_classstring | string[]Filter by entities in area with device class(es)
device.integrationstring | string[]Filter by devices in area with integration(s)
device.manufacturerstring | string[]Filter by devices in area with manufacturer(s)
device.modelstring | string[]Filter by devices in area with model(s)
multiplebooleanAllow multiple selection

Theme selector

- name: card_theme
  label: "Card Theme"
  selector:
    theme: {}
Options
OptionTypeDescription
include_defaultbooleanInclude the default theme

Action selector

- name: tap_action
  label: "Tap Action"
  selector:
    action: {}
Options
OptionTypeDescription
actionsstring[]List of allowed actions (e.g., ["more-info", "toggle", "call-service", "navigate", "url", "none"])

Time selector

- name: start_time
  label: "Start Time"
  selector:
    time: {}

No additional option for this selector.

Date selector

- name: event_date
  label: "Event Date"
  selector:
    date: {}
Options
OptionTypeDescription
minstringMinimum date in ISO format (YYYY-MM-DD)
maxstringMaximum date in ISO format (YYYY-MM-DD)

Datetime selector

- name: event_datetime
  label: "Event Date and Time"
  selector:
    datetime: {}
Options
OptionTypeDescription
minstringMinimum datetime in ISO format
maxstringMaximum datetime in ISO format

Media selector

- name: media
  label: "Media"
  selector:
    media: {}
Options
OptionTypeDescription
filter_media_sourcebooleanFilter media sources
filter_local_mediabooleanFilter local media

Attribute selector

This selector works only if combined to an entity selector at the same level. Inside an object selector item, if the entity field next to it is empty the attribute list falls back to the card's configured entity, matching the common runtime pattern where items inherit the card entity.

- name: attribute
  label: "Attribute"
  selector:
    attribute: {}
Options
OptionTypeDescription
entity_idstringRequired: Entity ID to select attribute from
hide_attributesstring[]List of attributes to exclude

State selector

This selector works only if combined to an entity selector at the same level.

- name: target_state
  label: "Target State"
  selector:
    state: {}
Options
OptionTypeDescription
entity_idstringRequired: Entity ID to select state from
attributestringSelect from entity attribute rather than state

Target selector

- name: target
  label: "Target"
  selector:
    target:
      entity:
        domain: light
Options
OptionTypeDescription
entityobjectEntity filters (same as entity selector)
deviceobjectDevice filters (same as device selector)
areaobjectArea filters (same as area selector)

Config entry selector

- name: config_entry
  label: "Integration"
  selector:
    config_entry:
      domain: zwave_js
Options
OptionTypeDescription
domainstringFilter by domain

Addon selector

- name: addon
  label: "Add-on"
  selector:
    addon: {}
Options
OptionTypeDescription
namestringFilter by add-on name

Location selector

- name: location
  label: "Location"
  selector:
    location:
      radius: true
      icon: "mdi:home"
Options
OptionTypeDescription
radiusbooleanAllow setting a radius around the location
iconstringIcon to show on the map

Object selector

The object selector lets users enter structured objects defined by a set of sub-fields. Each sub-field uses its own selector (e.g., text, number, icon). It can capture a single object or a list when multiple is true. Use label_field and description_field to control the label and secondary text displayed for each item. The output is an object or a list of objects.

When multiple is true, each item row has a drag handle to reorder the list and a duplicate button that inserts a deep copy of the item right below it.

- name: main_item
  label: "Main item"
  selector:
    object:
      fields:
        name:
          label: "Name"
          selector:
            text: {}
        icon:
          label: "Icon"
          selector:
            icon: {}
      label_field: name
      description_field: icon
      multiple: true

Fields that share a group are rendered together inside a collapsible section (the group name becomes the section title, group_icon its optional icon). The stored configuration stays flat, grouping only affects the editor UI, so adding groups to an existing module is fully backward compatible. Fields without a group render at the top level as usual:

- name: items
  label: "Items"
  selector:
    object:
      fields:
        name:
          label: "Name"
          selector:
            text: {}
        background_color:
          label: "Background color"
          group: "Appearance"
          group_icon: "mdi:palette"
          selector:
            ui_color: {}
        text_color:
          label: "Text color"
          group: "Appearance"
          selector:
            ui_color: {}
      multiple: true
Options
OptionTypeDescription
fieldsobjectMap of field keys to field schemas. Each field supports a label, a description, a nested selector (any selector type), and an optional group. Keys starting with __ or bc_group_ are reserved for the editor's internal UI state.
fields.*.groupstringRenders the field inside a collapsible section with this title. Fields sharing the same group end up in the same section. UI-only: the stored value stays flat.
fields.*.group_iconstringOptional icon (Material Design Icons) for the field's group section.
fields.*.visible_ifstringJS expression evaluated against the item's current data as item, the live hass object and the card's config as card (e.g. item.target === 'card'). hass and card may be undefined (early renders, dialogs the card config can't be found from), so guard them (card && card.entity). The field only renders while the expression is truthy; sections whose fields are all hidden disappear. Re-evaluated live on every change. Broken expressions fail open (field stays visible).
fields.*.warn_ifstringJS expression with the same item / hass / card arguments as visible_if, including the same need to guard hass and card. While truthy, the field shows warn_text, for example to warn about a missing entity (item.entity && hass && !hass.states[item.entity]) or an option that has no effect without another one. Broken expressions fail silent (no warning).
fields.*.warn_textstringThe warning message displayed while warn_if is truthy, rendered as an amber warning alert above the field.
fields.*.defaultanyFor text-based sub-fields, the declared default is shown as the input's placeholder so users can see what applies when the field is left empty. Single-select dropdowns render the default as the selected value while the key is unset (display only, picking the default or clearing the dropdown stores nothing, so unset and default stay the same config).
fields.*.variant_ofstringMarks this field as an alternative representation ("variant") of the named base field, for example a state→color map or a JS expression next to a static color. The form collapses the family into one mode dropdown (the base mode, labelled Static unless the base field sets its own variant, plus each variant's variant label) and only the active variant's input. The dropdown is UI-only: stored values keep their original keys, and opening an existing config selects whichever variant already has data. If an inactive variant also has data, a helper line warns that the module's priority rules decide which wins. The family renders inside a light visual cluster (thin left rail), so the mode dropdown and its value field read as one unit.
fields.*.variantstringDisplay label of this variant in the family's mode dropdown (e.g. "State map", "JS"). Defaults to the field key. On the base field it instead renames the base mode's dropdown label (default "Static"), for example variant: Single next to a "Multiple" variant.
fields.*.cluster_ofstringRenders this field inside the named base field's visual cluster (the same thin-left-rail box variant families use) instead of as a standalone row, purely visual grouping for fields that form one logical unit, e.g. a mode select plus its parameters. Rule of thumb: variants swap which key is stored, clusters only group real fields visually. Nothing is synthetic or swapped: every member is a real stored field and keeps its own visible_if. A hidden base hides the whole cluster; while no member is visible the base renders flat (no rail). Composes with variants, members append after the variant rows.
label_fieldstringProperty key used as the item label in the UI (useful when multiple is true).
description_fieldstring | listProperty key used as an optional item description in the UI. A list of keys falls back to the first one with a value; list values render comma-separated.
multiplebooleanAllow entering a list of objects. If true, the resulting value is a list.

Backup selector

- name: backup
  label: "Backup"
  selector:
    backup:
      integration: google_assistant
Options
OptionTypeDescription
integrationstringFilter backups by integration

Assistance selector

- name: assistance_pipeline
  label: "Assistance Pipeline"
  selector:
    assistance: {}

No additional option for this selector.

Label selector

- name: labels
  label: "Labels"
  selector:
    label:
      multiple: true
Options
OptionTypeDescription
multiplebooleanAllow multiple selection

Language selector

- name: language
  label: "Language"
  selector:
    language: {}

No additional option for this selector.

Schedule selector

- name: schedule
  label: "Schedule"
  selector:
    schedule: {}

No additional option for this selector.

Template selector

- name: template
  label: "Template"
  selector:
    template: {}

No additional option for this selector.

File selector

- name: config_file
  label: "Configuration File"
  selector:
    file:
      accept: ".yaml,.json"
Options
OptionTypeDescription
acceptstringComma-separated list of acceptable file extensions

QR code selector

- name: qr_data
  label: "QR Code Data"
  selector:
    qr_code: {}

No additional option for this selector.

Conversation agent selector

- name: agent
  label: "Conversation Agent"
  selector:
    conversation_agent: {}

No additional option for this selector.

Duration selector

- name: timeout
  label: "Timeout"
  selector:
    duration:
      enable_day: false
Options
OptionTypeDescription
enable_daybooleanInclude days in the duration selector

Dashboard selector

- name: dashboard
  label: "Dashboard"
  selector:
    dashboard:
      include_dashboards: ["lovelace"]
Options
OptionTypeDescription
include_dashboardsstring[]List of dashboards to include

Floor selector

- name: floor
  label: "Floor"
  selector:
    floor: {}

No additional option for this selector.

Legacy type-based fields

While selector-based fields are recommended, you can also use the legacy type syntax:

String field

- name: title
  label: "Title"
  type: string

Integer field

- name: count
  label: "Count"
  type: integer
  valueMin: 0
  valueMax: 100

Float field

- name: opacity
  label: "Opacity"
  type: float

Boolean field

- name: enabled
  label: "Enabled"
  type: boolean

Select field

- name: mode
  label: "Mode"
  type: select
  options:
    - ["auto", "Automatic"]
    - ["manual", "Manual"]

Multi-select field

- name: features
  label: "Features"
  type: multi_select
  options:
    - ["animations", "Animations"]
    - ["colors", "Custom Colors"]
    - ["icons", "Custom Icons"]

Advanced structure

Grid layout

You can organize fields in a grid layout:

- type: grid
  name: appearance
  schema:
    - name: color
      label: "Color"
      selector:
        select:
          options:
            - label: "Red"
              value: "red"
            - label: "Blue"
              value: "blue"
    - name: size
      label: "Size"
      selector:
        number:
          min: 10
          max: 100
Options
OptionTypeDescription
column_min_widthstringMinimum column width (CSS value)
schemaarrayFields in the grid

Expandable sections

You can create collapsible sections:

- type: expandable
  name: advanced_settings
  title: "Advanced Settings"
  icon: "mdi:tune"
  expanded: false
  schema:
    - name: animation_speed
      label: "Animation Speed"
      selector:
        number:
          min: 1
          max: 10
Options
OptionTypeDescription
titlestringSection title
iconstringSection icon (Material Design Icons)
expandedbooleanInitially expanded
schemaarrayFields in the section

Entity suggestions

Home Assistant (2026.6+) suggests cards when a user picks an entity in the card picker, and Bubble Card answers with a set of built-in tile recipes. Your module can join that list by declaring a suggestions: key next to name, code and editor. Suggested cards appear in the Community section of the picker, rendered as live previews.

my_module:
  name: My Module
  version: "1.0"
  supported:
    - button
  suggestions:
    # Variant 1: twin every built-in suggestion, with your module applied.
    - extends: native

    # Variant 2: standalone suggestion, fully authored by the module.
    - label: Weather + forecast
      domains: [weather]
      config:
        card_type: button
        button_type: state
        entity: ${entity}
        weather_forecast:
          card_layout: background_only

Each entry of suggestions: accepts:

OptionTypeDescription
extendsstringnative clones every built-in tile suggestion offered for the picked entity (the classic dedicated card, Button and Slider shortcuts are not cloned), adds your module to its modules list, and applies the optional config patch on top (shallow merge, key by key). base does the same but clones only the first tile: the right pick when a module offers one entry per layout
configobjectStandalone card configuration, or the patch applied over each clone when extends is set. ${entity} anywhere in a string is replaced by the picked entity id, in both forms, which is how a patch points your module's own options at the entity being suggested. Standalone configurations must include card_type, and their entity/modules are filled in automatically when missing
domainslistOnly offer the suggestion for these entity domains (light, switch, ...). Without it the rule applies to every entity that has suggestions
conditionstringJavaScript expression evaluated with hass, entity (the id), state, attributes, stateObj and domain in scope. The suggestion is skipped when it is falsy or throws
labelstringVariant text shown after the module name in the picker (e.g. Square card gives "My Module · Square card"). Without it the entry is labeled with the module name alone

Good to know:

  • The picker asks synchronously, so suggestions are read from the module registry once a dashboard has rendered (or from the Bubble Card Tools cache). On a brand new browser profile only the built-in suggestions show up until then.
  • supported: is honored: a clone or a standalone configuration whose card_type your module does not support is dropped automatically.
  • The whole list (built-in + modules) is deduplicated, and each module is limited to 24 suggestions per entity, its declarative rules and its code hook counted together. There is no global cap: every installed module keeps its own share of the picker, instead of the last ones being silently truncated. Keep your rules focused with domains and condition anyway, nobody scrolls through 24 previews.
  • A rule that throws is ignored and logged once, it never breaks the picker.

Computed suggestions

A suggestions: rule is declarative, so it can only describe a configuration known in advance. When the configuration has to be computed (a room pop-up built from every entity of the area the picked entity belongs to, for instance), declare a suggestions_code: string instead. Both keys can live on the same module, the declarative rules are evaluated first.

bubble_popup_suggestions:
  name: Bubble Pop-up Suggestions
  version: "1.0"
  supported:
    - pop-up
  suggestions_code: |-
    const area = helpers.areaOf(entity);
    if (!area) return null;

    const ids = helpers.areaEntities(area, { domains: ['light', 'switch', 'cover'] });
    if (ids.length < 2) return null;

    return {
      label: helpers.areaName(area),
      config: {
        card_type: 'pop-up',
        hash: '#' + area,
        name: helpers.areaName(area),
        icon: 'mdi:sofa-outline',
        cards: ids.map((id) => ({
          type: 'custom:bubble-card',
          card_type: 'button',
          entity: id,
          name: helpers.friendlyName(id),
        })),
      },
    };

The string is a function body, compiled once with Function(...) and cached. It runs in the same sandbox as a condition: expression: plain function scope, no access to the card, no this. It must be synchronous, the picker asks synchronously.

ArgumentDescription
hassThe live Home Assistant object
entityThe picked entity id, a string (light.salon)
stateObjhass.states[entity], guaranteed to exist
helpersThe helper API described below
moduleYour own module object, as parsed from its YAML (name, version, your own keys), plus module.id, the id your module is registered under

Return an array of { label, config } entries, a single entry, or null / undefined when the module has nothing to offer for that entity. Every entry is then normalized:

  • The entry is dropped unless config is an object carrying a truthy card_type.
  • config.type is forced to custom:bubble-card.
  • supported: and unsupported: are honored, exactly like a declarative rule.
  • label is composed as <module name> · <label>, so your entries read as one family in the picker. Without a label, the entry is named after your module alone.
  • Nothing else is touched. No entity is filled in, no ${entity} substitution happens, and your module is not added to modules:. A code hook returns a fully authored configuration: a generated card is standalone and has to keep working after your module is uninstalled, so making its output silently depend on the generator would be wrong. A styling module that does want itself applied simply returns modules: [module.id] in the configuration it builds.
  • Only the top level of config is normalized. Nested cards: are authored content and are left byte for byte as you returned them.

Helpers

Everything is synchronous, and every helper answers with an empty result instead of throwing when hass.entities, hass.devices or hass.areas are not loaded yet, which happens for a moment after a hard reload.

HelperReturns
t(key, params)Translated string for a Bubble Card translation key, in the user's language. params fills the {placeholders} of the value, e.g. t('editor.card_picker.suggestions.controls', { area: 'Kitchen' }). Unknown keys come back as the key itself
domainOf(entityId)'light' for 'light.salon', an empty string when there is no domain
friendlyName(entityId)attributes.friendly_name, falling back to a readable object id (light.living_room_lamp gives living room lamp), like Home Assistant does
hasModule(moduleId)Whether another module is installed. Use it to offer a variant built on someone else's module and stay silent otherwise: listing a module that is not installed is harmless at render time, but offering the variant is not, since the user would pick it and get a card identical to the plain one
areaOf(entityId)The area id of an entity, resolved from its registry entry first, then from its device. null when it has no area, and also null for an area that no longer exists, so a deleted or stale id never leaks into a generated title
areaName(areaId)The display name of an area, falling back to the id
areas()[{ area_id, name, icon }] for every area, sorted by name. icon is null when the area has none
areaEntities(areaId, options)The entity ids assigned to that area, directly or through their device, sorted by friendly name. Entities missing from hass.states are skipped. options.domains filters by domain ({ domains: ['light'] }), options.includeHidden (default false) keeps registry-hidden entities, options.includeDiagnostic (default false) keeps the diagnostic and config entity categories
nativeSuggestions(entityId)The built-in Bubble Card recipes for any entity, as a fresh copy you can freely reshape. Entries are { label, config }, plus an internal classic: true marker on the legacy shortcuts (the dedicated card, plain Button and Slider entries) so you can filter them out with .filter((s) => !s.classic)

Good to know:

  • Write the value as a YAML block scalar (suggestions_code: |-). A plain scalar loses everything after an unquoted #, and suggestions_code: {a:1} parses as a mapping instead of a string.
  • suggestions_code: goes next to name: in your module, like suggestions:.
  • An error at run time or at compile time is caught and logged once, then your module is skipped. The picker and the other modules are never affected.
  • Keep it cheap. The hook runs every time a user picks an entity in the card picker, for every installed module.

How to optimize your modules

Your code: runs again on every style pass of every card that carries your module, and a pop-up rebuilds all of its cards each time it opens. A module that is cheap on one card can therefore be expensive on a dashboard, and the two sections below are what makes the difference. On one real module they took a pop-up from 7.2 s to 3.7 s cold on a low end iPad.

Do the work only when something changed

A module that touches the DOM is re-executed on every style pass, because re-running it is the only way its side effects happen. A card gets roughly seven passes per pop-up open, so a module that writes a few custom properties writes them seven times per card for an identical result, and every read it makes to compute them is paid seven times too.

hasChanged(label, ...values) answers true the first time and whenever one of the values has moved since, and false in between:

code: |
  ${(() => {
    const rgb = hass.states[entity]?.attributes?.rgb_color;

    // Falls back to the same answer on versions without the helper, see the
    // section above.
    const changed = (label, ...values) => {
      if (typeof hasChanged === 'function') return hasChanged(label, ...values);
      const signature = values.map(v => Array.isArray(v) ? v.join(',') : String(v)).join('|');
      if (this['_gate_' + label] === signature) return false;
      this['_gate_' + label] = signature;
      return true;
    };

    if (!changed('tint', state, rgb)) return;

    // Only reached when the state or the colour actually moved.
    card.style.setProperty('--my-tint', computeTint(state, rgb));
  })()}

What you can rely on:

  • One memory per label per card, so a module can gate several independent pieces of work, and two modules using the same label never collide.
  • Arrays are compared by value, so an rgb triplet works directly, and two colours differing by one unit are two different answers.
  • It never throws, whatever you pass it, and it answers true rather than skipping your work if it cannot remember anything.
  • A rebuilt card starts with an empty memory, so your work runs again.

Two things to keep in mind. Pass everything your work depends on, including values you read from the DOM or the theme, because what is not in the list cannot be noticed. And gate only your own writes. If something outside your module can remove what you wrote without any of your values changing, the gate will not know to write it again.

Measured on one real module, gating the work this way took a pop-up from 7.2 s to 3.7 s cold on a low end iPad, and the per-execution cost of its template from 30.7 ms to 6.8 ms.

Release what your module started

Your code: runs again on every style pass of every card that carries your module, and a pop-up rebuilds all of its cards every time it opens. Anything you start once per card, a timer, an observer, a listener on a shared target, therefore outlives the card that owns it, and a guard like if (!this._timer) does not help, because the state lives on the card element, so each rebuilt card starts from a fresh one.

onTeardown(fn) is called with the function to run when the card goes away:

my_module:
  name: My Module
  code: |
    ${(() => {
      if (!this._myTimer) {
        this._myTimer = setInterval(() => {
          // Fallback for versions without the hook, see the section above.
          if (!this.isConnected) { clearInterval(this._myTimer); this._myTimer = null; return; }
          /* ... */
        }, 5000);
        if (typeof onTeardown === 'function') {
          onTeardown(() => {
            clearInterval(this._myTimer);
            this._myTimer = null;
          });
        }
      }
    })()}

What you can rely on:

  • One registration per module per card. Registering on every pass replaces your own entry rather than stacking one per pass, so you never need to check whether you already registered.
  • The last function you registered wins, and it runs exactly once.
  • Your teardown cannot break anything else. It runs after the card is disconnected, one module's throw is logged and the other modules still get their turn.
  • A card that Home Assistant merely moved in the DOM is torn down too. That is intentional, since your next style pass registers again, so you always end with exactly one live registration.

Registering from a card's own styles: works the same way, with its own slot.

Staying compatible with older versions

Your code: is compiled with a fixed list of names in scope (hass, entity, state, card, and the helpers below). Referencing a name that the installed version does not provide throws, and Bubble Card then skips your whole module, so a card loses all of your styling rather than just that one feature. If you share your module, its users are on many different versions.

typeof is the only way to test an undeclared name without throwing:

code: |
  ${(() => {
    if (typeof onTeardown === 'function') {
      onTeardown(() => clearInterval(this._myTimer));
    }
  })()}
HelperAvailable since
onTeardown3.3.0
hasChanged3.3.0

Two things worth knowing. YAML keys are safe, an older version simply ignores a key it does not know, such as suggestions:, so only names used inside code: need this treatment. And when you write a fallback for older versions, make it do the same thing rather than something different, otherwise you are maintaining two behaviours and only testing one.

Best practices

  1. Keep it simple: Only include fields that users actually need to configure
  2. Use clear labels: Make field labels descriptive but concise
  3. Provide defaults: Set sensible default values where possible
  4. Group related fields: Use grid or expandable sections to organize complex options
  5. Add descriptions: Use the description property to explain complex options
  6. Test your UI: Ensure your form is user-friendly before sharing

Example: Complete module editor schema

editor:
  - name: color_mode
    label: "Color Mode"
    selector:
      select:
        options:
          - label: "Custom Color"
            value: "custom"
          - label: "Theme Color"
            value: "theme"
  - name: custom_color
    label: "Custom Color"
    selector:
      ui_color: {}
  - type: expandable
    title: "Advanced Settings"
    expanded: false
    schema:
      - name: animation
        label: "Animation"
        selector:
          boolean: {}
      - name: animation_speed
        label: "Animation Speed"
        selector:
          number:
            min: 1
            max: 10
            step: 0.1
      - name: opacity
        label: "Opacity"
        selector:
          number:
            min: 0
            max: 100
            unit_of_measurement: "%"

This schema creates a form with a select dropdown for color mode, a color picker, and an expandable section with animation controls. This is the example shown in the screenshot at the top of this documentation.

References

This documentation is based on Home Assistant's form schemas: