CSS3 tooltip: show and hide hint text with a transition
The title attribute gives you a free tooltip, but you cannot style it, it appears after a delay you do not control, and it never shows for keyboard or touch users. A small amount of HTML and CSS produces a hint that fades in on hover and on focus, sits where you want it and is announced by screen readers.
The markup
The trigger and the tooltip text are both real elements. The trigger points to the text with aria-describedby, so assistive technology reads the hint as a description of the control.
<span class="tip">
<button type="button" class="tip-trigger" aria-describedby="tip-cvv">What is this?</button>
<span class="tip-text" role="tooltip" id="tip-cvv">The three digits printed on the back of the card.</span>
</span>
The CSS
.tip {
position: relative; /* the tooltip is positioned against this box */
display: inline-block;
}
.tip-text {
position: absolute;
bottom: calc(100% + 10px); /* above the trigger, with room for the arrow */
left: 50%;
width: max-content;
max-width: 16rem;
padding: 0.5rem 0.75rem;
border-radius: 6px;
background: #222;
color: #fff;
font-size: 0.875rem;
line-height: 1.4;
text-align: center;
opacity: 0;
visibility: hidden;
transform: translate(-50%, 4px);
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s linear 0.2s;
pointer-events: none;
z-index: 10;
}
/* the arrow */
.tip-text::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
margin-left: -6px;
border: 6px solid transparent;
border-top-color: #222;
}
/* show on hover and when the trigger has keyboard focus */
.tip:hover .tip-text,
.tip:focus-within .tip-text {
opacity: 1;
visibility: visible;
transform: translate(-50%, 0);
transition: opacity 0.2s ease, transform 0.2s ease, visibility 0s linear 0s;
}
@media (prefers-reduced-motion: reduce) {
.tip-text, .tip:hover .tip-text, .tip:focus-within .tip-text {
transition-duration: 0.01ms;
transform: translate(-50%, 0);
}
}
How each part works
- Positioning.
position: relativeon the wrapper makes it the containing block.bottom: calc(100% + 10px)places the tooltip's bottom edge ten pixels above the wrapper's top.left: 50%plustranslate(-50%, …)centres it horizontally whatever its width. - Width.
max-contentlets short hints stay on one line;max-widthwraps longer ones. - The fade. Opacity and a small vertical slide are transitioned together. Both are compositor-friendly, so the animation stays smooth.
- Visibility. As in the fade article,
visibilityswitches immediately when showing and after the fade when hiding, so an invisible tooltip is never read or hovered. pointer-events: none. The tooltip cannot steal the mouse from the trigger, which would otherwise make it flicker when the pointer crosses it.- The arrow. A zero-size box with thick borders is a triangle: only the top border is coloured, so it points down at the trigger.
:focus-within. The tooltip also opens when the button inside has focus, which covers keyboard users and most touch devices (a tap focuses a button).
Dismissing with Escape
Accessibility guidance (WCAG 2.1 success criterion 1.4.13, "Content on Hover or Focus") asks that extra content shown on hover or focus can be dismissed without moving the pointer or focus, usually with Escape. A few lines of script add that:
document.addEventListener("keydown", function (e) {
if (e.key !== "Escape") return;
document.querySelectorAll(".tip").forEach(function (tip) {
tip.classList.add("tip-dismissed");
});
});
document.addEventListener("focusin", function (e) {
var tip = e.target.closest(".tip");
if (tip) tip.classList.remove("tip-dismissed");
});
document.addEventListener("mouseover", function (e) {
var tip = e.target.closest(".tip");
if (tip) tip.classList.remove("tip-dismissed");
});
.tip.tip-dismissed .tip-text {
opacity: 0;
visibility: hidden;
}
The same criterion asks that the tooltip stays visible while the pointer is over it. With pointer-events: none the pointer passes through, which is acceptable for short hints; if your tooltips contain links, remove that rule and add an invisible bridge (padding) between trigger and tooltip so the pointer can travel into it.
Placement variants
/* below the trigger */
.tip--below .tip-text { bottom: auto; top: calc(100% + 10px); }
.tip--below .tip-text::after { top: auto; bottom: 100%; border-top-color: transparent; border-bottom-color: #222; }
/* to the right */
.tip--right .tip-text {
bottom: auto; left: calc(100% + 10px); top: 50%;
transform: translate(4px, -50%);
}
.tip--right:hover .tip-text,
.tip--right:focus-within .tip-text { transform: translate(0, -50%); }
.tip--right .tip-text::after {
top: 50%; left: auto; right: 100%; margin: -6px 0 0;
border-top-color: transparent; border-right-color: #222;
}
Pure CSS cannot detect that a tooltip would leave the viewport. Near screen edges, choose the variant by hand, or use a small positioning library. Newer CSS anchor positioning will handle this natively once support is broad enough.
With ASP.NET form controls
Server controls render ordinary inputs, so you can wrap them the same way. Point aria-describedby at the hint through the Attributes collection, because TextBox has no property for it:
<span class="tip">
<asp:Label runat="server" AssociatedControlID="txtCvv" Text="Security code" />
<asp:TextBox ID="txtCvv" runat="server" MaxLength="4" Columns="4"
inputmode="numeric" autocomplete="cc-csc" aria-describedby="tip-cvv" />
<span class="tip-text" role="tooltip" id="tip-cvv">The three or four digits printed on the card.</span>
</span>
Unknown attributes written in the markup of a server control, such as inputmode and aria-describedby here, are passed through to the rendered HTML unchanged. From the code-behind, use txtCvv.Attributes["aria-describedby"] = "tip-cvv";. If the hint is essential to fill the field in correctly, consider showing it permanently under the field instead: a tooltip is for optional help.
When not to use a tooltip
- For error messages: show them in the page next to the field, and connect them with
aria-describedbytoo. - For long or rich content with links or buttons: use a disclosure panel or a dialog.
- On disabled buttons: they cannot receive focus, so keyboard users never see the hint.