Creating custom elements with web components is a powerful way to enhance your web development skills and build modern, reusable components that can be used across different projects and platforms. Whether you’re new to web components or looking to deepen your understanding, mastering custom elements is a crucial step in creating scalable and maintainable web applications.
In this guide, we’ll walk through the process of creating custom elements with web components, exploring the foundational concepts and providing practical examples. By the end of this article, you’ll have the knowledge and confidence to create your own custom elements, improving both your development workflow and the user experience of your applications.
Understanding Custom Elements

Custom elements are the cornerstone of web components. They allow you to define your own HTML tags, complete with unique behavior, styles, and functionality.
Unlike traditional HTML elements, which are predefined by the browser, custom elements are entirely under your control. This flexibility makes them an invaluable tool for modern web development.
The Basics of Custom Elements
At the heart of custom elements is the ability to create new HTML tags. These tags can be used in your HTML just like any built-in element, but with the added benefit of custom functionality that you define. This is achieved through a combination of JavaScript classes and the customElements.define()
method.
To create a custom element, you start by defining a new class that extends the HTMLElement
class. This class will encapsulate all the logic and behavior of your custom element.
Once the class is defined, you register it with the browser using customElements.define()
, specifying both the tag name and the class associated with it.
For example, let’s create a simple custom element called <my-button>
that displays a button with custom behavior:
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
button {
padding: 10px 20px;
background-color: #007BFF;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
</style>
<button>Click Me</button>
`;
this.shadowRoot.querySelector('button').addEventListener('click', () => {
alert('Button clicked!');
});
}
}
customElements.define('my-button', MyButton);
In this example, the MyButton
class extends HTMLElement
, which is the base class for all custom elements. Inside the constructor, we use attachShadow({ mode: 'open' })
to create a Shadow DOM, which encapsulates the component’s structure and style.
The connectedCallback()
method is automatically called when the element is added to the DOM, where we define the button’s HTML and CSS, and add a click event listener.
Using Custom Elements in HTML
Once a custom element is defined and registered, you can use it in your HTML just like any other element:
<my-button></my-button>
This simple line of HTML creates an instance of the <my-button>
element, displaying the button with the defined style and behavior. You can add as many instances of the custom element as needed, and each will behave according to the logic you’ve defined in the JavaScript class.
The Power of Encapsulation
One of the key benefits of custom elements is encapsulation. By using the Shadow DOM, you can isolate the styles and scripts of your custom element from the rest of the document. This means that global styles won’t affect your custom element, and the element’s internal styles won’t leak out into the rest of the page.
Encapsulation is particularly important when building complex applications, as it helps prevent unintended side effects and makes your components more reusable.
For example, if you have a custom element that includes specific styles for buttons, those styles won’t interfere with other buttons on the page, even if they share the same class names.
Custom Elements vs. Standard HTML Elements
While standard HTML elements are sufficient for many tasks, custom elements offer a level of flexibility and control that standard elements simply can’t match. With custom elements, you can create highly specialized components that encapsulate specific functionality, making your code more modular and maintainable.
For instance, a standard <button>
element might be fine for a basic clickable button, but if you need a button that includes custom logic, such as a loading indicator or a click counter, a custom element is the better choice.
By creating a custom <loading-button>
or <counter-button>
element, you can encapsulate all the necessary logic and styles within a single, reusable component.
Custom elements also make it easier to create consistent, reusable UI components across your application. Instead of copying and pasting code or relying on external libraries, you can define your own elements that meet your specific needs, ensuring a consistent look and feel throughout your project.
Creating Advanced Custom Elements

Once you have a grasp of the basics, you can start creating more advanced custom elements that include richer functionality, dynamic content, and interaction with other parts of your application.
By leveraging JavaScript, the Shadow DOM, and other web technologies, you can build components that are not only reusable but also highly adaptable to different contexts.
Handling Attributes and Properties
Custom elements can be customized through attributes and properties, just like standard HTML elements. Attributes are part of the element’s HTML markup, while properties are part of the element’s JavaScript API. Understanding how to use both effectively is crucial for creating flexible and reusable components.
Let’s extend our <my-button>
example by adding a label
attribute that allows users to set the button’s text dynamically:
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
static get observedAttributes() {
return ['label'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'label') {
this.updateLabel(newValue);
}
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
button {
padding: 10px 20px;
background-color: #007BFF;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
</style>
<button></button>
`;
this.updateLabel(this.getAttribute('label') || 'Click Me');
this.shadowRoot.querySelector('button').addEventListener('click', () => {
alert('Button clicked!');
});
}
updateLabel(label) {
this.shadowRoot.querySelector('button').textContent = label;
}
}
customElements.define('my-button', MyButton);
In this updated version, we added a label
attribute that users can set to customize the button’s text. The observedAttributes
static method tells the browser which attributes to watch for changes.
When the label
attribute changes, the attributeChangedCallback
method is triggered, updating the button’s text accordingly.
Using attributes like this allows your custom element to be more flexible and customizable. Developers using your component can easily adjust its appearance or behavior by setting different attributes in the HTML:
<my-button label="Submit"></my-button>
<my-button label="Cancel"></my-button>
Interacting with JavaScript Properties
In addition to attributes, you can define properties in your custom elements that can be accessed and modified via JavaScript. Properties provide a more dynamic way to interact with your elements, allowing you to change their state or behavior programmatically.
Let’s enhance our <my-button>
component by adding a disabled
property that controls whether the button is clickable:
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
button {
padding: 10px 20px;
background-color: #007BFF;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
button[disabled] {
background-color: #CCCCCC;
cursor: not-allowed;
}
</style>
<button></button>
`;
this.updateLabel(this.getAttribute('label') || 'Click Me');
this.shadowRoot.querySelector('button').addEventListener('click', () => {
if (!this.disabled) {
alert('Button clicked!');
}
});
}
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(value) {
if (value) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
updateLabel(label) {
this.shadowRoot.querySelector('button').textContent = label;
}
}
customElements.define('my-button', MyButton);
In this version, we introduced a disabled
property. This property is linked to the disabled
attribute, allowing it to be set or removed through JavaScript. When the disabled
property is true
, the button is styled differently and becomes unclickable.
Now, you can control the button’s disabled state programmatically:
const button = document.querySelector('my-button');
button.disabled = true;
This approach gives you more control over the component’s behavior and allows you to create more interactive and dynamic elements that respond to user actions or other events in your application.
Handling Events and Communication
Custom elements often need to communicate with other parts of the application, either by listening for events or by dispatching their own. Understanding how to handle events effectively is key to creating components that integrate smoothly into your web application.
Let’s add an event to our <my-button>
component that notifies the application whenever the button is clicked:
class MyButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
button {
padding: 10px 20px;
background-color: #007BFF;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
</style>
<button></button>
`;
this.updateLabel(this.getAttribute('label') || 'Click Me');
this.shadowRoot.querySelector('button').addEventListener('click', () => {
if (!this.disabled) {
this.dispatchEvent(new CustomEvent('button-clicked', {
detail: { message: 'Button was clicked!' }
}));
}
});
}
updateLabel(label) {
this.shadowRoot.querySelector('button').textContent = label;
}
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(value) {
if (value) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
}
customElements.define('my-button', MyButton);
Here, we use the dispatchEvent
method to emit a custom event named button-clicked
whenever the button is clicked. The event includes additional data in the detail
property, which can be used by other parts of your application.
To listen for this event, you can add an event listener in your JavaScript:
const button = document.querySelector('my-button');
button.addEventListener('button-clicked', (event) => {
console.log(event.detail.message);
});
This setup allows your custom elements to communicate with the rest of your application, enabling more complex interactions and behaviors.
Integrating Custom Elements into Existing Projects
Creating custom elements is just the beginning. The real power of web components lies in their ability to be integrated seamlessly into existing projects, regardless of the framework or technology stack in use.
This flexibility makes custom elements a versatile tool for modern web development, allowing you to enhance and modernize your applications without a complete overhaul.
Using Custom Elements with Frameworks
One of the major advantages of custom elements is their framework-agnostic nature. Whether your project is built with React, Angular, Vue, or vanilla JavaScript, custom elements can be integrated easily.
This means you can introduce web components into any project without needing to refactor or rewrite large portions of your codebase.
Integrating with React
React is a popular JavaScript library for building user interfaces, and it has its own component model. However, you can still use custom elements within a React application by simply including them in your JSX:
import React from 'react';
function App() {
return (
<div>
<my-button label="Click Me"></my-button>
</div>
);
}
export default App;
React treats custom elements as regular HTML elements, so you can use them just like any other element. However, because React uses a virtual DOM, there are some considerations to keep in mind. For example, if your custom element relies on properties rather than attributes, you may need to interact with the element directly using refs:
import React, { useRef, useEffect } from 'react';
function App() {
const buttonRef = useRef(null);
useEffect(() => {
if (buttonRef.current) {
buttonRef.current.disabled = true;
}
}, []);
return (
<div>
<my-button ref={buttonRef} label="Submit"></my-button>
</div>
);
}
export default App;
In this example, we use React’s useRef
and useEffect
hooks to interact with the custom element directly, setting its disabled
property after the component has mounted.
Integrating with Angular
Angular also supports the use of custom elements. In fact, Angular has a feature called Angular Elements, which allows you to package Angular components as custom elements. However, you can also use web components created outside of Angular in your Angular projects.
To integrate a custom element into an Angular component, simply include it in your template:
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
template: '<my-button label="Submit"></my-button>',
styleUrls: ['./app.component.css']
})
export class AppComponent {}
Angular handles custom elements just like standard HTML elements, so you can use them directly in your Angular templates. If your custom element emits custom events, you can listen for them using Angular’s event binding syntax:
<my-button label="Submit" (button-clicked)="onButtonClicked($event)"></my-button>
In your component class, you would define the onButtonClicked
method to handle the event:
onButtonClicked(event: Event) {
console.log('Button clicked:', event);
}
Integrating with Vue
Vue is another popular JavaScript framework that works well with custom elements. Vue’s template syntax is similar to HTML, so you can use custom elements directly in your Vue components:
<template>
<div>
<my-button label="Click Me"></my-button>
</div>
</template>
<script>
export default {
mounted() {
const button = this.$el.querySelector('my-button');
button.addEventListener('button-clicked', this.handleButtonClick);
},
methods: {
handleButtonClick(event) {
console.log('Button clicked:', event.detail.message);
}
}
}
</script>
In this Vue component, we use the mounted
lifecycle hook to add an event listener to the custom element, allowing us to handle custom events within the Vue framework.
Integrating Custom Elements into Legacy Projects
If you’re working on a legacy project that uses older technologies, custom elements can still be a valuable addition. They allow you to modernize parts of your application incrementally without needing to rewrite everything from scratch.
For example, if you have an older application built with jQuery, you can create custom elements to replace certain parts of the UI with more modern, reusable components. These custom elements can be integrated into the existing codebase without disrupting the overall structure of the application.
Here’s how you might integrate a custom element into a legacy project using jQuery:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Legacy Project</title>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="my-button.js"></script>
</head>
<body>
<my-button label="Submit"></my-button>
<script>
$(document).ready(function() {
$('my-button').on('button-clicked', function(event) {
console.log('Button clicked:', event.detail.message);
});
});
</script>
</body>
</html>
In this example, the custom element <my-button>
is used alongside jQuery. The custom event button-clicked
is handled using jQuery’s event system, demonstrating how easily custom elements can be integrated into legacy codebases.
Best Practices for Integrating Custom Elements
When integrating custom elements into existing projects, there are a few best practices to keep in mind:
- Ensure Compatibility: Test your custom elements across different browsers and environments to ensure they work as expected. This is especially important if your application needs to support older browsers.
- Maintain Clear Documentation: Document the API of your custom elements, including available attributes, properties, methods, and events. This makes it easier for other developers to use your components effectively.
- Use Polyfills if Necessary: If you need to support older browsers that don’t fully support web components, consider using polyfills. Polyfills provide the necessary functionality in environments where it’s not natively available, ensuring wider compatibility.
- Keep Performance in Mind: While custom elements can enhance your application, it’s important to monitor their impact on performance. Ensure that your components are optimized for fast rendering and minimal memory usage.
Styling Custom Elements

Styling is a crucial aspect of creating custom elements with web components. The way your components look and behave can have a significant impact on the user experience.
With web components, you have the ability to encapsulate styles within the component itself, ensuring that they don’t interfere with the rest of your application. This encapsulation is made possible through the use of the Shadow DOM.
Encapsulation with the Shadow DOM
When you create a custom element using the Shadow DOM, you ensure that the styles and structure inside your component are isolated from the global styles of your application.
This means that styles defined within a component won’t leak out and affect other parts of the page, and similarly, external styles won’t affect the component’s internal structure.
For example, consider the following custom element:
class StyledButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
button {
padding: 10px 20px;
background-color: #28a745;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
}
button:hover {
background-color: #218838;
}
</style>
<button>Styled Button</button>
`;
}
}
customElements.define('styled-button', StyledButton);
In this example, the styles for the button are defined within the Shadow DOM. As a result, these styles are completely encapsulated within the StyledButton
component, ensuring that they won’t be affected by global CSS, and that they won’t affect any other buttons on the page.
Customizing Styles with CSS Variables
One of the powerful features of modern CSS is the ability to use CSS variables (also known as custom properties). CSS variables allow you to define values that can be reused throughout your styles, and they can be particularly useful when creating customizable web components.
Let’s enhance the StyledButton
component by allowing the background color and text color to be customized using CSS variables:
class CustomizableButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
button {
padding: 10px 20px;
background-color: var(--button-bg-color, #007BFF);
color: var(--button-text-color, white);
border: none;
border-radius: 5px;
cursor: pointer;
}
button:hover {
background-color: var(--button-hover-bg-color, #0056b3);
}
</style>
<button><slot></slot></button>
`;
}
}
customElements.define('customizable-button', CustomizableButton);
In this version, the button’s colors are controlled by CSS variables: --button-bg-color
, --button-text-color
, and --button-hover-bg-color
. These variables can be set by the user of the component to customize its appearance:
<customizable-button style="--button-bg-color: #ff5733; --button-text-color: #fff;">Custom Button</customizable-button>
This approach allows for greater flexibility and reusability of the component, as developers can easily adapt the button’s appearance to match the design requirements of different parts of the application.
The Role of the :host
and :host-context
Pseudo-Classes
The :host
and :host-context
pseudo-classes are powerful tools in styling custom elements. They allow you to apply styles to the host element itself (the element that the custom component represents) and to target the component based on its context in the DOM.
The :host
pseudo-class applies styles to the host element, providing a way to style the component based on its own state or attributes:
class HighlightedButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host([highlight]) button {
background-color: yellow;
color: black;
}
</style>
<button><slot></slot></button>
`;
}
}
customElements.define('highlighted-button', HighlightedButton);
In this example, if the highlight
attribute is present on the highlighted-button
element, the button’s background will turn yellow, and the text will turn black. The :host
pseudo-class makes it easy to change the component’s styles based on its state or attributes.
The :host-context
pseudo-class allows you to style your component based on its surroundings or context in the DOM. For example, you might want to change the appearance of a component when it’s placed inside a specific container or has a particular ancestor element:
class ContextAwareButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
:host-context(.dark-mode) button {
background-color: black;
color: white;
}
</style>
<button><slot></slot></button>
`;
}
}
customElements.define('context-aware-button', ContextAwareButton);
Here, if the context-aware-button
is placed within an element that has the dark-mode
class, the button will automatically switch to a dark color scheme. This makes it easier to create components that adapt to different themes or contexts within your application.
Ensuring Responsiveness
Responsive design is a critical aspect of modern web development, and it’s just as important when building custom elements. By using media queries within your component’s Shadow DOM, you can ensure that your elements adapt to different screen sizes and devices.
For example, you might want your button to adjust its padding and font size based on the viewport width:
class ResponsiveButton extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `
<style>
button {
padding: 10px 20px;
font-size: 16px;
}
@media (max-width: 600px) {
button {
padding: 8px 16px;
font-size: 14px;
}
}
</style>
<button><slot></slot></button>
`;
}
}
customElements.define('responsive-button', ResponsiveButton);
In this example, the button’s padding and font size are reduced when the viewport width is 600px or less, ensuring that the button remains comfortable to use on smaller screens.
Testing and Debugging Styles
Testing and debugging the styles of your custom elements is crucial to ensure they behave as expected across different scenarios and environments. Modern browsers provide powerful developer tools that can help you inspect and debug the styles applied within the Shadow DOM.
Using the Elements panel in Chrome DevTools, for example, you can inspect the Shadow DOM and see exactly how styles are applied to your custom elements. This allows you to troubleshoot issues such as unintended style overrides, conflicts, or incorrect applications of CSS variables.
Additionally, testing your custom elements across different devices and screen sizes is essential to ensure they are responsive and accessible to all users. By using browser testing tools and responsive design testing tools, you can catch any issues that might arise in different contexts and make the necessary adjustments.
Testing and Maintaining Custom Elements

