Close menu

How To Create CSS Only Tooltips

We've already looked at CSS only modals, there is another similar UI pattern traditionally implemented via JavaScript which could be done using only CSS. The humble tooltip.

In theory, they're very simple widgets. You mouseover an element, and a little box with some text is overlaid in proximity to that element.

The JavaScript way would require a mouseover and mouseout event listener to be added on the trigger element so that the tip can be shown or hidden. In more complex setups, the contents of the tooltip might be fetched or lookedup and the tooltip DOM element constructed - so in practice, there's actually a lot of main thread work going on here.

The Tooltip Contents

First, let's tackle the contents of the tooltip. You could simply add an element such as a div with the contents, however, assuming the content is simply text, there's a better option.

Let's say we have a span element in a paragraph and we want to show a tooltip on hover.

We'll store the tooltip content in a data-* attribute.


<p>
    <span data-title="Look Ma! No JS!">Hover for tooltip...</span>
</p>

Display on hover

We can now use a CSS psuedo-element and the contents of the data-title attribute, to style and position the text as a tooltip; we'll use a ::before element:


[data-title] {
    /* allows us to position the tooltip correctly */
    position: relative;
}
[data-title]:hover::before {
    content: attr(data-title);
    display: inline-flex;
    position: absolute;
    bottom: 100%;
    left: 50%;
    width: max-content;
    padding: 10px;
    transform: translate(-50%, 0);
    background: rgba(#ff7705, 0.8);
}

Hover for tooltip...

Pointer

If we want a pointer/chevron on the tooltip, we can use an ::after element, presented as a triangle using the border trick - we'll also need to adjust the tooltip's bottom position to account for the height of pointer:


[data-title]:hover::before {
    /* ... */
    bottom: calc(100% + 10px);
}

[data-title]:hover::after {
    content: "";
    display: block;
    position: absolute;
    bottom: 100%;
    left: 50%;
    width: 0;
    height: 0;
    transform: translate(-50%, 0);
    border-width: 10px 10px 0;
    border-style: solid;
    border-color: rgba(#ff7705, 0.8) transparent transparent;
}

Hover for tooltip...

Now for the fun part - visual effects! ✨.

Transitions

We usually want our tooltips to have a tween effect in and out; but we can't use the display property and add transitions, usually they are skipped. So just like we did with the CSS only modals, we'll rely on the transform property to hide and show the tooltip. We'll also use the opacity and transform properties for the appearance and dissappearance effects, though we won't apply any tweening on the transform. Let's refactor the current styles:


[data-title] {
    /* allows us to position the tooltip correctly */
    position: relative;
}
[data-title]::before {
    content: attr(data-title);
    display: inline-flex;
    position: absolute;
    bottom: calc(100% + 10px);
    left: 50%;
    width: max-content;
    padding: 10px;
    background: rgba(#ff7705, 0.8);
    /* setup effects */
    transition:
        opacity 250ms ease-out 50ms,
        transform 0ms ease-out 300ms;
    /* initial hidden state */
    transform:
        translate(-50%, 0)
        scale(0);
    opacity: 0;
}

[data-title]:hover::before {
    /* adjust effects timings */
    transition:
        opacity 250ms ease-out 50ms,
        transform 0ms ease-out 50ms;
    /* visible state */
    transform:
        translate(-50%, 0)
        scale(1);
    opacity: 1;
}

[data-title]::after {
    content: "";
    display: block;
    position: absolute;
    bottom: 100%;
    left: 50%;
    width: 0;
    height: 0;
    border-width: 10px 10px 0;
    border-style: solid;
    border-color: rgba(#ff7705, 0.8) transparent transparent;
    /* setup effects */
    transition:
        opacity 250ms ease-out 50ms,
        transform 0ms ease-out 300ms;
    /* initial hidden state */
    transform:
        translate(-50%, 0)
        scale(0);
    opacity: 0;
}

[data-title]:hover::after {
    /* adjust effects timings */
    transition:
        opacity 250ms ease-out 50ms,
        transform 0ms ease-out 50ms;
    /* visible state */
    transform:
        translate(-50%, 0)
        scale(1);
    opacity: 1;
}

You'll notice we've used delays on the transitions, a base 50ms delay prevents flashing as the pointer travels across the tooltip trigger - this ensures the tooltip only appears when the user has settled on the trigger, that is, shows intent that they want to trigger the tooltip.

Our 0ms transition timing on the transform means that on hover, the tooltip element shows but with the opacity still at 0. The transition to 1 then kicks in and we get the fade in effect.

Hover for tooltip...

Display on touch

Here's where tooltips get a little tricky.

We are certainly in a mobile-first world. Most websites will find their user base is largely visiting on a touch enabled device, such as smartphones or tablets - such devices often do not implement a hover state and instead emulate the behaviour. UI interactions using the :hover psuedo-class are usually applied when an element is clicked or tapped, and removed when any other element is clicked.

However, this is not a universal or standardised behaviour, so some devices/browsers/OSs may behave differently, while others may just ignore the hover state altogether.

To ensure our CSS only solution works as needed for touch devices, we should explicitly apply the emulated behaviour. To cover both, we can use media queries to apply different styles based on hover support.

Let's wrap our :hover rules in a media query for clients that support it:


/* user agent supports hover */
@media (hover: hover) {
    [data-title]:hover::before {
        /* adjust effects timings */
        transition:
            opacity 250ms ease-out 50ms,
            transform 0ms ease-out 50ms;
        /* visible state */
        transform:
            translate(-50%, 0)
            scale(1);
        opacity: 1;
    }
    
    [data-title]:hover::after {
        /* adjust effects timings */
        transition:
            opacity 250ms ease-out 50ms,
            transform 0ms ease-out 50ms;
        /* visible state */
        transform:
            translate(-50%, 0)
            scale(1);
        opacity: 1;
    }
}

The above styles will apply only when the user's primary input mechanism can hover over elements, such as a mouse.

Now for devices that do not support hover, we first need to make a very tiny tweak to the HTML markup; we need to give the tooltip trigger elements a tabindex attribute set to 0. This tells the browser the element is focusable.


<p>
    <span data-title="Look Ma! No JS!" tabindex="0">Hover for tooltip...</span>
</p>

Now, we apply the same style rules for the hover states, but using the :focus selector, inside a media query for clients that do not support it:


/* user agent DOES NOT support hover */
@media (hover: none) {
    [data-title]:focus::before {
        /* adjust effects timings */
        transition:
            opacity 250ms ease-out 50ms,
            transform 0ms ease-out 50ms;
        /* visible state */
        transform:
            translate(-50%, 0)
            scale(1);
        opacity: 1;
    }
    
    [data-title]:focus::after {
        /* adjust effects timings */
        transition:
            opacity 250ms ease-out 50ms,
            transform 0ms ease-out 50ms;
        /* visible state */
        transform:
            translate(-50%, 0)
            scale(1);
        opacity: 1;
    }
}

Touch only tooltip*...

* does not apply hover rules

There you have it, our tooltips will work consistently across mobile and desktop, applied using only CSS.

First published: 23/11/2022