Customizing Tooltips with the Power of Sass Mixins

Sue Anna Joe
Zoosk Engineering
Published in
9 min readMar 11, 2020
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
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
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
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
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:

  • the tooltip side on which the pointer is aligned (left, right, top, or bottom)
  • the pointer’s size
  • the pointer’s border width

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
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

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

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:

  • Move all tooltip and pointer mixins from the main div to the new wrapper div except for tooltip-position.
  • If the main div has width, move that to the new div as well.
  • Add position: relative; to the new div so it’s still the positioning context for the pseudo-elements.

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

Last thoughts

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

  • The light source for the drop shadow is the same for all your tooltips.
  • The pointer is square with no border-radius.
  • The portion of the pointer that sticks out past the tooltip’s edge is the same for all.

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.

--

--