After creating and styling custom elements, the next crucial step is to ensure that they are robust, reliable, and maintainable over time. Testing plays a vital role in this process, helping you catch bugs early and maintain high-quality code as your application evolves.
In addition, ongoing maintenance ensures that your custom elements remain functional and up-to-date as web standards and user requirements change.
Unit Testing Custom Elements
Unit testing is the practice of testing individual components in isolation to ensure that they work as expected. For custom elements, this means testing their attributes, properties, events, and methods independently of the rest of the application.
To start unit testing your custom elements, you can use popular JavaScript testing frameworks like Jasmine or Mocha. These frameworks allow you to write test cases that verify the behavior of your elements in a controlled environment.
For example, let’s write a simple unit test for the CustomizableButton
component that verifies its ability to update the button’s label based on an attribute:
describe('CustomizableButton', () => {
let button;
beforeEach(() => {
button = document.createElement('customizable-button');
document.body.appendChild(button);
});
afterEach(() => {
document.body.removeChild(button);
});
it('should update the label based on the attribute', () => {
button.setAttribute('label', 'Submit');
expect(button.shadowRoot.querySelector('button').textContent).toBe('Submit');
});
it('should fire a custom event when clicked', () => {
const mockCallback = jasmine.createSpy('mockCallback');
button.addEventListener('button-clicked', mockCallback);
button.shadowRoot.querySelector('button').click();
expect(mockCallback).toHaveBeenCalled();
});
});
In this test, we check that the button’s label is updated correctly when the label
attribute is changed. We also verify that a custom event is dispatched when the button is clicked. Writing tests like these helps ensure that your components behave as expected under different conditions.
Integration Testing Custom Elements
Integration testing involves testing how your custom elements interact with other parts of your application. This is important because, in a real-world scenario, your components will likely be used alongside other elements and scripts, and it’s crucial to ensure they work together seamlessly.
To perform integration tests, you can create more complex test cases that simulate the environment in which your components will be used. For example, you might test how your custom element behaves when it receives input from a form, interacts with other components, or is dynamically inserted into the DOM.
End-to-End Testing
End-to-end (E2E) testing simulates user interactions with your application, testing the entire flow from start to finish. This is especially useful for catching issues that might arise only when the application is used in a real-world scenario.
Tools like Cypress or Selenium can be used for E2E testing. These tools allow you to write tests that interact with your custom elements as a user would—clicking buttons, filling out forms, and navigating between pages. E2E testing is essential for ensuring that your custom elements function correctly in the context of the entire application.
Debugging Custom Elements
Debugging custom elements is an important part of maintaining them over time. Modern browsers provide excellent tools for debugging web components, including the ability to inspect the Shadow DOM, view applied styles, and monitor events.
For example, in Chrome DevTools, you can use the Elements panel to view the structure of your custom elements, including the Shadow DOM. You can also use the Console panel to log messages and inspect variables, making it easier to identify issues in your component’s logic.
When debugging, pay special attention to how your components interact with the rest of the application. Look for issues such as styling conflicts, unexpected behavior when interacting with other components, or performance bottlenecks.
Maintaining Custom Elements
Ongoing maintenance is essential for keeping your custom elements functional and up-to-date. This includes updating your components as web standards evolve, fixing bugs, optimizing performance, and adding new features as needed.
One of the best ways to maintain custom elements is to keep them modular and well-documented. By clearly documenting the API of each component, including its attributes, properties, methods, and events, you make it easier for other developers (and your future self) to understand and work with the component.
It’s also important to monitor the performance of your custom elements, especially as your application grows. Regularly profile your components using browser developer tools to identify and address any performance issues, such as slow rendering, excessive reflows, or memory leaks.
Versioning and Compatibility
As you continue to develop and improve your custom elements, it’s important to implement a versioning strategy. Versioning helps you track changes and ensure compatibility with existing projects.
Semantic versioning (using major, minor, and patch version numbers) is a common approach that makes it clear when updates introduce breaking changes, new features, or bug fixes.
When making significant changes to a custom element, consider how those changes will affect existing applications that use the component. Providing clear migration paths or maintaining backward compatibility can help ensure that your updates are adopted smoothly by other developers.
The Future of Custom Elements
Custom elements, as part of the broader web components standard, are becoming increasingly important in modern web development. As more developers adopt this technology, the ecosystem around custom elements continues to grow, with new tools, libraries, and best practices emerging.
By mastering the creation, testing, and maintenance of custom elements, you position yourself at the forefront of modern web development. These skills not only allow you to build more modular, scalable, and maintainable applications but also ensure that you can adapt to the evolving landscape of web technologies.
Conclusion
Creating custom elements with web components is a powerful way to enhance your web development capabilities. By understanding the core concepts, such as the Shadow DOM, attributes, properties, and events, you can build reusable, encapsulated components that work seamlessly across different projects and frameworks.
Through effective testing, debugging, and ongoing maintenance, you can ensure that your custom elements remain robust, reliable, and adaptable over time. As web development continues to evolve, mastering custom elements will become an increasingly valuable skill, enabling you to create more flexible, efficient, and future-proof applications.
Whether you’re just starting with web components or looking to deepen your expertise, this guide provides the foundation you need to create, integrate, and maintain custom elements with confidence. By leveraging the full potential of web components, you can take your web development projects to the next level.
Read Next: