← Blog
25 Jul 2026custom elements htmlweb componentsshadow domhtml guidevue ui

Custom Elements HTML: A Practical Developer's Guide

Learn how custom elements HTML works — lifecycle callbacks, Shadow DOM, attributes vs properties, accessibility, and Vue integration patterns explained clearly.

Custom Elements HTML: A Practical Developer's Guide

If you’ve ever tried to ship the same button, dropdown, or command palette across two codebases and ended up rewriting the whole thing twice, you already know why custom elements HTML matters. The browser gives you a standards-based way to define your own tags, keep their behavior close to the markup, and reuse that work across frameworks instead of locking it to one component system. That makes custom elements the right place to think about when you want a headless primitive layer first, then a Vue wrapper that turns those primitives into reactive components later.

Table of Contents

Why Custom Elements Exist and What They Solve

A lot of teams start the same way. Someone builds a date picker with a jQuery plugin, another team wraps a similar picker in a framework-specific component, and a third team clones the markup for a dashboard card because nothing portable exists. The result is predictable, the same interaction gets implemented three times, and none of the code travels cleanly between projects.

That mess is exactly what the Web Components vision tried to clean up. The original model grouped Custom Elements, Shadow DOM, HTML Templates, and HTML Imports as primitives for reusable widgets, and the standards work later formalized custom elements as real DOM elements with author-supplied constructors and prototypes in the WHATWG HTML spec. The spec also makes the naming rule explicit, custom element names must contain a hyphen, which is how the parser distinguishes them from built-in tags and future native elements. See the standards definition in the WHATWG HTML custom elements specification.

A diagram comparing fragmented web development solutions to standardized custom elements for building portable, future-proof UI widgets.

What changes when the browser owns the tag

The practical shift is simple. Instead of shipping a post-processing script that finds plain tags and patches them after render, the browser can parse and construct your element directly, with the behavior already attached. That matters in large UI systems because it lets a component library publish one primitive that can work in plain HTML, inside Vue, or behind another framework wrapper without changing the underlying contract.

Practical rule: if the interaction needs to survive framework churn, the lowest-risk place to put it is the browser’s own element model.

The standards angle also helps you separate “widget shape” from “framework shape.” A headless component library can expose the tag itself as the stable primitive, then let each consumer layer add its preferred rendering or state handling on top.

If you want a wider primer on the bigger Web Components picture, the Web Component overview from DOM Studio is a useful companion read.

And if you need a quick example of how reusable front-end primitives show up in another medium, browse the AI video definition to see how a technical concept is framed for a different audience. The point isn’t video, it’s the pattern of making a building block portable enough to explain and reuse.

Defining and Registering Your First Custom Element

The smallest useful custom element usually starts with HTMLElement, a class, and a registration call. The browser needs that registration so it can upgrade matching tags in markup and create instances from JavaScript with document.createElement(). MDN’s custom elements guide walks through the basic registration model and explains that you can extend HTMLElement or existing interfaces, then register the class with customElements.define() so it can be used in normal markup and imperative DOM code, as documented in MDN’s custom elements guide.

A minimal autonomous element

<user-card name="Ada"></user-card>

<script>
  class UserCard extends HTMLElement {
    static observedAttributes = ['name']

    connectedCallback() {
      this.textContent = `Hello, ${this.getAttribute('name') || 'guest'}`
    }

    attributeChangedCallback(name, oldValue, newValue) {
      if (name === 'name' && oldValue !== newValue) {
        this.textContent = `Hello, ${newValue || 'guest'}`
      }
    }
  }

  customElements.define('user-card', UserCard)
</script>

That example is intentionally small, but the pieces are the same in production. The class extends HTMLElement, the tag name includes a hyphen, and the browser can upgrade <user-card> wherever it appears. If you create the element imperatively, document.createElement('user-card') yields the same registered type once the definition exists.

Naming rule: the hyphen isn’t a style preference. It’s the boundary that keeps your tag distinguishable from built-in HTML and from future platform additions.

A quick naming check

Tag Name Valid Reason
user-card Yes Contains a hyphen, so the browser treats it as a custom element name.
card No No hyphen, so it conflicts with the naming rule for custom elements.
my-dropdown Yes Contains a hyphen and follows the custom element naming constraint.
button No Built-in tag names are not custom element names.

Place the define() call early in your bundle path, or load the module before the parser reaches the tag in server-rendered markup. That avoids the common “why didn’t my component upgrade?” moment, which usually comes down to definition timing rather than broken code.

For a tighter spec reference, the component spec note from DOM Studio is handy when you want to compare browser behavior against your own API design.

Lifecycle Callbacks and When Each Fires

Custom elements have a lifecycle that maps cleanly to DOM reality. The browser tells you when the element connects, disconnects, changes attributes, or moves between documents, and that’s a lot better than trying to infer state from random observers and mount hooks. The important part is to use the callback that matches the job, not to cram every side effect into connectedCallback().

Connect, disconnect, and react to attributes

connectedCallback() is for setup that needs a live DOM node, like wiring events or starting subscriptions. disconnectedCallback() is for cleanup, and it matters more than people think in single-page apps, where a component can be created and destroyed many times without a full refresh. attributeChangedCallback() is the right place to react when markup changes should update internal state, while adoptedCallback() covers the rarer case where the node moves across documents.

A common production pattern is a component that subscribes to a global event bus on connect and unsubscribes on disconnect. That keeps long-lived apps from leaking handlers after route changes or list re-renders.

A clean lifecycle mental model

  • connectedCallback(). Attach listeners, read initial attributes, measure layout if you need to.
  • attributeChangedCallback(). Reflect a changed attribute into the element’s internal state or render.
  • disconnectedCallback(). Remove listeners, cancel timers, and tear down observers.
  • adoptedCallback(). Handle document moves if your app uses them, which most won’t.

A diagram illustrating the lifecycle callbacks of a custom HTML element throughout its DOM interactions.

The observedAttributes list and the static get observedAttributes() pattern work together by telling the browser which attribute names should trigger attributeChangedCallback(). If you skip that list, the callback won’t fire for those attributes, which is usually why a “reactive” element seems dead in markup but alive in code.

One good habit is to keep side effects out of the constructor unless you’re only initializing fields. The browser hasn’t connected the element yet, so anything that depends on the live DOM belongs in connectedCallback() instead.

Attributes, Properties, and Slots Working Together

Most custom element bugs come from mixing up attributes and properties. Attributes are strings in markup, which makes them good for declarative defaults and static configuration. Properties live on the JavaScript object, so they can carry arrays, booleans, objects, and other typed values that frameworks like Vue or React often pass in more naturally.

Design one API, not two separate ones

A good public API usually reflects the same value both ways. The attribute gives you readable HTML, and the property lets code update the element without serializing everything back into strings. If you only support one side, consumers will end up fighting the component instead of using it.

class ToggleChip extends HTMLElement {
  static observedAttributes = ['checked']

  get checked() {
    return this.hasAttribute('checked')
  }

  set checked(value) {
    if (value) {
      this.setAttribute('checked', '')
    } else {
      this.removeAttribute('checked')
    }
  }

  attributeChangedCallback(name) {
    if (name === 'checked') {
      this.render()
    }
  }

  render() {
    this.textContent = this.checked ? 'On' : 'Off'
  }
}

That reflection pattern is boring in the best way. HTML authors can write <toggle-chip checked></toggle-chip>, while JavaScript can do el.checked = true, and both reach the same state.

Slots are about composition, not decoration

Slots let the light DOM feed content into the component without the component needing to know every child ahead of time. A default slot handles unnamed children, named slots handle multiple regions, and both are the clean way to let callers provide labels, icons, or rich content while the element owns behavior. That’s especially useful when the primitive needs to stay framework-agnostic.

The serialization trap shows up when people try to stuff complex data into attributes. Arrays and objects turn into strings, then everyone spends time parsing and re-parsing JSON that should have been a property in the first place.

Rule of thumb: if a consumer would ever want to mutate it in JavaScript, give it a property. If it should be readable in markup, reflect it to an attribute too.

Shadow DOM, Styling, and Slot Composition

Shadow DOM is what turns a custom element from “a div with event handlers” into an encapsulated component. It gives you a scoped subtree, which means outside selectors stop leaking into your internals and your internals don’t accidentally style the rest of the page. For most production components, open shadow roots are the better default because they stay inspectable and debuggable.

Choosing open versus closed

Open Shadow DOM lets developers inspect, test, and reason about the component more easily. Closed Shadow DOM hides the internals from outside scripts, but that usually creates more friction than value for a reusable UI library. If you’re building components other engineers will debug in production, openness wins more often than not.

class PopoverPanel extends HTMLElement {
  connectedCallback() {
    if (!this.shadowRoot) {
      this.attachShadow({ mode: 'open' })
      this.shadowRoot.innerHTML = `
        <style>
          :host {
            display: block;
            border: 1px solid var(--panel-border, #ccc);
            background: var(--panel-bg, white);
          }
        </style>
        <slot></slot>
      `
    }
  }
}

That pattern keeps internal styles local while still exposing a small theming surface. CSS custom properties are usually the cleanest way to expose those hooks because they cross the shadow boundary without breaking encapsulation.

Styling slots without breaking the component

::slotted() targets light DOM children that land in a slot, which is useful when you want to control spacing or typography for projected content. It doesn’t give you full deep styling access, and that’s a feature, not a bug. Once you start trying to punch through every internal layer, you’ve rebuilt global CSS with extra steps.

A simple before-and-after tells the story. Before Shadow DOM, a page-wide selector can accidentally restyle a menu item inside three unrelated widgets. After Shadow DOM, the component decides what’s exposed, and everything else stays private unless you explicitly open a styling hook.

Accessibility, ARIA, and Focus Management You Need to Build

Autonomous custom elements don’t inherit native semantics just because they look like a button or a menu. They’re HTMLElement derivatives until you give them roles, keyboard behavior, and focus management yourself. That’s not a downside, it’s the contract, and teams that treat accessibility as part of the primitive end up with far fewer wrapper fixes later.

Semantics have to be authored

Pick the right ARIA role for the interaction, then make sure the keyboard model matches user expectations. A listbox needs arrow-key navigation, a menu needs focus movement and escape handling, and a dialog needs a clear open-and-close focus strategy. If the component announces state, reflect the relevant aria-* attributes from the same source of truth that drives the UI.

Composite widgets often need roving tabindex, where one item is focusable at a time and arrow keys move the active item through the set. That keeps keyboard use predictable and prevents tab order from exploding when a component contains multiple interactive children.

Focus is part of the component API

A production component should answer a few questions every time. Where does focus go on open, where does it land on close, and what happens if a user only uses the keyboard? If you can’t answer those questions in code, the component isn’t finished yet.

A slide titled Accessibility, ARIA, and Focus Management showing four numbered steps for building accessible web applications.

A useful production pattern is to keep a single focus target inside the component and move it deliberately, rather than letting browser defaults decide. That applies to menus, comboboxes, tabs, and dialogs alike, even though each interaction has its own details.

If your library gets this right once, every downstream team inherits that work. If it gets it wrong, every consumer ends up patching the same keyboard and screen reader problems in different ways.

Custom Elements With Vue and Other Frameworks

Custom elements are framework-agnostic by design, which is why they work so well as the lowest layer of a headless component library. Vue treats tags with a hyphen as custom elements unless a Vue component of the same name is registered, so the browser-native tag can stay the stable primitive while a wrapper adds framework-specific convenience. That makes it easy to keep one source of behavior and avoid a split-brain implementation.

Raw primitive first, wrapper second

A raw custom element is the right choice when the consumer wants the smallest possible dependency surface. A Vue wrapper makes sense when the team needs reactive props, v-model, or slot forwarding that feels native to the Vue app without rewriting the underlying behavior. The wrapper should translate framework conventions, not duplicate the component’s keyboard handling or ARIA logic.

That separation is the pattern DOM Studio uses in its headless layer. A tag like <dom-dropdown> can hold the standards-based behavior, while the Vue wrapper exposes reactivity and slots without making the primitive know anything about Vue itself.

If you want a product-focused example of a component built around this layering model, the command palette guide from DOM Studio is a useful reference point.

What the wrapper should and shouldn’t do

  • Should do: map props to element properties, forward events, support v-model, and pass slot content through.
  • Shouldn’t do: re-implement ARIA patterns, keyboard logic, or internal state machines.
  • Should do: adapt Vue reactivity to the native element API.
  • Shouldn’t do: become a second copy of the primitive with slightly different behavior.

If you’re looking at deployment patterns around framework-adjacent UI and AI-assisted tooling, the SupportGPT AI assistant deployment guide is a good example of how teams think about integration layers without replacing the underlying system. The same mental model applies here, the wrapper helps adoption, the primitive owns behavior.

The easiest way to avoid the dual-implementation trap is to let the custom element own the truth and let Vue mirror it. When the wrapper starts making product decisions, you’ve already gone too far.

Testing, Debugging, and Performance Checklist

Before a custom element ships, it needs the same discipline you’d give any other production component, plus a few checks that are specific to browser-native behavior. Unit tests catch state and attribute logic, browser tests catch real rendering and interaction details, and DevTools helps you inspect shadow trees that otherwise feel opaque. For testing setups, @open-wc/testing is a common choice for web components, while Playwright is a strong fit when you need full-browser interaction coverage.

What to verify before release

  • Unit behavior: confirm attribute reflection, property setters, and lifecycle reactions with @open-wc/testing or similar harnesses.
  • Browser integration: run Playwright against the component in Chrome or another target browser, not just a simulated DOM.
  • Shadow DOM inspection: use Chrome DevTools to expand shadow roots, inspect slots, and verify focus behavior.
  • Performance shape: keep primitives small, load heavy logic lazily, and avoid doing expensive work until the element is needed.

A checklist for testing, debugging, and optimizing web component performance using developer tools and frameworks.

A good migration path from a framework-only library is to identify which parts are visual, then move the interaction and semantics into the custom element layer first. After that, wrappers can become thin adapters instead of full rewrites. That keeps the codebase from splitting into “legacy framework components” and “new native components” that drift apart over time.

The biggest production mistakes are usually predictable. Teams forget cleanup in disconnectedCallback(), overuse attributes for typed data, skip keyboard support because the mouse demo works, or bury define() calls so the parser sees unknown tags too early. If you audit those four areas before release, you catch most of the pain while it’s still easy to fix.


If you’re building a UI system right now, start by choosing one primitive that’s worth standardizing, then make it work as a real custom element before you wrap it in Vue or any other framework. If you want a production-minded reference point for that approach, explore DOM Studio and use it as a model for how headless primitives, accessible behavior, and thin framework layers can fit together cleanly.