Customizing Tooltips with the Power of Sass Mixins

Sue Anna Joe
Mar 11 · 9 min read
A collage of different tooltip designs
A collage of different tooltip designs

At Zoosk we have a UI component called a tooltip. It resembles a speech bubble and provides helpful information to our users. It appears when you either click on, tap on, or hover over another element.

We have many tooltips on our desktop and mobile websites, and they come in several variations. I discovered that each time I created a new one, I duplicated CSS properties that had been used for older tooltips. This inspired me to write some Sass mixins to speed up development and make it easier to customize our tooltip designs.

First iteration

Initially I came up with the following mixin:

@mixin tooltip($bg, $border, $border-radius, $bottom, $box-shadow, $color, $font-size, $left, $padding, $right, $top) {
background: $bg;
border: $border;
border-radius: $border-radius;
bottom: $bottom;
box-shadow: $box-shadow;
color: $color;
font-size: $font-size;
left: $left;
line-height: 1.5;
padding: $padding;
position: absolute;
right: $right;
top: $top;
}

I wasn’t happy with this because it has too many parameters. It would be hard to keep them straight in my head, and I prefer tidier code.

Second iteration

I grouped similar properties into separate mixins. My hope was that mixins with fewer parameters would be easier to manage and digest. Below is the first one which handles a tooltip’s position.

@mixin tooltip-position($top: false, $right: false, $bottom: false, $left: false) {
position: absolute;
@if $top != false {
top: $top;
}
@if $right != false {
right: $right;
}
@if $bottom != false {
bottom: $bottom;
}
@if $left != false {
left: $left;
}
@if $left == $right {
margin-left: auto;
margin-right: auto;
}
}

All parameters are optional so they aren’t compiled unless a value is specified.

With that done, I needed mixins to handle a tooltip’s visual aspects. For the pointer arrow I wanted to use a rotated ::after pseudo-element. Pseudo-elements allow you to style special parts of DOM elements. ::after is a great candidate here because it can be styled much like a span. The following snippet shows the mixins for creating the pointer and other tooltip styles.

// CREATE THE POINTER
@mixin pointer-base-styles {
&::after {
content: "";
position: absolute;
transform: rotate(45deg);
}
}
// BACKGROUND COLOR FOR THE TOOLTIP AND POINTER ARROW
@mixin tooltip-backgrounds($bg) {
&, &::after {
background: $bg;
}
}
// FONT STYLES
@mixin tooltip-text($color: false, $font-size: false, $line-height: false) {
color: $color;
font-size: $font-size;
line-height: $line-height;
}
// OTHER VISUAL TREATMENTS
@mixin tooltip-framing($padding: false, $border-radius: false, $shadow: false) {
@if $padding != false {
padding: $padding;
}
@if $border-radius != false {
border-radius: $border-radius;
}
@if $shadow != false {
box-shadow: $shadow;
}
}

So far so good. These mixins would be added to my tooltip’s CSS declaration block via @include.

Borders for the tooltip and pointer

You might have noticed that I haven’t addressed border styles. This was intentional; borders on the pointer affect how the pointer is positioned on a tooltip’s edge. Therefore, I needed a mixin that handles both the pointer’s position and any borders.

Before I get to that, I’d like to demonstrate how a pointer’s borders affect its position. Let’s say I have a tooltip design in which the pointer points left. I can use position: absolute; and left on the pointer to move its center to the tooltip’s left edge. If there are no borders, I can do some simple math in the left value to achieve this position.

A graphic of a tooltip with the CSS code that properly positions its pointer without borders
A graphic of a tooltip with the CSS code that properly positions its pointer without borders
Pointer Example 1: Pointer is 50% black so you can easily see its center. It lines up with the tooltip’s left edge.

If, however, there are borders, the code above won’t work because left doesn’t factor in the border width.

A graphic of a tooltip with the CSS code that unsatisfactorily positions its pointer with borders
A graphic of a tooltip with the CSS code that unsatisfactorily positions its pointer with borders
Pointer Example 2: The pointer’s borders jut into the tooltip’s body.

To fix this, I must include the border’s width in the left calculation. In addition it’s best to apply the same border on the pointer’s internal sides but with transparent as the color. This trick makes the new left calculation work consistently regardless of border width.

A graphic of a tooltip with the CSS code that properly positions its pointer with borders
A graphic of a tooltip with the CSS code that properly positions its pointer with borders
Pointer Example 3

