How to create a custom checkbox web component?

The HTMLElement.attachInternals() method returns an ElementInternals object. This method allows a custom element to participate in HTML forms. The ElementInternals interface provides utilities for working with these elements in same way you would work with any standard HTML form element, and also exposes the Accessibility Object Model to the element.

Check the Browser compatibility table carefully before using this in production.

Syntax

./components/CustomCheckbox.js
export default class CustomCheckbox extends HTMLElement {
  static formAssociated = true; // this is needed
  #internals;

  constructor() {
    super();
    this.#internals = this.attachInternals();
  }

  connectedCallback() {
    console.log(this.#internals); // ElementInternals object
  }
}

customElements.define('custom-checkbox', CustomCheckbox);

We need to set a static property formAssociated to be true to tell browser engine that our custom checkbox is a form-associated element. More on this property later.

Now we’ve defined our custom checkbox element, we can use it inside a form element.

index.html
<form method="POST" name="myForm">
  <custom-checkbox></custom-checkbox>
</form>

ElementInternals.shadowRoot

The shadowRoot read-only property of the ElementInternals interface returns the ShadowRoot for this element, or null if the custom element does not have a shadow root.

Now if we add a shadow root to our custom element.

./components/CustomCheckbox.js
export default class CustomCheckbox extends HTMLElement {
  static formAssociated = true; // this is needed
  #internals;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.#internals = this.attachInternals();
  }

  connectedCallback() {
    console.log(this.#internals); // ElementInternals object
    console.log(checkbox.#internals.shadowRoot); // a shadowRoot object
  }
}

customElements.define('custom-checkbox', CustomCheckbox);

ElementInternals.form

The form read-only property of the ElementInternals interface returns the HTMLFormElement associated with this element.

index.html
<form method="POST" name="myForm">
  <custom-checkbox></custom-checkbox>
</form>
./components/CustomCheckbox.js
export default class CustomCheckbox extends HTMLElement {
  static formAssociated = true; // this is needed
  #internals;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.#internals = this.attachInternals();
  }

  connectedCallback() {
    console.log(this.#internals); // ElementInternals object
    console.log(checkbox.#internals.shadowRoot); // a shadowRoot object
    console.log(checkbox.#internals.form); // <form method="POST" name="myForm"> element
    console.log(checkbox.#internals.form instanceof HTMLFormElement) // true
    console.log(checkbox.#internals.form.name) // myForm
    console.log(checkbox.#internals.form.length) // 1
  }
}

customElements.define('custom-checkbox', CustomCheckbox);

ElementInternals.states

The states read-only property of the ElementInternals interface returns a CustomStateSet representing the possible states of the custom element.

A CustomStateSet which is a Set of strings.

Now we can update our CustomCheckbox class.

./components/CustomCheckbox.js
export default class CustomCheckbox extends HTMLElement {
  static formAssociated = true; // this is needed
  #internals;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.#internals = this.attachInternals();
  }

  get checked() {
    return this.#internals.states.has('--checked');
  }

  set checked(flag) {
    if (flag) {
      this.#internals.states.add('--checked');
    } else {
      this.#internals.states.delete('--checked');
    }

    console.log(this.#internals.states.has('--checked'));
  }
}

customElements.define('custom-checkbox', CustomCheckbox);

To set the checked state of our custom checkbox to true, we can add the checked attribute. Additionally, we can add a click event listener to toggle the checked state of the checkbox.

index.html
<form method="POST" name="myForm">
  <custom-checkbox checked></custom-checkbox>
</form>
./components/CustomCheckbox.js
export default class CustomCheckbox extends HTMLElement {
  static formAssociated = true; // this is needed
  #internals;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.#internals = this.attachInternals();
  }

  get checked() {
    return this.#internals.states.has('--checked');
  }

  set checked(flag) {
    if (flag) {
      this.#internals.states.add('--checked');
    } else {
      this.#internals.states.delete('--checked');
    }

    console.log(this.#internals.states.has('--checked'));
  }

  connectedCallback() {
    this.checked = this.hasAttribute('checked');
    this.addEventListener('click', () => this.checked = !this.checked);
  }
}

customElements.define('custom-checkbox', CustomCheckbox);

Here’s how to style the custom checkbox outside of its shadow root.

index.css
custom-checkbox {
  /* unchecked styles */
}

custom-checkbox:--checked {
  /* checked styles */
}

Here’s how to style the custom checkbox inside of its shadow root.

index.css
:host {
  /* unchecked styles */
}

:host(:--checked) {
  /* checked styles */
}

ElementInternals.setFormValue()

To complete the form submission process, we need to include a checkbox value that can be sent to FormData(). This can be achieved by using the setFormValue() method of the ElementInternals interface. This method sets the submission value and state of the element, which is then communicated to the user agent.

./components/CustomCheckbox.js
export default class CustomCheckbox extends HTMLElement {
  static formAssociated = true; // this is needed
  #internals;

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.#internals = this.attachInternals();
  }

  get checked() {
    return this.#internals.states.has('--checked');
  }

  set checked(flag) {
    if (flag) {
      this.#internals.states.add('--checked');
      // when checked, we set a form value of 'on'
      this.#internals.setFormValue('on', '--checked');
    } else {
      this.#internals.states.delete('--checked');
      // when unchecked, we set a form value of 'off'
      this.#internals.setFormValue('off');
    }

    console.log(this.#internals.states.has('--checked'));
  }

  connectedCallback() {
    this.checked = this.hasAttribute('checked');
    this.addEventListener('click', () => this.checked = !this.checked);
  }
}

customElements.define('custom-checkbox', CustomCheckbox);

To test our form, we can include a submit button within it.

index.html
<form method="POST" name="myForm">
  <custom-checkbox checked name="custom-checkbox"></custom-checkbox>
  <button type="submit">Submit</button>
</form>
index.js
document.querySelector('form[name="myForm"]').addEventListener('submit', (e) => {
  e.preventDefault();
  const formData = new FormData(e.target);
  // prints 'on' if checkbox is checked, 'off' if unchecked
  console.log(formData.get('custom-checkbox')); 
});