CSS Tooltip Directions
Tooltips pointing top, right, bottom, and left using ::before and ::after pseudo-elements.
A CSS tooltip in four directions needs no extra markup at all: ::before draws the bubble, ::after draws the arrow, and a data-pos attribute on the trigger picks which side they sit on. The text comes from a second attribute through content: attr(data-tip), so a tooltip is one span with two attributes, and the stylesheet handles everything else.
That compactness has a cost, and it is worth knowing before you use this on anything that matters. Generated content is not real text, hover is not available to keyboards or touchscreens, and a bubble with pointer-events: none cannot be read by anyone who needs to move their pointer onto it. Two versions below deal with that: one built from real markup that responds to focus, and one with a delay before it appears and text that wraps.
Four directions off one trigger
HTML
<div class="td-grid">
<span class="td-tip" data-tip="Tooltip above" data-pos="top">Top ↑</span>
<span class="td-tip" data-tip="Tooltip to the right" data-pos="right">Right →</span>
<span class="td-tip" data-tip="Tooltip below" data-pos="bottom">Bottom ↓</span>
<span class="td-tip" data-tip="Tooltip to the left" data-pos="left">← Left</span>
</div>
CSS
.td-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
max-width: 360px;
margin: 0 auto;
}
/* position: relative is what the bubble and arrow anchor to.
Without it they would position against the page. */
.td-tip {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
padding: .6rem 1rem;
background: #1c1c1e;
border: 1px solid #2a2a2d;
border-radius: 8px;
font-size: .875rem;
font-weight: 500;
color: #f0f0f0;
cursor: default;
}
/* Bubble */
.td-tip::before {
content: attr(data-tip);
position: absolute;
z-index: 10;
padding: .35rem .65rem;
background: #f0f0f0;
color: #0c0c0d;
font-size: .78rem;
font-weight: 400;
white-space: nowrap;
border-radius: 6px;
pointer-events: none;
opacity: 0;
transition: opacity .2s, transform .2s;
}
/* Arrow: a zero-size box where one border has color. */
.td-tip::after {
content: '';
position: absolute;
z-index: 10;
border: 5px solid transparent;
pointer-events: none;
opacity: 0;
transition: opacity .2s;
}
.td-tip:hover::before,
.td-tip:hover::after {
opacity: 1;
}
/* Top */
[data-pos="top"]::before {
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateY(4px);
}
[data-pos="top"]:hover::before {
transform: translateX(-50%) translateY(0);
}
[data-pos="top"]::after {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-top-color: #f0f0f0;
}
/* Bottom */
[data-pos="bottom"]::before {
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateY(-4px);
}
[data-pos="bottom"]:hover::before {
transform: translateX(-50%) translateY(0);
}
[data-pos="bottom"]::after {
top: 100%;
left: 50%;
transform: translateX(-50%);
border-bottom-color: #f0f0f0;
}
/* Right */
[data-pos="right"]::before {
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%) translateX(-4px);
}
[data-pos="right"]:hover::before {
transform: translateY(-50%) translateX(0);
}
[data-pos="right"]::after {
left: 100%;
top: 50%;
transform: translateY(-50%);
border-right-color: #f0f0f0;
}
/* Left */
[data-pos="left"]::before {
right: calc(100% + 8px);
top: 50%;
transform: translateY(-50%) translateX(4px);
}
[data-pos="left"]:hover::before {
transform: translateY(-50%) translateX(0);
}
[data-pos="left"]::after {
right: 100%;
top: 50%;
transform: translateY(-50%);
border-left-color: #f0f0f0;
}
Other ways to build it
Real markup, so focus and pointers both work
Moving the bubble out of a pseudo-element and into a <span role="tooltip"> fixes three things at once. The text becomes part of the document, so it can be selected, translated, and pointed at with aria-describedby. The trigger becomes a <button>, so it is focusable and the tooltip can respond to :focus-visible. And because the hover rule sits on the wrapper rather than the trigger, the bubble stays open while the pointer is on it, which is what WCAG means by hoverable. The 8px gap is padding on the bubble rather than a margin, so there is no dead space to cross on the way there. Tab to the button below, then hover it.
HTML
<span class="td-real">
<button type="button" class="td-real-trigger" aria-describedby="tooltip-directions-desc">Export</button>
<span class="td-real-bubble" role="tooltip" id="desc">
<span class="td-real-bubble-inner">Downloads a CSV of every row in the current view, filters included.</span>
</span>
</span>
CSS
.td-real {
position: relative;
display: inline-block;
}
.td-real-bubble {
position: absolute;
bottom: 100%;
left: 50%;
z-index: 10;
/* the gap is padding on the bubble, so the pointer never
crosses dead space between the trigger and the tooltip */
padding-bottom: 8px;
width: max-content;
max-width: 220px;
opacity: 0;
visibility: hidden;
transform: translateX(-50%) translateY(4px);
transition: opacity .2s, transform .2s, visibility 0s .2s;
}
.td-real-bubble-inner {
display: block;
position: relative;
padding: .4rem .7rem;
border-radius: 6px;
background: #f0f0f0;
color: #0c0c0d;
font-size: .78rem;
line-height: 1.4;
}
/* top: 100% puts the arrow's flat edge on the bubble's bottom */
.td-real-bubble-inner::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: #f0f0f0;
}
/* hover on the wrapper, so moving onto the bubble keeps it open */
.td-real:hover .td-real-bubble,
.td-real-trigger:focus-visible + .td-real-bubble {
opacity: 1;
visibility: visible;
transform: translateX(-50%) translateY(0);
transition: opacity .2s, transform .2s, visibility 0s;
}
A delay before it appears, and text that wraps
Two changes make a row of tooltips bearable. Putting transition-delay on the hover rule only means the bubble waits half a second before appearing but disappears the instant the pointer leaves, so traveling across a toolbar no longer sets off a trail of tooltips. And replacing white-space: nowrap with width: max-content plus a max-width lets a longer message wrap into a block rather than growing sideways until it leaves the screen. Hover one of these and wait.
HTML
<span class="td-slow" data-tip="Half a second of hover before this appears.">
Hover and wait
</span>
CSS
.td-slow::before {
content: attr(data-tip);
position: absolute;
top: calc(100% + 8px);
left: 50%;
transform: translateX(-50%) translateY(-4px);
/* max-content plus a cap, instead of nowrap */
width: max-content;
max-width: 200px;
white-space: normal;
text-align: center;
padding: .4rem .7rem;
border-radius: 6px;
background: #f0f0f0;
color: #0c0c0d;
font-size: .78rem;
line-height: 1.4;
opacity: 0;
pointer-events: none;
z-index: 10;
/* no delay here, so it leaves immediately */
transition: opacity .15s, transform .15s;
}
.td-slow:hover::before {
opacity: 1;
transform: translateX(-50%) translateY(0);
/* the wait lives on the visible state only */
transition-delay: .5s;
}
How it works
The tooltip bubble (::before) and arrow (::after) are positioned relative to the trigger element using a combination of top, bottom, left, right, and translate. Each direction shifts the bubble to the appropriate side. The arrow is a zero-size element with colored borders: only one border has a color, the rest are transparent, and the result is a triangle pointing toward the trigger. A slide-in transform transition adds polish.
position: relative on the trigger is the load bearing line, and it is the one people leave out. An absolutely positioned element anchors to its nearest positioned ancestor, so without it the bubble skips the trigger entirely and positions against whatever is above it in the tree, or against the page. The symptom is a tooltip that appears in a corner of the screen rather than beside the thing it describes, which looks like a broken calculation rather than a missing declaration.
The arrow is a rectangle with no content and a 5px border on all four sides. Because adjacent borders meet on a 45 degree diagonal, a zero by zero box with four borders is four triangles sharing a point. Color exactly one of them and the other three stay transparent, which leaves a single triangle 10px wide and 5px tall. Which border you color decides which way it points, and it is the opposite of what most people guess: border-top-color produces a triangle pointing down, because the top border tapers toward the box's center.
Every direction has to restate the centering transform. The bubble above the trigger sits at left: 50%, which aligns its left edge with the trigger's center, so translateX(-50%) pulls it back by half its own width. The slide-in adds translateY(4px) to that, and transform is a single property holding an ordered list, so the hover rule has to write translateX(-50%) translateY(0) in full. Write only the translateY and the centering disappears, and the bubble jumps half its width to the right at the moment it becomes visible.
pointer-events: none on both pseudo-elements prevents a flicker loop. A bubble drawn under the cursor would take the hover away from the trigger, which hides the bubble, which gives the hover back to the trigger, which shows it again. That fixes the flicker and creates a different problem, which is that nobody can put their pointer on the tooltip to read it. WCAG asks for exactly that, so a long or important message needs the second approach shown below rather than this one.
The hard limit of the pure CSS version is that it cannot see the viewport. A tooltip on a trigger near the right edge runs off the screen, and a tooltip inside any ancestor with overflow: hidden or overflow: auto is clipped by it, both without a hint that anything went wrong. There is no CSS that flips the bubble to the other side when it will not fit. CSS anchor positioning adds that ability with position-try, though Can I Use has it at Chrome 125 and much later elsewhere, so for now the practical answer is to pick a direction that has room, or use a script.
CSS properties used
contentattr(data-tip)pulls the tooltip text out of an attribute. Insidecontentthis works everywhere; usingattr()for any other property is far newer.positionrelativeon the trigger creates the containing block.absoluteon both pseudo-elements takes them out of flow so they cannot affect the trigger's size.border-color- Coloring one side of a zero-size box with four borders leaves a single triangle. Which side you color is which way it points.
transform- One ordered list, not several properties. Centering and sliding both live in it, so any rule that changes one has to restate the other.
pointer-eventsnonekeeps the bubble from stealing hover from the trigger. It also stops the reader from putting a pointer on the tooltip, which is a real cost rather than a free win.white-spacenowrapkeeps a short tooltip on one line. It is also why a long one runs off the screen instead of wrapping, which the delayed variant below fixes withmax-width.z-index- Lifts the bubble above nearby content. It only applies within the current stacking context, so a sibling with a
transformor a partialopacitycan still cover it.
Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---|---|---|---|---|
content on pseudo-elements | 4 | 2 | 3.1 | 12 |
2D transforms | 4 | 3.5 | 3.1 | 12 |
CSS transitions | 4 | 5 | 5.1 | 12 |
CSS grid | 57 | 52 | 10.1 | 16 |
CSS anchor positioning | 125 | 147 | 26 | 125 |
Everything the four tooltips above rely on has been supported for well over a decade. Two things are worth separating out. attr() inside content is part of generated content support and is safe everywhere, but attr() used for any other property is tracked separately by Can I Use as CSS3 attr, and it stands at Chrome 133 with no support in Firefox or Safari, so width: attr(data-w px) is not something to reach for yet. The anchor positioning row is included because it is the feature that will eventually let a tooltip flip itself when it would overflow the viewport, which is the one thing this technique genuinely cannot do today.
Accessibility notes
A hover-only tooltip reaches neither keyboard nor touch. A <span> cannot receive focus, so there is no way to show these from the keyboard at all, and on a touchscreen the first tap fires a synthetic hover that usually cancels on the next touch. Make the trigger a <button> or add tabindex="0", then show the tooltip on :focus-visible as well as :hover. The first variant below does both.
Generated content is not in the document text. It cannot be selected or copied, it is not translated by page translation tools, and screen reader support for reading content varies by engine and by verbosity setting. Anything a reader is required to understand belongs in real markup, referenced with aria-describedby so it is announced as a description of the control rather than as loose text.
WCAG 1.4.13 asks that content shown on hover or focus be dismissible, hoverable, and persistent. A pure CSS tooltip fails two of those three. There is no Escape key handling, and pointer-events: none means the reader cannot move onto the bubble to read a longer message, which is exactly what hoverable requires. Keeping tooltips to a few words is a partial answer. Real markup that the pointer can rest on is a better one.
What you can build with it
- Icon-only controls. Toolbar buttons whose meaning is not obvious from the glyph. This is the case tooltips exist for, and the one where a real accessible name matters most.
- Abbreviations and jargon. Expanding a term inline without sending the reader to a glossary. Consider
<abbr>with atitlefirst, since browsers handle it natively. - Truncated table cells. Showing the full value of a cell that has been shortened with an ellipsis. Note that the table cell's own overflow rules are usually what clip the tooltip.
- Form field hints. Short format notes next to an input, though a persistent hint under the field is easier to read and does not vanish while the reader is typing.
- Chart labels. Naming a bar or a slice on hover. A directional tooltip is useful here because the correct side changes depending on where the element sits in the chart.
Mistakes worth avoiding
- Leaving
position: relativeoff the trigger. Both pseudo-elements anchor to some ancestor further up instead, and the tooltip appears somewhere unrelated to the thing it belongs to. - Forgetting to restate
translateX(-50%)in the hover rule. The bubble slides into place and jumps sideways by half its width at the same moment, becausetransformis one property and the second rule replaced the whole list. - Putting the tooltip inside a container with
overflow: hidden. It is clipped at the boundary with nothing logged, and this is by far the most common reason a correctly written tooltip appears to be missing a chunk. - Relying on
z-indexalone. It only orders elements inside the current stacking context, so a nearby element with atransform, afilter, or an opacity below 1 creates its own context and can cover the tooltip regardless of the number. - Keeping
white-space: nowrapfor a long message. The bubble grows sideways past the edge of the screen. Swap it formax-widthwith normal wrapping once the text runs past a few words.
Frequently asked questions
How do I make a CSS tooltip appear on the left or right?
left: calc(100% + 8px) and top: 50% with translateY(-50%), and the arrow takes left: 100% with border-right-color set. Each direction is one pair of rules, and the arrow's colored border is always the side facing away from the trigger.Why is my CSS tooltip cut off?
overflow: hidden or overflow: auto is clipping it. That includes scroll containers, cards with rounded corners that hide overflow, and table wrappers. The fix is to move the tooltip out of the clipping ancestor, which usually means it can no longer be a pseudo-element of the trigger.Can a CSS tooltip flip when it would go off screen?
position-try for exactly this, but Can I Use puts it at Chrome 125 and considerably later in other engines, so today the choice is a direction with guaranteed room or a script.Are CSS tooltips accessible?
pointer-events: none breaks the requirement that a reader be able to move onto the tooltip to read it. A focusable trigger plus real markup referenced with aria-describedby fixes all three.How do I delay a tooltip so it does not flash while the pointer crosses it?
transition-delay on the visible state only. A delay on the hover rule and none on the base rule means the bubble waits before appearing and leaves immediately, which is what stops a row of icons from firing tooltips as the pointer travels across them.