Custom HTML Tags

jfbrennan profile image Jordan Brennan Updated on ・13 min read

Design better component APIs and avoid over-engineering with custom HTML tags.

As much as I love JavaScript, my favorite language of the web is HTML. Its declarative style allows me to most easily express what's in my mind and with a refresh of the browser I get to immediately see my creation on screen.

Writing HTML is design and engineering all in one motion and I love it!

Understandably HTML doesn't get the kind of attention it used to - we're building increasingly sophisticated applications in the browser now - but within the scope of UI components let me show you an approach that may have you looking at HTML with renewed interest.

HTML's purpose

HTML’s primary job is to give your content structure and meaning. As the web progressed HTML adapted to include new elements to provide semantic support for more and more types of content, like <nav> for navigation and <video> for videos. It also added new capabilities to existing elements like the autofocus attribute which tells the browser which element to focus on after page load (a must for log in or search pages!). These additions and more were implemented through the usual HTML constructs:

  • Tags
  • Attributes
  • Nesting

In case you need a refresher, look at this example:

<p>Download your <a href="example.com/files" download>files</a></p>

That's a "paragraph" element. Its tag is p and it has an "anchor" element nested inside of it. The anchor has download and href (short for "hypertext reference") attributes. All HTML elements are designed this way.

Here's some more examples (note the semantic tag and attribute names and the child-parent relationships):

<input type="email" placeholder="name@example.com" autofocus>

<video src="example.com/vids/cats.mp4" poster="example.com/posters/cats.jpg" autoplay loop controls></video>


Tags, attributes, and nesting are all there is to HTML's declarative API for instantiating elements. It's simple and powerful!

But as you know HTML doesn't have elements for everything we need and never will. Because of that developers have to create their own custom UI components. This is normally done using classes and CSS and/or JavaScript frameworks for more sophisticated components. Unfortunately, these components always deviate from the original design of HTML and lose many of its benefits.

The old way

Take icons as a simple example, here's some in GitHub's UI:

Icons used in a tabs user interface

Because HTML doesn't provide an icon tag to markup a site's icons developers come up with their own solutions. Here are four real custom icon solutions:

<i class="fa fa-gear"></i>

<i class="icon icon-gear"></i>

<span class="oi oi-gear"></span>

this is what GitHub does in the screenshot above
<svg class="octicon octicon-gear">
  <path d="..."></path>

Those solutions use classes to define both the component type and its attributes, and while there is nothing wrong with that, there are drawbacks:

1. Poor naming:
Only one of those has a meaningful name. Also, fa-, icon-, oi-, and octicon- prefixes are required which results in a not DRY pattern.

2. Loss of clarity over time:
Other developers can diminish the element's purpose.

<i class="icon icon-gear"></i>  original code
<i class="icon icon-gear foo"></i>  six weeks later
<i class="bar-baz icon icon-gear foo"></i>  a year later...what exactly is this element now?
3. The tag and class attribute are unavoidable boilerplate with no meaning:
<div class="icon icon-gear"></div>

<div class=""></div> is all meaningless boilerplate. What a bummer!

4. Compared to standard elements the class-based design looks out of place:
<i class="icon icon-gear"></i>
<input type="email" autofocus>

What if standard elements were done with classes? Instead of the input above we'd have:

<div class="input input-type-email input-autofocus">


It gets even worse if you follow BEM. Here's an example of BEM from a popular design system:

<div class="mdc-dialog__actions mdc-dialog__actions--full-width">

Other approaches get even weirder:

<span uk-icon="icon: gear"></span>

We don't have to do it this way.

We don't have to use classes or trickery.

There's something better.

A new approach

You can design custom UI components with more meaningful and familiar APIs by using HTML tags, attributes, and nesting. Here's an example:

Old class-based icon design

<i class="icon icon-gear"></i>

Same thing but with a custom tag and attribute

<icon name="gear"></icon>

If this makes you uneasy, don't worry. Custom tags are compatible with all browsers, even older IE. Browsers happily download, parse, and render custom tags just like any "real" HTML because this is real HTML. Browsers won't have any default styles or built-in behaviors for your custom tags (registered by the browser as "unknown" tags), but this is not a problem at all. These are real elements so you can create CSS rules for them and query them in the DOM.

So, in the case of icon we simply style the custom tag and attribute(s) instead of icon classes:

icon {
  /* display: inline; Browsers display all unknown tags as inline, you can set it to whatever you want */
  font-family: 'My Icons';

icon[name="gear"]:before {
  content: "\u123"; /* a gear-shaped glyph */

That's it. No hacks, no dependencies, nothing new or proprietary!

Let's do another one. Let's convert the popular Badge component:

Old class-based badge design

<span class="badge badge-success">1</span>

New badge with tag and attributes

<badge count="1" type="success"></badge>

The custom tag design really stands out as a semantic Badge element with it's own meaningful attributes just like standard elements!

And check it out: with a little CSS we can add intelligence to Badge so when it has a zero count or no count, it goes away:

badge[count="0"], badge[count=""] { 
  display: none; 

That's pretty cool!

Here's some other examples of common components designed as custom tags with attributes instead of classes:

<loader loading></loader>

<alert type="success">...</alert>

  <col span="6" hide="sm">...</col> hides on small screens
  <col span="6 sm-12">...</col> goes to 12 cols on small screens

How about we redo Material's Dialog Actions component that uses the BEM methodology?


<div class="mdc-dialog__actions mdc-dialog__actions--full-width">...</div>


<mdc-dialog-actions size="full-width">...</mdc-dialog-actions>

Can you see the difference?

Are you starting to sense the benefits?

Designing UI components with tags and attributes instead of classes is fun and it's better. It is objectively better:

  • Custom tags provide strong, DRY, semantic names that are easily identifiable compared to classes: <badge> vs. <span class="badge">
  • Custom tag retains its semantic identity regardless of modifier classes added over time:<badge class="foo bar"> vs. <span class="foo bar badge">
  • Tags and attributes give developers a rich and familiar API instead of boilerplate tag with a mixed list of classes: <col span="6" hide="sm"> vs. <div class="col col-6 col-hidden-sm">
  • No more BEM or other methodologies for engineering around the problems with class-based design
  • In many cases you can ditch the need for expensive abstractions and their dependencies: {{> icon name="gear"}} (Handlebars) or <OverEngineeredIcon name="gear"/> (React) is replaced with the dependency-free <icon name="gear"></icon>
  • The result is cleaner and shorter code that's patterned after the standard declarative HTML API.

Using custom tags and attributes is officially supported (more details on that in a bit). HTML is meant to be extended this way, but devs instead went crazy for classes and that pattern quickly became the norm. It's time to reconsider!

There's also another very big benefit to using custom tags and attributes: it better positions your component for future improvements. How so? Let’s get into that now.

Component evolution

Creating and sharing custom components is a commitment. Your components will evolve and have new capabilities added to them over time. Let's look at the possible evolution of a custom Alert (aka Callout) component:

Original design

<alert type="success">
  <p>Custom tags are great!</p>
alert { 
  display: flex; 
  color: white;

alert[type="success"] { background-color: green; }
alert[type="warn"] { background-color: orange; }
alert[type="error"] { background-color: red; }

That would look something like:

Alert component

Please note that there are no dependencies here. There's nothing to download, no tools and nothing to build. No magic, no hacks, nothing proprietary, no frameworks or special syntax, nothing. And when it comes to building software, nothing is better than something.

Our Alert is pretty plain right now, so let’s see if we can give it an icon:

With an icon

<alert type="success">
  <icon name="check"></icon>
  <p>Custom tags are great!</p>

That works, but it's not the right way to design a component. Let's get an icon without leaving it up to the implementer:

With the icon inferred

<alert type="success">
  <p>Custom tags are great!</p>
alert[type="success"]:before {
  font-family: 'My Icons';
  content: "\u555"; /* gets us a ✓ icon */

Alert component with an icon

Ok, that's starting to really look like something. (Note that the CSS here does not include all the properties needed like font-size and padding)

It's pretty common for Alerts to disappear automatically, so let's add support for that. If there really was an HTML alert element and it had an auto-disappearing feature one could imagine it would have an autodismiss attribute for triggering this behavior, so let's go with that:

New autodismiss feature

<alert type="success" autodismiss>
  <p>Custom tags are great!</p>
alert {
     transition: opacity 2s 4s ease-in-out; /* 4 second delay, then fade out */
     opacity: 1; 

alert[autodismiss] {
    opacity: 0; 

Nice! We've really got ourselves a useful component without a single dependency, build step, or polyfill required! And check out its friendly little API:

  • alert tag
  • type attribute (required) - one of "success", "warn", or "error"
  • autodismiss attribute (optional) - if present, the Alert will disappear after four seconds
  • id, class, aria- and other "inherited" attributes still apply
  • transitionend event - DOM event, fires after Alert disappears
  • Accepts nested content, including other custom tags

If you didn't know you might think this was just a standard HTML element. That's a sign we are on the right track!

Close, but not quite

There is a small problem, though. The problem is our tag name is not totally future-proof. There are two considerations here:


The first is that some day HTML might get a tag with the same name as ours. I pray every night before bed that WHATWG will give us <icon>, but if WHATWG doesn't it's still possible some other developer will. Either way there's risk of a collision and that brings us to the second consideration: prefixing.


Although these aren't technically Custom Elements at this point, you'll want to follow that spec by using a prefix for your custom tag names. At Avalara we use s- as our prefix. The s is short for Skylab, which is the name of our design system, but it also means:

  • standards - we always go for standards until we actually need to bring in a dependency
  • semantic - tags with attributes are much more semantic than div with classes
  • small - basic HTML and CSS can take you very far without the overhead of something like React
  • shared - these components are shared by our 20+ web apps and three times as many developers

So yeah, prefixing is a best-practice. It solves the risk of colliding tags and it's a helpful visual distinguisher between standard and custom tags. More importantly it sets you up very nicely for when JavaScript-enabled functionality is required and your happy little "micro" component needs to grow up and become a true Custom Element. You see, using prefixed custom tags instead of classes allows your components to scale in either direction: you can scale down to lightweight CSS-only components like Icon and Badge, or all the way up to interactive components that respond to state changes all while maintaining the same HTML interface for standard elements, custom tags, and full-blown Custom Elements. The secret is starting with a prefixed custom tag.

Let's see how our Alert can go from a basic custom tag with styles to interactive JavaScript-enabled component without breaking changes or a shifting paradigm.

In a future release of Alert let's say we're adding the ability to set the autodismiss duration. You can take the default four seconds by simply adding the attribute, or you can shorten or extend that duration by setting its value to a number:

Override autodismiss duration

<alert type="success" autodismiss="10">
  <p>Custom tags are great!</p>

But as we've learned it's best-practice to prefix, so that really should be:

<s-alert type="success" autodismiss="10">
  <p>Custom tags are great!</p>

Side note: If you're the maintainer of a shared library, pick a short prefix that's meaningful to you. Twitter's Bootstrap, for example, would go from:

<div class="alert alert-success">


<twbs-alert type="success">

or maybe just

<b-alert type="success">

Material could use mdc- as shown above.

Anyway, back to autodismiss. Supporting a value of seconds now requires the use of JavaScript. At this point most people go with what they know, or try the flavor-of-the-day ramping up on whatever idioms and special syntax is required. That's not a problem if you're a small team with one app, but if you have lots of consumers of your Alert component you're entering into a code contract and the less that contract asks of the implementer the better, especially when additional dependencies are avoided!

We can minimize the contract and be better positioned for the long-term if we pick a solution that follows, or stays close to, Custom Elements. Here are some options available today:

Here are two examples where Alert has been upgraded to a stateful component to support a user-defined value for autodismiss delay:

Custom Elements + <template> element

<template id="s-alert">
    :host {...}


  let tmpl = document.querySelector('#s-alert');

  customElements.define('s-alert', class extends HTMLElement {
    constructor() {
      let shadowRoot = this.attachShadow({mode: 'open'});

    static get observedAttributes() {
      return ['type', 'autodismiss'];

    get type() {
      return this.getAttribute('type', val);

    set type(val) {
      if (val) {
        this.setAttribute('type', val);

    get seconds() {
      if (this.hasAttribute('autodismiss')) {
        let seconds = (typeof this.getAttribute('autodismiss') === 'number' ? this.getAttribute('autodismiss') : 4) * 1000;
      } else {
        let seconds = 0

      return seconds;

    set seconds(val) {
      if (val) {
        this.setAttribute('autodismiss', val);
      } else {

    attributeChangedCallback(name, oldValue, newValue) {
      // Update the type or autodismiss attribute

    connectedCallback() {
      let icon = this.type === 'success' ? 'check' : this.type === 'error' ? 'info' : 'warn';
      this.getElementsByTagName('s-icon')[0].setAttribute('name', icon);

      if (this.seconds > 0) setTimeout(this.remove(), this.seconds);


    <s-icon name="{icon}"></i>
    <yield/> <!-- same as <slot> -->

        this.icon = this.opts.type === 'success' ? 'check' : this.opts.type === 'error' ? 'info' : 'warn';

        this.on('mount', () => {
            if (this.opts.autodismiss) {
                let seconds = (typeof this.opts.autodismiss === 'number' ? this.opts.autodismiss : 4) * 1000;
                setTimeout(this.unmount(), seconds);
      :scope {...}

Regardless of the implementation, our markup for Alert hasn't changed:

<s-alert type="success" autodismiss="10">
  <p>Custom tags are great!</p>

And the default still works the same too:

<s-alert type="success" autodismiss>
  <p>Custom tags are great!</p>

Going forward

The front-end space is notorious for rapidly changing. It's a place of hype and fads. That probably won't change, but going forward if the thing you pick enables you and other devs to compose UIs using HTML, then it's a good choice. If something forces you to add lots of kb (more than 10 min+gz) and write special syntax, then it's not a good choice for UI composition because we already have HTML for that. We just haven't been using it correctly!

Being able to write apps built with this kind of standards-based markup is not just a better DX, it's less costly since there's nothing proprietary that will inevitably fall out of fashion and need to be refactored. Take GitHub's UI for example. No idea what they built it with, but as I write this article I look at the interface imagining myself using Skylab to recreate it:

    <s-tab for="code">
      <s-icon name="code"></s-icon> Code
    <div id="code">
      <s-editor mode="md"></s-editor>
    <s-tab for="pull-req">
      <s-icon name="merge"></s-icon> Pull requests <s-badge count="0"></s-badge>
    <div id="pull-req">
    <s-tab for="projects">
      <s-icon name="board"></s-icon> Projects <s-badge count="1"></s-badge>
    <div id="projects">

Now I know this doesn't address the hard problem of application state management and having the UI reliably reflect that state. That's what React and others set out to solve and they did. But the front-end community seems to have been unable to take a balanced approach to adopting these new technologies and just started over-engineering everything in sight. It's very pervasive in the React community in particular. I'll go out on a limb and say that if you use React you no doubt have an over-engineered app, or at least in part. When I see things like this I just wonder what the heck are all the React devs doing to themselves (these are real React components, there's 100's of examples out there like this):

<DisplayText size="extraLarge" element="h4">Good evening, Dominic.</DisplayText>

which outputs

<h4 class="Polaris-DisplayText Polaris-DisplayText--sizeExtraLarge">Good evening, Dominic.</h4>

Just take a minute to think about what happened there...

Here's another one from a great company that should know better:

<UitkInlineBadge shape="shape-pill" theme="theme-success">10% off</UitkInlineBadge>

which outputs

<span class="uitk-badge uitk-badge-inline shape-pill theme-success">10% off</span>

The overuse of React and other libraries for shared components diminishes their potential gains, even to the point of resulting in an overall negative outcome. True story:

Should an engineer write a dozen lines of CSS to make Badge, or should they write 474 total lines of code across 8 files with multiple dependencies and a mandatory build pipeline?

"So it can scale" I hear. So it can...and yet 9 out of 10 implementations were in zero danger of not being able to scale, but all 10 were solved with [insert favorite js library] and now the app has 10x the amount of code as necessary and an extremely high degree of dependency. Can it scale down? Down so much that it can get out of its own way and not be needed at all?

And that's really what the custom tag approach is about. Yes, a custom tag plus attributes design is much nicer than class-based (the time for that switch has definitely come), but being able to design and build components that scale in either direction - up or down - and do so with no dependencies following the standard HTML API across a wide range of use cases is a very compelling opportunity!


Custom HTML tags, Web Components, the Custom Elements spec and the few js libs that stay close to it - that's the path to designing better UI components and getting past this over-engineered era.

Any custom tag experience you'd like to share? Comment below!


Editor guide
jedimmel profile image
Jamie E. Dimmel

Thank you for this, it makes me feel a lot better about creating my own custom tags for my website classical-sketchup-draft.netlify.com/.

It's about building Classical Orders and uses a lot of fractions, so I created my own "frac" tag:

frac { /* Custom tag for fractions */
display: inline;
font-variant-numeric: diagonal-fractions;
-moz-font-feature-settings: "frac";
-webkit-font-feature-settings: "frac";
font-feature-settings: "frac";

So I can do 5/6 instead of 5/6.

The thought of having to type all those spans was dreadfull.

notbigmuzzy profile image

Hey, so a bit late to this :) but did you work with custom tags? Are there some obvious downsides?

jfbrennan profile image
Jordan Brennan Author

Yes, I use them every day (M- has many, along with native elements and full Custom Elements). I think they’re great and I have yet to run into any issues and I’ve been using them for about four years. I have helped build several design systems, including M-, at large companies that use them across diverse tech stacks too. Make sure to pick a prefix and use it though, even if you don’t think they’ll become Custom Elements.

notbigmuzzy profile image

Great stuff! I love the DOM tree on the M- site :)

Do you know maybe any quality reading materials about the custom HTML tags?

casper profile image

Awesome article, thanks!

okahtal profile image
Othmane Kahtal

tnx you this is best quick lesson good!