Build an accessible tooltip component efficiently with CSS and Vue

Feb 22, 2023 ยท 13 min read
Build an accessible tooltip component efficiently with CSS and Vue

Tooltip is an informative and short message which appears only when there is user interaction with an element on the UI of an application. As a commonly used feature, it aims to provide hints or additional descriptive information for a particular part. For interactive elements like buttons, especially icon buttons, using a tooltip improves the component's visual accessibility and user experience on both keyboard and mouse navigation. While title only appears on mouse hovering, a tooltip is a great solution for providing crucial information about the element on keyboard focusing.

There are two ways to create a tooltip for a specific element in Vue: as a wrapper component with a slot or as a custom directive on the target component.

This article will explore the first approach - building a tooltip component using Vue and CSS. And yes, you don't need a component library for tooltips, as it will only take a few lines of code.

Table of Contents

Prerequisites

You need to have a working Vue application (see the official guide for setting up a new Vue project with Vite) and a basic understanding of Vue and CSS.

Once your Vue project is ready, we can create our tooltip component.

Building a Tooltip component with a slot

Our Tooltip contains a wrapper element that includes the following child elements:

  • Any child component injected through a default slot
  • A text field acts as the tooltip for the injected component. The component receives the content for this text field to display using text props.

Based on these requirements, we will add the following code to our Tooltip.vue file:

<template>
    <div class="tooltip-wrapper">
        <slot />
        <span class="tooltip__text">{{ text }}</span>
    </div>
</template>
<script>
const props = defineProps({
    text: {
        type: String,
        required: true
    }
})
</script>

In the above code, we use a div with the class tooltip-wrapper as the wrapper element for:

  • A default slot as a component placeholder,
  • A span element that displays the text value for the tooltip and has the class name tooltip__text.

And within the <script>, we define the text props as a required string using the defineProps method.

Great. We now have the essential structure implemented for our tooltip component. We can then import and use Tooltip in any Vue component as follows:

<template>
    <Tooltip text="This is a tooltip">
        <button>Click me</button>
    </Tooltip>
</template>
<script setup>
import Tooltip from './Tooltip.vue'
</script>

However, Tooltip at this point is not functional and will always display the tooltip next the target element, as in the below screenshot:

Basic Tooltip implement without visibility control

To make the component fully functional, we need to add the CSS stylings and control the tooltip's position and visibility, which we will explore in the next section.

Showing the tooltip on hover with CSS

By default, the tooltip should be invisible on the UI when there is no interaction with the element. To do so, we set visibility to hidden and opacity to 0 for the .tooltip__text class, and add a transition effect for a smooth transition on opacity. We will also add other CSS properties to provide a nice layout for the tooltip and set the wrapper element (.tooltip-wrapper) with display: inline-block to have the same width as its nested element, as shown in the below code:

.tooltip__text {
    visibility: hidden;
    opacity: 0;
    transition: opacity 1s;

    color: #ffffff;
    text-align: center;
    padding: 5px 0;
    border-radius: 2px;
    min-width: 120px;
    background: #5e5d5d;
}

.tooltip-wrapper {
    display: inline-block;
}

We want to show the tooltip when the user hovers over the target element. We can do this by changing the value of the opacity property to 1 and visibility to visible, for the class .tooltip__text when .tooltip-wrapper is on :hover, like in the below code:

.tooltip-wrapper:hover .tooltip__text{
    visibility: visible;
    opacity: 1;
}

You can see how the tooltip is visible with a smooth transition effect when the user hovers on the target element in the below screenshot:

Tooltip appearing only on hovering

Because we place the tooltip below the target button element, it will leave a space when not visible, causing a potential UI bug. We can avoid this issue by using the position: absolute to remove the tooltip from the usual flows of the page layout, hence removing the space generated with it. We also need to set the z-index to 1 to ensure the tooltip always overlaps on top of other elements when visible.

.tooltip__text {
    /**... */
    position: absolute;
    z-index: 1;
}

And we must set the position: relative for the .tooltip-wrapper to make sure to position the tooltip relative to the wrapper element, as follows:

.tooltip-wrapper {
    position: relative;
    display: inline-block;
}

Now the browser will display the tooltip correctly next the target element, as in the following screenshot:

Tooltip appearing only on hovering on top of the paragraph

We have successfully created a tooltip component showing the tooltip on hover. However, if you navigate the page using the keyboard, you will notice that the tooltip is not visible when focusing on the target element with the Tab key. Thus, it will cause a visual accessibility issue for scenarios where the target element has no other descriptive label than the tooltip. Adding the :focus pseudo-element to the .tooltip-wrapper selector will not help since the focused element here is the target element - button, and not its parent - the div. Also, Vue doesn't support automatically passing attributes like class from the slot to the target component, making it harder to compose the correct CSS selector.

In such a scenario, we use the ::slotted() pseudo-element, which we will discuss next.

Showing the tooltip on focus with pseudo-element :slotted()

The :slotted() pseudo-element is a CSS selector that allows us to select elements passed into a component's default slot. It accepts a simple component selector (without space) as its argument. For our case, we can select our target element(s) using the selector *, thus :slotted(*).

:slotted(*) {
}

We can then add the :focus pseudo-element to the :slotted(*) selector and use the adjacent sibling combinator (+) to select the immediate sibling of the target element, which is .tooltip__text, and change its opacity to show the tooltip, as in the following code:

:slotted(*):focus + .tooltip__text {
    visibility: visible;
    opacity: 1;
}

Note here * will work without worrying about the nested elements inside the target one because :slotted() can only select the first-level children of the slot.

And now when focusing on the target button element using the keyboard navigation, we can see the tooltip visible as expected:

Tooltip appearing only on focusing

We now have a fully functional tooltip component that shows the tooltip on hover and focus. Nevertheless, the browser will always display the tooltip below the target element, which is not so customizable. The following section will explore how to position the tooltip using props and the target element's position.

Position the tooltip with CSS and props

A tooltip can usually appear in four positions around an element: top, right, bottom, and left. We start by adding four different CSS class selectors in the style section, each one for a different position with a prefix tooltip--, as shown in the following code:

.tooltip--top {
}

.tooltip--bottom {
}

.tooltip--left {
}

.tooltip--right {
}

We define a new prop - position with a default value of bottom as the default tooltip's position. We also add a new computed property - tooltipClasses to return a set of relevant class names based on the value of the position prop, as in the following code:

import { computed } from 'vue';

const props = defineProps({
    /**... */
    position: {
        type: String,
        default: 'bottom'
    },
})

const tooltipClasses = computed(() => ({
    'tooltip__text': true,
    [`tooltip--${props.position}`]: true
}))

We then bind the computed tooltipClasses to the class attribute of the .tooltip__text element as follows:

<span :class="tooltipClasses">{{ text }}</span>

Now, we can use the position prop to place the tooltip, for example, to the left, as in the below code:

<Tooltip text="This is a tooltip" position="left">
    <button>Click me</button>
</Tooltip>

Vue will generate the correct class name, tooltip--left tooltip__text for the tooltip element.

All we have left is to add the relevant CSS rules for each position. In this post, we use the corresponding CSS logical properties to help control both logical and physical locations of the tooltip based on the writing direction and text orientation of the target element and the desired location, as shown in the below table:

PositionLogical propertyDescription
Topinset-block-startThe distance between the tooltip's top edge and the container's top edge.
Bottominset-block-endThe distance between the tooltip's bottom edge and the container's bottom edge.
Leftinset-inline-startThe distance between the tooltip and the container from the starting inline direction, usually from the left edge.
Rightinset-inline-endThe distance between the tooltip and the container from the ending inline direction, usually from the right edge.

The following code blocks show the CSS rules for each position.

Positioning the tooltip to the top

We will implement the CSS rules for the tooltip--top class selector as follows:

.tooltip--top {
    inset-block-end: 120%;
    inset-inline-start: 50%;
    margin-inline-start: -60px;
}

Here we position the tooltip 20% above and 50% from the left edge of its relative container - .tooltip-wrapper (which has the same offset as the target element) using inset-block-end: 120% and inset-inline-start: 50%, respectively. We also set the margin-inline-start property to -60px to move the tooltip 60px to the left, which will center the tooltip horizontally, as shown in the following screenshot:

Tooltip on the top

Next, we will implement the rules for the bottom position.

Positioning the tooltip to the bottom

Similarly to the top position, we kept the left edge offset and margin and positioned the tooltip 20% below its relative container using inset-block-start: 120% for the tooltip--bottom class selector, as follows:

.tooltip--bottom {
    inset-block-start: 120%;
    inset-inline-start: 50%;
    margin-inline-start: -60px;
}

And the tooltip will appear from the right side as shown below:

Tooltip on the bottom

Next, we will implement the rules for the left position (or inline-start position).

Positioning the tooltip to the left

For the left position, we offset the tooltip 10% from the right edge of its relative container using inset-inline-end: 110% while keeping both bottom edges aligned using inset-block-end: 0%, as shown in the following code:

.tooltip--left {
    inset-block-end: 0%;
    inset-inline-end: 110%;
}

The tooltip will appear from the left side as shown below: Tooltip on the left

Finally, we will implement the rules for the position on the right (or inline-end position).

Positioning the tooltip to the right

Similar to the left position, we keep the bottom edges alignment and offset the tooltip 10% from the left edge of its relative container using inset-inline-start: 110%, as shown in the following code:

.tooltip--right {
    inset-block-end: 0%;
    inset-inline-start: 110%;
}

The tooltip will appear from the right side as shown below:

Tooltip on the right

That's it. We have enabled the tooltip component with four different positions. With the tooltip displaying in a customizable place, we can now move on to the next step - adding an arrow (the triangle shape) to the tooltip and making it look better.

Adding an arrow to the tooltip with pseudo-element ::after

We can add an arrow tip to the tooltip for the sake of beauty by using the :after pseudo-element on the .tooltip__text element, with the following CSS rules:

  • Set the content property to an empty string to create the pseudo-element.
  • Set the position property to absolute to remove the element from the normal flow of the page layout and be relative to its ancestor element with a position: relative set (.tooltip-wrapper in our case).
  • Use the border property to create the arrow shape, filling the border-style to solid and assigning value to the border-width.

The below code demonstrates the above rules:

.tooltip__text::after {
    content: " ";
    position: absolute;
    border-width: 5px;
    border-style: solid;
}

In an empty element (with no width/height), when we set the border-width to 5px, the whole piece will take 10px in width and height and be filled with the color of the border through border-color property. The color corner for each border side will overlap in the shape of a triangle. For instance, if we set border-color: red green blue pink for the top, right, bottom, and left sides, respectively, the element will have color filled as shown below:

A box with 4 different colors in shape of triangles using the border-color property

And to create our arrow tip as a triangle shape, we take advantage of this mechanism by setting the color of the desired border side to the background color of the tooltip and the rest to transparent. Below is how the triangle looks for each different side when using border-color:

Triangles in different border sides

Based on the above, we can define the CSS rules for border-color for each position, as shown in the following table:

PositionBorder color
Top#5e5d5d transparent transparent transparent
Bottomtransparent transparent #5e5d5d transparent
Lefttransparent transparent transparent #5e5d5d
Righttransparent #5e5d5d transparent transparent

And like the tooltip, we need to position the arrow tip. We can do that by setting the inset-block-start, inset-block-end, inset-inline-start, and inset-inline-end properties to the desired value, as seen in the following code:

.tooltip--left::after {
    inset-block-start: 50%;
    inset-inline-start: 100%;
    border-color: transparent transparent transparent #5e5d5d;
}

.tooltip--right::after {
    inset-block-start: 50%;
    inset-inline-end: 100%;
    border-color: transparent #5e5d5d transparent transparent;
}

.tooltip--top::after {
    inset-block-start: 100%;
    inset-inline-start: 50%;
    border-color: #5e5d5d transparent transparent  transparent;
}

.tooltip--bottom::after {
    inset-block-end: 100%;
    inset-inline-start: 50%;
    border-color: transparent transparent #5e5d5d transparent;
}

Straightforward enough. We now have a fully functional tooltip component that supports custom positioning, shows the tooltip on hover and focus, and has an arrow tip, as in the following screenshot:

tooltip appearing on hovering with arrow tip, from four directions

Are we done? No, we still need to make the tooltip accessible to screen readers. Let's do that next.

Announce the tooltip content to screen readers

When navigating to the element with a tooltip, the screen reader needs to know a tooltip is associated with it and announce its content with the component. To do so, we use the aria-describedby attribute on the target element with the tooltip and pass a unique string representing the tooltip element's id, using the tooltip-id prop, as shown in the following code:

<Tooltip text="I'm the top tooltip" position="top" :tooltip-id="tooltipsIds.top">
 <button :aria-describedby="tooltipsIds.top">Top</button>
</Tooltip>

We will adjust the Tooltip component to accept tooltipId as a prop and apply it to the id attribute on the span element containing the tooltip's content, as follows:

<span :class="tooltipClasses" role="tooltip" :id="tooltipId">{{ text }}</span>

We must also assign the element's role as a tooltip for the screen readers to pick up. Doing so helps to prevent the tooltip from being announced twice, once when focusing on the target element and once when reaching the tooltip by the screen reader. We can also enable the inert attribute to ensure the browser will exclude the tooltip from the accessibility tree order and ignore any events within it, including events from assistive technologies.

<span :class="tooltipClasses" role="tooltip" :id="tooltipId" inert>{{ text }}</span>

Below is a demo of how the tooltip works with screen readers when navigating to the Top button:

VoiceOver reads out the tooltip and the button description on focus

And our tooltip component is now accessible for assistive technologies like screen readers. Indeed, there are more things we can do to improve the component, such as allowing custom components within the tooltip and more. As for now, we have a fully functional and accessible tooltip component that we can use in our Vue projects.

Resources

You can find the source code for this tutorial on GitHub repo, and/or learn more about some of the topics covered in this post:

Summary

While using a component library seems convenient, there are better options to go for when working with components. Sometimes, it is better to create your components to have more control over the implementation and customization. We have learned how to create a tooltip component from scratch using Vue and CSS. And from this tutorial, you can extend the component to have more features, such as color customization, animation, and more.

What's next? How about implementing a second approach to create a tooltip as a Vue.js directive and see which one you prefer?

๐Ÿ‘‰ If you'd like to catch up with me sometimes, follow me on Twitter | Facebook.

Like this post or find it helpful? Share it ๐Ÿ‘‡๐Ÿผ ๐Ÿ˜‰