DEV Community

Cover image for A web component without Shadow DOM: build one with plain JavaScript
Andrzej Stacherski
Andrzej Stacherski

Posted on AI-assisted

A web component without Shadow DOM: build one with plain JavaScript

Build a custom element with plain JavaScript and plain CSS: lifecycle, properties vs attributes, and the two traps that break most first components.

Most web component tutorials open with Shadow DOM, templates and slots. You can skip all of that and still get a useful, reusable component. Here is a card element in vanilla JavaScript. It works with your normal CSS and needs no build step.

The goal is to turn this:

const card = document.createElement('article');
card.className = 'card';
// ...a dozen lines of createElement and append
Enter fullscreen mode Exit fullscreen mode

into this:

const card = document.createElement('ml-card');
card.fact = fact;
card.topic = topic;
feed.append(card);
Enter fullscreen mode Exit fullscreen mode

And in HTML, you can write <ml-card hint></ml-card> as if browsers had always shipped a card tag.

Two details in that tag are worth explaining up front:

  • ml- is a prefix I use on every component. It stands for Microlearn, the name of the app I'm building. The prefix is what keeps my tags from clashing with other people's code or with future HTML elements.
  • hint is a boolean attribute I invented for this card. It switches on a small "Scroll for the next one ↓" line at the bottom of the card. It works like the built-in disabled or checked: if the attribute is present, the hint shows; if it's absent, the hint stays hidden. I put it only on the first card of the feed, so new users learn they can scroll, and the rest of the cards stay clean. We'll see how the component reads it further down.

The smallest custom element

A custom element is a class that extends HTMLElement, registered under a tag name:

class MlCard extends HTMLElement {
  connectedCallback() {
    this.textContent = 'Hello from a card';
  }
}

customElements.define('ml-card', MlCard);
Enter fullscreen mode Exit fullscreen mode

Three things to know:

  • The tag name must contain a hyphen. ml-card works, card does not. The hyphen is how the browser tells your tags from its own, now and in the future. That's why I start every name with ml- (Microlearn).
  • connectedCallback is called by the browser when the element is added to the page.
  • customElements.define registers the tag. Every <ml-card>, existing or added later, becomes an instance of your class.

Build once, render many times

Here is the real component:

class MlCard extends HTMLElement {
  static observedAttributes = ['hint'];

  #fact = null;   // the fact to show: { id, t, f, m }
  #topic = null;  // the topic it belongs to: { label, hue }

  constructor() {
    super();
    this.ready = false;
  }

  connectedCallback() {
    if (!this.ready) this.#build();
    this.#render();
  }

  attributeChangedCallback() {
    if (this.ready) this.#render();
  }

