Dynamically Switching From One HTML Element to Another in Vue

Avatar of Travis Almand
Travis Almand on

Take the pain out of building site search with the Algolia hosted API. Start free now!

A friend once contacted me asking if I had a way to dynamically change one HTML element into another within Vue’s template block. For instance, shifting a <div> element to a <span> element based on some criteria. The trick was to do this without relying on a series of v-if and v-else code.

I didn’t think much of it because I couldn’t see a strong reason to do such a thing; it just doesn’t come up that often. Later that same day, though, he reached out again and told me he learned how to change element types. He excitedly pointed out that Vue has a built-in component that can be used as a dynamic element in the very way that he needed.

This small feature can keep code in the template nice and tidy. It can reduce v-if and v-else glut down to a smaller amount of code that’s easier to understand and maintain. This allows us to use methods or computed methods to create nicely-coded, and yet more elaborate, conditions in the script block. That’s where such things belong: in the script, not the template block.

I had the idea for this article mainly because we use this feature in several places in the design system where I work. Granted it’s not a huge feature and it is barely mentioned in the documentation, at least as far as I can tell. Yet it has potential to help render specific HTML elements in components.

Vue’s built-in <component> element

There are several features available in Vue that allow for easy dynamic changes to the view. One such feature, the built-in <component> element, allows components to be dynamic and switched on demand. In both the Vue 2 and the Vue 3 documentation, there is a small note about using this element with HTML elements; that is the part we shall now explore.

The idea is to leverage this aspect of the <component> element to swap out common HTML elements that are somewhat similar in nature; yet with different functionality, semantics, or visuals. The following basic examples will show the potential of this element to help with keeping Vue components neat and tidy.

Buttons and links are often used interchangeably, but there are big differences in their functionality, semantics, and even visuals. Generally speaking, a button (<button>) is intended for an internal action in the current view tied to JavaScript code. A link (<a>), on the other hand, is intended to point to another resource, either on the host server or an external resource; most often web pages. Single page applications tend to rely more on the button than the link, but there is a need for both.

Links are often styled as buttons visually, much like Bootstrap’s .btn class that creates a button-like appearance. With that in mind, we can easily create a component that switches between the two elements based on a single prop. The component will be a button by default, but if an href prop is applied, it will render as a link.

Here is the <component> in the template:

<component
  :is="element"
  :href="href"
  class="my-button"
>
  <slot />
</component>

This bound is attribute points to a computed method named element and the bound href attribute uses the aptly named href prop. This takes advantage of Vue’s normal behavior that the bound attribute does not appear in the rendered HTML element if the prop has no value. The slot provides the inner content regardless whether the final element is a button or a link.

The computed method is simple in nature:

element () {
  return this.href ? 'a' : 'button';
}

If there’s an href prop,. then an <a> element is applied; otherwise we get a <button>.

<my-button>this is a button</my-button>
<my-button href="https://www.css-tricks.com">this is a link</my-button>

The HTML renders as so:

<button class="my-button">this is a button</button>
<a href="https://www.css-tricks.com" class="my-button">this is a link</a>

In this case, there could be an expectation that these two are similar visually, but for semantic and accessibility needs, they are clearly different. That said, there’s no reason the two outputted elements have to be styled the same. You could either use the element with the selector div.my-button in the style block, or create a dynamic class that will change based on the element.

The overall goal is to simplify things by allowing one component to potentially render as two different HTML elements as needed — without v-if or v-else!

Ordered or unordered list?

A similar idea as the button example above, we can have a component that outputs different list elements. Since an unordered list and an ordered list make use of the same list item (<li>) elements as children, then that’s easy enough; we just swap <ul> and <ol>. Even if we wanted to have an option to have a description list, <dl>, this is easily accomplished since the content is just a slot that can accept <li> elements or <dt>/<dd>combinations.

The template code is much the same as the button example:

<component
  :is="element"
  class="my-list"
>
  <slot>No list items!</slot>
</component>

Note the default content inside the slot element, I’ll get to that in a moment.

There is a prop for the type of list to be used that defaults to <ul>:

props: {
  listType: {
    type: String,
    default: 'ul'
  }
}

Again, there is a computed method named element:

element () {
  if (this.$slots.default) {
    return this.listType;
  } else {
    return 'div';
  }
}

In this case, we are testing if the default slot exists, meaning it has content to render. If it does, then the the list type passed through the listType prop is used. Otherwise, the element becomes a <div> which would show the “No list items!” message inside the slot element. This way, if there are no list items, then the HTML won’t render as a list with one item that says there are no items. That last aspect is up to you, though it is nice to consider the semantics of a list with no apparent valid items. Another thing to consider is the potential confusion of accessibility tools suggesting this is a list with one item that just states there are no items.

Just like the button example above, you could also style each list differently. This could be based on selectors that target the element with the class name, ul.my-list. Another option is to dynamically change the class name based on the chosen element.

This example follows a BEM-like class naming structure:

<component
  :is="element"
  class="my-list"
  :class="`my-list__${element}`"
>
  <slot>No list items!</slot>
</component>

Usage is as simple as the previous button example:

<my-list>
  <li>list item 1</li>
</my-list>

<my-list list-type="ol">
  <li>list item 1</li>
</my-list>

<my-list list-type="dl">
  <dt>Item 1</dt>
  <dd>This is item one.</dd>
</my-list>

<my-list></my-list>

Each instance renders the specified list element. The last one, though, results in a <div> stating no list items because, well, there’s no list to show!

One might wonder why create a component that switches between the different list types when it could just be simple HTML. While there could be benefits to keeping lists contained to a component for styling reasons and maintainability, other reasons could be considered. Take, for instance, if some forms of functionality were being tied to the different list types? Maybe consider a sorting of a <ul> list that switches to a <ol> to show sorting order and then switching back when done?

Now we’re controlling the elements

Even though these two examples are essentially changing the root element component, consider deeper into a component. For instance, a title that might need to change from an <h2> to an <h3> based on some criteria.

If you find yourself having to use ternary solutions to control things beyond a few attributes, I would suggest sticking with the v-if. Having to write more code to handle attributes, classes, and properties just complicates the code more than the v-if. In those cases, the v-if makes for simpler code in the long run and simpler code is easier to read and maintain.

When creating a component and there’s a simple v-if to switch between elements, consider this small aspect of a major Vue feature.

Expanding the idea, a flexible card system

Consider all that we’ve covered so far and put it to use in a flexible card component. This example of a card component allows for three different types of cards to be placed in specific parts of the layout of an article:

  • Hero card: This is expected to be used at the top of the page and draw more attention than other cards.
  • Call to action card: This is used as a line of user actions before or within the article.
  • Info card: This is intended for pull quotes.

Consider each of these as following a design system and the component controls the HTML for semantics and styling.

In the example above, you can see the hero card at the top, a line of call-to-action cards next, and then — scrolling down a bit — you’ll see the info card off to the right side.

Here is the template code for the card component:

<component :is="elements('root')" :class="'custom-card custom-card__' + type" @click="rootClickHandler">
  <header class="custom-card__header" :style="bg">
    <component :is="elements('header')" class="custom-card__header-content">
      <slot name="header"></slot>
    </component>
  </header>
  <div class="custom-card__content">
    <slot name="content"></slot>
  </div>
  <footer class="custom-card__footer">
    <component :is="elements('footer')" class="custom-card__footer-content" @click="footerClickHandler">
      <slot name="footer"></slot>
    </component>
  </footer>
</component>

There are three of the “component” elements in the card. Each represents a specific element inside the card, but will be changed based on what kind of card it is. Each component calls the elements() method with a parameter identifying which section of the card is making the call.

The elements() method is:

elements(which) {
  const tags = {
    hero: { root: 'section', header: 'h1', footer: 'date' },
    cta: { root: 'section', header: 'h2', footer: 'div' },
    info: { root: 'aside', header: 'h3', footer: 'small' }
  }
  return tags[this.type][which];
}

There are probably several ways of handing this, but you’ll have to go in the direction that works with your component’s requirements. In this case, there is an object that keeps track of HTML element tags for each section in each card type. Then the method returns the needed HTML element based on the current card type and the parameter passed in.

For the styles, I inserted a class on the root element of the card based on the type of card it is. That makes it easy enough to create the CSS for each type of card based on the requirements. You could also create the CSS based on the HTML elements themselves, but I tend to prefer classes. Future changes to the card component could change the HTML structure and less likely to make changes to the logic creating the class.

The card also supports a background image on the header for the hero card. This is done with a simple computed placed on the header element: bg. This is the computed:

bg() {
  return this.background ? `background-image: url(${this.background})` : null;
}

If an image URL is provided in the background prop, then the computed returns a string for an inline style that applies the image as a background image. A rather simple solution that could easily be made more robust. For instance, it could have support for custom colors, gradients, or default colors in case of no image provided. There’s a large number of possibilities that his example doesn’t approach because each card type could potentially have their own optional props for developers to leverage.

Here’s the hero card from this demo:

<custom-card type="hero" background="https://picsum.photos/id/237/800/200">
  <template v-slot:header>Article Title</template>
  <template v-slot:content>Lorem ipsum...</template>
  <template v-slot:footer>January 1, 2011</template>
</custom-card>

You’ll see that each section of the card has its own slot for content. And, to keep things simple, text is the only thing expected in the slots. The card component handles the needed HTML element based solely on the card type. Having the component just expect text makes using the component rather simplistic in nature. It replaces the need for decisions over HTML structure to be made and in turn the card is simply implemented.

For comparison, here are the other two types being used in the demo:

<custom-card type="cta">
  <template v-slot:header>CTA Title One</template>
  <template v-slot:content>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</template>
  <template v-slot:footer>footer</template>
</custom-card>

<custom-card type="info">
  <template v-slot:header>Here's a Quote</template>
  <template v-slot:content>“Maecenas ... quis.”</template>
  <template v-slot:footer>who said it</template>
</custom-card>

Again, notice that each slot only expects text as each card type generates its own HTML elements as defined by the elements() method. If it’s deemed in the future that a different HTML element should be used, it’s a simple matter of updating the component. Building in features for accessibility is another potential future update. Even interaction features can be expanded, based on card types.

The power is in the component that’s in the component

The oddly named <component> element in Vue components was intended for one thing but, as often happens, it has a small side effect that makes it rather useful in other ways. The <component> element was intended to dynamically switch Vue components inside another component on demand. A basic idea of this could be a tab system to switch between components acting as pages; which is actually demonstrated in the Vue documentation. Yet it supports doing the same thing with HTML elements.

This is an example of a new technique shared by a friend that has become s surprisingly useful tool in the belt of Vue features that I’ve used. I hope that this article carries forward the ideas and information about this small feature for you to explore how you might leverage this in your own Vue projects.