Customizing Tooltips with the Power of Sass Mixins
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.
If, however, there are borders, the code above won’t work because left
doesn’t factor in the border width.
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.
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.
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;
}
}
}
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>
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.
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 wrapperdiv
except fortooltip-position
. - If the main
div
haswidth
, move that to the newdiv
as well. - Add
position: relative;
to the newdiv
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>
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.