  set fact(value) { this.#fact = value; this.#render(); }
  get fact() { return this.#fact; }
  set topic(value) { this.#topic = value; this.#render(); }

  #build() {
    this.innerHTML = `
      <p class="ml-card__topic"></p>
      <h2 class="ml-card__fact"></h2>
      <p class="ml-card__more"></p>
      <p class="ml-card__hint" hidden>Scroll for the next one ↓</p>`;
    this.ready = true;
  }

  #render() {
    // Guard: stop if the markup isn't built yet, or the data hasn't arrived.
    // This is what makes it safe to call #render at any moment.
    if (!this.ready || !this.#fact || !this.#topic) return;

    // Set a CSS custom property (--h) on the card, so the stylesheet can
    // use the topic's hue for colours (e.g. hsl(var(--h) 70% 50%)).
    this.style.setProperty('--h', this.#topic.hue);

    // Fill in the topic label, e.g. "Space". textContent inserts plain
    // text, never HTML, so the data can't inject markup.
    this.querySelector('.ml-card__topic').textContent = this.#topic.label;

    // Fill in the headline: the fact itself (field "f").
    this.querySelector('.ml-card__fact').textContent = this.#fact.f;

    // Fill in the extra detail under the headline (field "m", for "more").
    this.querySelector('.ml-card__more').textContent = this.#fact.m;

    // Show the hint only if the card has the `hint` attribute.
    // hasAttribute returns true/false; "hidden" is the opposite, hence the !.
    this.querySelector('.ml-card__hint').hidden = !this.hasAttribute('hint');
  }
}

if (window.customElements.get('ml-card') === undefined) {
  window.customElements.define('ml-card', MlCard);
}
Enter fullscreen mode Exit fullscreen mode

The same shape works for any component:

  1. The constructor only sets defaults. Don't read attributes or touch children there. The element may not be in the page yet.
  2. connectedCallback builds the markup once. The ready flag matters: the callback runs again every time the element is moved or reconnected, and without the flag you would build twice.
  3. #render is safe to call at any moment. It returns early if there is nothing to draw yet, and calling it five times gives the same result.
  4. Data is poured in with textContent, never placed inside the innerHTML string. The template is fixed text you wrote, so it is safe.

The # names are private class members. Only code inside the class can use them, which keeps the public surface small: fact, topic and the hint attribute.

What are #fact and #topic?

They are the card's two pieces of data, stored in private fields. Each starts as null ("nothing yet") and is filled in from outside.

  • #fact is one entry from the app's list of facts, an object with four keys:
  { id: 'b0', t: 'space', f: 'A day on Venus is longer than its year.', m: 'Venus turns once every 243 Earth days...' }
Enter fullscreen mode Exit fullscreen mode

id identifies the fact, t is its topic key, f is the headline and m is the longer explanation.

  • #topic describes the topic that fact belongs to. It holds a display name and a colour:
  { label: 'Space', hue: 230 }
Enter fullscreen mode Exit fullscreen mode

The app keeps a lookup table of topics, and the calling code picks the right one with TOPICS[fact.t].

Why two fields and not one? The fact says what to show, and the topic says how it looks (its label and colour). Many facts share one topic, so the topic is stored once and passed in separately.

Why private? Because the outside world never touches the fields directly. It goes through the public setters, card.fact = ... and card.topic = ..., and each setter stores the value and calls #render. That is how the card redraws itself whenever new data arrives. The fact getter exists so other code can ask the card which fact it holds. There is no topic getter because nothing needed one.

Properties or attributes?

Input Use Why
A flag or short text (hint, size="large") Attribute Visible in HTML, usable from a CMS, styleable with [hint]
An object or array Property Attributes can only hold strings

hint is the attribute case. Inside #render, this line does the work:

this.querySelector('.ml-card__hint').hidden = !this.hasAttribute('hint');
Enter fullscreen mode Exit fullscreen mode

It checks whether the hint attribute is present and shows or hides the hint paragraph to match. For the card to redraw when the attribute changes later, list it in static observedAttributes. After that, card.toggleAttribute('hint') updates the card on its own, with no extra JavaScript on your side. In the feed, I use toggleAttribute('hint', index === 0) so only the first card gets it.

Trap 1: the blank card

Look at the order of events in the calling code:

const card = document.createElement('ml-card');
card.fact = fact;         // setter runs, but there is no markup yet
card.topic = topic;       // same
feed.append(card);        // now connectedCallback builds the markup
Enter fullscreen mode Exit fullscreen mode

Both setters run before the card is in the page. The guard on the first line of #render makes them harmless: they store the value and return. When connectedCallback fires, #render picks up what was stored. Without the guard you get blank cards or errors. If a card renders blank, check the order of events first.

Trap 2: the collapsed card

A custom element is display: inline by default, like a <span>, and inline elements ignore height. Forget this and your cards shrink to the height of their text.

ml-card {
  display: flex;
  height: 100dvh;
}
Enter fullscreen mode Exit fullscreen mode

Whenever you register a tag, decide what display it should have and write that rule first.

A related gotcha: define twice

Calling customElements.define twice for the same name throws. In your own page that never happens. In a CMS, where a theme and a module may both load the same script, it happens all the time. The get check before define makes loading the script twice harmless.

Why no Shadow DOM?

An honest trade-off.

What you give up: real style isolation. Another stylesheet could restyle your card by accident. The prefix (ml-card__topic, not .topic) is your only protection, so use it everywhere.

What you gain: your normal CSS, design tokens and dark mode just work. The inspector shows plain HTML. Components drop into a CMS and inherit the surrounding page's styles, which is often exactly what you want.

For an app or design system you control, light DOM is the shorter road. Shadow DOM earns its effort when you ship a widget into pages you don't own.

Try it

Add a compact attribute that makes the headline smaller. You need one entry in observedAttributes and one CSS rule: ml-card[compact] .ml-card__fact. Notice that the attribute does its work through CSS alone.


This component comes from a small book I'm writing, where you build a full "one fact per screen" learning app with vanilla JavaScript and web components, chapter by chapter. Chapter 1 is free to read: One Screen at a Time.

Top comments (0)