This is better, but while testing the code, I found a visual issue if the design required a drop shadow. Any shadow would be applied to both the tooltip and pointer. Because the pointer is above the tooltip in the DOM stacking order, the pointer’s shadow unfortunately appeared over the tooltip.

A graphic of a tooltip with its CSS code demonstrating how a shadow on the pointer appears undesirably on the tooltip
A graphic of a tooltip with its CSS code demonstrating how a shadow on the pointer appears undesirably on the tooltip
Pointer Example 4

Because of this I decided to try the ::before pseudo-element to render the pointer’s shadow. Like its counterpart ::after, ::before can function as a separate element. I can manipulate its stacking order in the DOM with the goal of positioning it under the tooltip. With this in mind I started working on the next mixin.

Positioning the pointer

To position the pointer and its shadow (::after and ::before respectively), my next mixin must take into account the following factors:

After much tinkering I came up with the mixin below. It accepts an additional parameter cross-position that moves the pointer and shadow along the tooltip side. Visual examples of pointer positions follow the snippet.

@mixin pointer-position($size, $side, $cross-position, $border-width) {
$half-size: $size / 2;
height: $size;
width: $size;

// MOVE PSEUDO-ELEMENTS OUTSIDE OF TOOLTIP BODY
@if $border-width != 0 {
#{$side}: calc(#{-$half-size} - #{$border-width});
} @else {
#{$side}: -$half-size;
}

// MOVE PSEUDO-ELEMENTS ALONG TOOLTIP SIDE
@if $side == "left" or $side == "right" {
@if $cross-position == "middle" {
top: calc(50% - (#{$half-size} + #{$border-width}));
} @else {
top: $cross-position;
}
} @elseif $side == "bottom" or $side == "top" {
@if $cross-position == "middle" {
left: 0;
margin: auto;
right: 0;
} @else {
left: $cross-position;
}
}
}
A graphic showing two tooltips, each with a pointer sitting on the bottom edge but with different cross positions
A graphic showing two tooltips, each with a pointer sitting on the bottom edge but with different cross positions
Pointers sitting on the bottom tooltip side with different cross positions

Styling the pseudo-elements

Now I needed to apply visual treatments to the pseudo-elements. Since the pointer-position mixin takes an argument for side, I can leverage it within a new mixin to manage the pointer’s border display. How? I can rotate the ::after element differently based on the tooltip side. This will result in the pointer’s visible borders pointing in the correct direction. Below is the last mixin that does just that and also sets box-shadow.

// ADD TO TOOLTIP'S MAIN DIV DECLARATION BLOCK VIA @include
@mixin pointer-framing($size, $side, $cross-position, $border-width: 0, $border-style: false, $shadow: false) {
&::before {
z-index: -1; // Puts the shadow behind the tooltip
@if $shadow != false {
box-shadow: $shadow;
}
}
&::after, &::before {
@include pointer-position($size, $side, $cross-position, $border-width);
}
@if $border-width != 0 {
border: $border-width $border-style; // For the tooltip; including it in this mixin allows us to pass border arguments once for both tooltip and ::after.
&::after, &::before {
border: $border-width solid transparent;
}
&::after {
border-bottom: $border-width $border-style;
border-left: $border-width $border-style;
@if $side == "right" {
transform: rotate(225deg);
} @elseif $side == "bottom" {
transform: rotate(-45deg);
} @elseif $side == "top" {
transform: rotate(135deg);
}
}
}
}

I noticed that $shadow is a parameter here and in the mixin tooltip-framing I made earlier. The $shadow argument would likely be the same for both the tooltip and ::before, so I removed it from pointer-framing and updated the @if $shadow block in tooltip-framing.

@mixin tooltip-framing($padding: false, $border-radius: false, $shadow: false) {
@if $padding != false {
padding: $padding;
}
@if $border-radius != false {
border-radius: $border-radius;
}
@if $shadow != false {
&, &::before {
box-shadow: $shadow;
}
}
}
@mixin pointer-framing($size, $side, $cross-position, $border-width: 0, $border-style: false, $shadow: false) {
&::before {
z-index: -1; // Puts the shadow behind the tooltip
}
&::after, &::before {
@include pointer-position($size, $side, $cross-position, $border-width);
}
@if $border-width != 0 {
border: $border-width $border-style; // For the tooltip; including it in this mixin allows us to pass border arguments once for both tooltip and ::after.
&::after, &::before {
border: $border-width solid transparent;
}
&::after {
border-bottom: $border-width $border-style;
border-left: $border-width $border-style;
@if $side == "right" {
transform: rotate(225deg);
} @elseif $side == "bottom" {
transform: rotate(-45deg);
} @elseif $side == "top" {
transform: rotate(135deg);
}
}
}
}

I also updated the pointer-base-styles mixin so it includes the ::before element.

@mixin pointer-base-styles {
&::after, &::before {
content: "";
position: absolute;
transform: rotate(45deg);
}
}

Putting it all together

Below is a sample snippet using the mixins plus a screenshot of the resulting UI. You can also see live tooltips on CodePen. (Scroll down for an embedded demo.)

$denim: #A5C5EB;
$pointer-size: 1.6rem;
$shadow: .2rem .2rem .2rem rgba(0, 0, 0, .2);
.tooltip--button-info {
@include pointer-base-styles;
@include pointer-framing($pointer-size, bottom, middle, .3rem, solid darken($denim, 12%));
@include tooltip-backgrounds($denim);
@include tooltip-framing(1em, .4rem, $shadow);
@include tooltip-position(-7.5rem, 0, false, 0);
@include tooltip-text(darken($denim, 50%), 1.5rem, 1.25);
box-sizing: border-box;
text-align: center;
width: 90%;
}
<div class="tooltip--button-info">
<p>Vote for the candidates by clicking a button.</p>
</div>
A screenshot of three buttons and a tooltip above them
A screenshot of three buttons and a tooltip above them

But there’s a gotcha (and a workaround)

If your main tooltip div has a shadow plus the transform property, the shadow on the ::before will overlap the tooltip.

A screenshot of an alert tooltip in which its pointer’s shadow is overlapping the tooltip
A screenshot of an alert tooltip in which its pointer’s shadow is overlapping the tooltip

This looks very much like figure “Pointer Example 4” from earlier. With the transform property, the div becomes the new stacking context for its pseudo-elements. Therefore, the ::before can’t fall behind the tooltip, even with z-index: -1. You can get around this by wrapping your tooltip’s child elements in a new div then applying the following changes:

Below is an example of the workaround. My new div has the CSS class tooltip__body. Be aware that other scenarios can set your main div as a new stacking context, including position: fixed. Check out Mozilla’s article on stacking order for a list of these scenarios.

$alert: #EACBE4;
$pointer-size: 1.6rem;
$shadow: .2rem .2rem .2rem rgba(0, 0, 0, .2);
.tooltip--alert {
@include tooltip-position(-3.3rem, false, false, 0);
transform: translateX(calc(-100% - #{$pointer-size}));

.tooltip__body {
@include pointer-base-styles;
@include pointer-framing($pointer-size, right, 3.7rem);
@include tooltip-backgrounds($alert);
@include tooltip-framing(1em, .6rem, $shadow);
@include tooltip-text(darken($alert, 50%), 1.5rem, 1.25);
position: relative;
width: 25rem;
}
}
<div class="tooltip--alert tooltip">
<div class="tooltip__body">
<p><strong>WARNING</strong></p>
<p>This might cause an allergic reaction.</p>
</div>
</div>
A screenshot of an alert tooltip in which its pointer’s shadow is no longer overlapping the tooltip
A screenshot of an alert tooltip in which its pointer’s shadow is no longer overlapping the tooltip

Last thoughts

The mixins in this article allow you to customize your tooltips, but they also rely on a few assumptions:

You should, of course, make your own customizations as needed. If your tooltips share common styles (such as pointer size, padding, and rounded corners), you can declare those once under a base CSS class for all of them. If you work with designers, you should advocate for standardized designs. If it’s out of your hands, I hope these mixins will help you create manageable and tidier tooltip styles. Below is a GitHub gist with all the code.

Zoosk Engineering

Anything and everything the Zoosk engineering team is up…

Sue Anna Joe

Written by

HTML and CSS developer at Zoosk

Zoosk Engineering

Anything and everything the Zoosk engineering team is up to.

More From Medium

More from Zoosk Engineering

More on CSS from Zoosk Engineering

More on Browsers from Zoosk Engineering

More on Browsers from Zoosk Engineering

The Witchy World of Inputs

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade