If you plan to use your Lit componentss in Vue, you may need to enable attribute reflection on all your properties

Aug 21, 2024

Some strange things are happening to Lit component attributes when they are rendered within Vue. The problem is not present in react, solid, or in framework-less HTML code, the problem is specific to Vue.

I have this simple Lit component that just renders hello text:

import { LitElement } from "lit";
import { html } from "lit/static-html.js";
import { customElement } from "lit/decorators.js";

@customElement("simple-component")
export class SimpleComponent extends LitElement {
  override render() {
    return html`<div>Hello from component</div>`;
  }
}

This is Vue the component that uses the Lit component above, I’m adding the attribute test to the custom element in the Vue template:

<script setup lang="ts">
import "simple-component.js";
</script>

<template>
  <simple-component test="works" />
</template>

When I look at the rendered Dom output of my Vue component it looks like this:

<simple-component test="works">
  <div>Hello from component</div>
</simple-component>

Okay, this looks good. There are no problems so far, but let’s see what happens next. If I go back to my Lit component class and create a reactive property with the name test by using @property() decorator:

import { LitElement } from "lit";
import { html } from "lit/static-html.js";
import { customElement, property } from "lit/decorators.js";

@customElement("simple-component")
export class SimpleComponent extends LitElement {
  @property() test?: string;

  override render() {
    return html` <div>Hello from component</div>`;
  }
}

And the rendered DOM output of my Vue component looks like this:

<simple-component>
  <div>Hello from component</div>
</simple-component>

But what has happened to the attribute test="works"? It seems that Vue has stripped it out after we created a reactive property with the same name inside the Lit component.

But the value was given to the Lit component. The issue is that only the attribute was removed, but the Lit component receives the attribute value and sets it as a value for reactive property inside the Lit component instance. If I adjust the lit component class to render the value of reactive property test inside my render function like this:

import { LitElement } from "lit";
import { html } from "lit/static-html.js";
import { customElement, property } from "lit/decorators.js";

@customElement("simple-component")
export class SimpleComponent extends LitElement {
  @property() test?: string;

  override render() {
    return html`<div>
      Hello from component and the attribute "test" value is: "${this.test}"
    </div>`;
  }
}

And see what gets rendered in DOM:

<simple-component>
  <div>Hello from component and the attribute "test" value is: "works"</div>
</simple-component>

Huh! What a weird behavior if I test the same component inside React, Solid, or framework-less way the rendered output would look like this with attribute present on our custom element

<!-- In React, Solid and framework-less -->
<simple-component test="works">
  <div>Hello from component and the attribute "test" value is: "works"</div>
</simple-component>

Also, it doesn’t matter if we add an atttribute with vue v-bind directive or without:

<script setup lang="ts">
import "simple-component.js";
</script>

<template>
  <simple-component test="works" />
  <simple-component :test="'works'" />
</template>

Vue removes attribute in both cases:

<!-- test="works" -->
<simple-component>
  <div>Hello from component</div>
</simple-component>
<!-- :test="'works'" -->
<simple-component>
  <div>Hello from component</div>
</simple-component>

Also I tried to check if the problem is in Vue template compiler or in Vue runtime, I created a render function to render the Lit component instead of using Vue template component:

import { h } from "vue";

export default {
  setup() {
    return () => h("simple-component", { test: "works" }, "");
  },
};

And import the render function in my App.vue component:

<script setup lang="ts">
import "./simple-component.js";
import RenderFunctionComponent from "./render-function-component.js";
</script>

<template>
  <RenderFunctionComponent />
</template>

But the problem persists the attribute is stripped by Vue runtime:

<simple-component>
  <div>Hello from component</div>
</simple-component>

Why this could be a problem?

Because Vue still passes the value of the attribute to the Lit component we are not in danger for the component to break when used in Vue. But the problem comes if you are using attributes on custom elements to set CSS styles like this:

import { LitElement, css } from "lit";
import { html } from "lit/static-html.js";
import { customElement, property } from "lit/decorators.js";

@customElement("simple-component")
export class SimpleComponent extends LitElement {
  @property() test?: string;

  override render() {
    return html`<div>
      Hello from component and the attribute "test" value is: "${this.test}"
    </div>`;
  }

  static override styles = css`
    :host {
      color: blue;
    }

    :host([test]) {
      color: red;
    }
  `;
}

There is a pattern in web component design where you use attributes present on the custom element to style the web components and I do that quite a lot in my Lit components, this allows me to create a very fine-grained way for styling components from outside by the component consumer with CSS variables.

Because attributes on custom elements can represent the component’s internal state the consumer of the component has the optionality to change the styling depending on the attributes present on the custom element.

So if you are planning to use such a design it will not work when the Lit components are being rendered inside Vue.

But there is a solution:

Set your reactive property to { reflect: true }:

import { LitElement } from "lit";
import { html } from "lit/static-html.js";
import { customElement, property } from "lit/decorators.js";

@customElement("simple-component")
export class SimpleComponent extends LitElement {
  @property({ reflect: true }) test?: string;

  override render() {
    return html` <div>Hello from component</div>`;
  }
}

And now we have our attribute back:

<simple-component test="works">
  <div>Hello from component</div>
</simple-component>

I guess it makes sense because when Vue removes the attribute from the DOM node of the custom element but still sets the reactive property inside the component Lit reacts to that change of reactive property and because we have enabled attribute reflection it adds the attribute back again to the custom element. I have written an article about attribute reflection in Lit components that goes deep inside what it is and how it works.

Unfortunately, I wasn’t smart enough to understand what is Vue doing behind the scenes for it to decide to take attributes out when our Lit component has declared reactive property with the same name. I suspect it is because of how Vue treats not all attributes as generic DOM node attributes inside the final render function tree after Vue component templates are compiled. Because some attributes become component properties when the attributes are added to the other Vue components. This may be something to look into in the future.