How to Use a CSS Triangle to Make a “Tooltip” Arrow

Every tooltip needs a pointer that makes the connection between the label and the trigger crystal clear. Images and SVGs work, but they complicate theming and sizing. A CSS triangle gives you a crisp, scalable arrow with a single rule change. In this guide you will build a reusable tooltip arrow with pure CSS, cover four arrow directions, and learn the tricks to match the bubble’s border and background perfectly.

Why CSS Triangles for Tooltip Arrows Matter

Tooltips live everywhere: form hints, icon labels, and chart details. The arrow is a small element with a lot of visual responsibility. CSS triangles use borders to create a zero-width, zero-height box that renders as a perfect triangle, which means no images to load, no SVG markup, and no scaling issues on high-density screens. You can theme the arrow with the same variables as the tooltip body, maintain crisp edges at any size, and keep the DOM clean by generating the arrow with pseudo-elements. The technique also plays well with motion preferences and avoids animation jank.

Prerequisites

You will work with a basic HTML structure and a single tooltip component. The arrow will be rendered by pseudo-elements using the border triangle trick, and CSS variables will control size and colors for easy theming.

  • Basic HTML
  • CSS custom properties
  • CSS pseudo-elements (::before / ::after)

Step 1: The HTML Structure

Here is the complete markup for a small demo showing four placements. Each button is wrapped by a positioning container. The tooltip lives as a sibling element inside that container and uses role and id/aria attributes to link the two. The modifier classes control arrow direction and placement.

<!-- HTML -->
<div class="demo">
  <div class="tooltip-anchor">
    <button type="button" aria-describedby="tip-bottom">Bottom arrow</button>
    <div role="tooltip" id="tip-bottom" class="tooltip tooltip--bottom" data-show="true">
      I point down to the trigger
    </div>
  </div>

  <div class="tooltip-anchor">
    <button type="button" aria-describedby="tip-top">Top arrow</button>
    <div role="tooltip" id="tip-top" class="tooltip tooltip--top" data-show="true">
      I point up to the trigger
    </div>
  </div>

  <div class="tooltip-anchor">
    <button type="button" aria-describedby="tip-left">Left arrow</button>
    <div role="tooltip" id="tip-left" class="tooltip tooltip--left" data-show="true">
      I point left to the trigger
    </div>
  </div>

  <div class="tooltip-anchor">
    <button type="button" aria-describedby="tip-right">Right arrow</button>
    <div role="tooltip" id="tip-right" class="tooltip tooltip--right" data-show="true">
      I point right to the trigger
    </div>
  </div>
</div>

Step 2: The Basic CSS & Styling

The base styles set up a simple layout for the demo, a set of variables for consistent theming, and a tooltip block with positioning, padding, and a subtle border. The anchor is positioned relative to give the tooltip an offset reference. The tooltip starts visually hidden and will fade in using a state attribute later.

/* CSS */
:root {
  --tooltip-bg: #111;
  --tooltip-text: #fff;
  --tooltip-border: #2a2a2a;
  --tooltip-radius: 8px;
  --tooltip-pad-y: 8px;
  --tooltip-pad-x: 10px;
  --arrow-size: 8px;      /* length of the triangular leg */
  --arrow-gap: 2px;       /* space to visually separate arrow from body edge */
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji";
  color: #1b1b1b;
  line-height: 1.45;
  margin: 0;
  padding: 24px;
  background: #f7f7f8;
}

.demo {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 28px 20px;
  align-items: start;
}

.tooltip-anchor {
  position: relative;
  display: inline-block;
  padding: 40px 24px; /* extra space to park the tooltip */
  background: #fff;
  border: 1px solid #e6e6e8;
  border-radius: 12px;
  text-align: center;
  min-height: 120px;
}

.tooltip-anchor > button {
  padding: 8px 12px;
  border-radius: 6px;
  border: 1px solid #d5d5d8;
  background: #fff;
  cursor: pointer;
}

.tooltip {
  position: absolute;
  max-width: 220px;
  background: var(--tooltip-bg);
  color: var(--tooltip-text);
  border: 1px solid var(--tooltip-border);
  border-radius: var(--tooltip-radius);
  padding: var(--tooltip-pad-y) var(--tooltip-pad-x);
  font-size: 14px;
  line-height: 1.3;
  box-shadow: 0 8px 24px rgba(0,0,0,.15);
  z-index: 10;

  /* hidden by default, Step 5 will reveal with [data-show] */
  opacity: 0;
  pointer-events: none;
}

Advanced Tip: Keep arrow geometry in variables. When design asks for a sharper arrow or larger radius, you will tweak one value and preserve consistent spacing across all placements.

Step 3: Building the Arrow (Bottom Placement)

The arrow is drawn with two triangles layered on top of each other. The larger triangle produces the border “stroke,” and the smaller one sits above it to match the tooltip background. Both are absolutely positioned with zero width and height and use border tricks to render as triangles. This step covers the common bottom placement, which means the arrow points down toward the trigger.

/* CSS */
.tooltip {
  /* common pseudo-element defaults for any placement */
}
.tooltip::before,
.tooltip::after {
  content: "";
  position: absolute;
  width: 0;
  height: 0;
  border-style: solid;
  pointer-events: none;
}

/* Bottom placement: tooltip sits above the trigger, arrow points downward */
.tooltip--bottom {
  bottom: calc(100% + var(--arrow-gap));
  left: 50%;
  transform: translateX(-50%);
}

.tooltip--bottom::before,
.tooltip--bottom::after {
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(-1 * var(--arrow-size)); /* anchor arrow to the bottom edge */
}

/* Border triangle (larger) */
.tooltip--bottom::before {
  border-width: calc(var(--arrow-size) + 2px) calc(var(--arrow-size) + 2px) 0 calc(var(--arrow-size) + 2px);
  border-color: var(--tooltip-border) transparent transparent transparent;
}

/* Fill triangle (smaller) */
.tooltip--bottom::after {
  border-width: var(--arrow-size) var(--arrow-size) 0 var(--arrow-size);
  border-color: var(--tooltip-bg) transparent transparent transparent;
  bottom: calc(-1 * var(--arrow-size) + 2px); /* pull up to reveal border stroke */
}

How This Works (Code Breakdown)

The tooltip uses position: absolute so it can sit relative to the .tooltip-anchor box. That container is position: relative, which creates the positioning context for the bubble. For the bottom placement, the bubble is anchored above the trigger with bottom: calc(100% + var(–arrow-gap)). The arrow pseudo-elements sit on the bottom edge of the bubble and only draw borders. With width and height at zero, the visible area comes from the borders themselves.

The border-width values form a triangle by keeping one side non-zero and the others transparent. For a downward-pointing arrow, the top border is the visible face when you invert the reference, so we set border-width like “top heavy” and set border-color so that only the top side uses the desired color. If triangles are new to you, the pattern mirrors the approach in how to make a triangle up with CSS, only rotated based on the placement.

The double-layer approach solves the border seam. The ::before triangle is slightly larger and matches the tooltip border color. The ::after triangle is smaller and matches the tooltip background. By nudging the smaller triangle up by 2px, the larger one remains visible and creates a clean edge around the arrow that matches the bubble’s outline.

The left: 50% and transform: translateX(-50%) logic keeps the arrow centered under the bubble. You can offset it with a variable if the arrow needs to align with a specific element, like the middle of an icon.

Step 4: Building the Other Placements (Top, Left, Right)

With the bottom arrow complete, the other sides are simple rotations of the same idea. The position of the bubble flips, the arrow anchors to a different side, and the border-color assignments rotate to match the direction.

/* CSS */
/* Top placement: tooltip sits below the trigger, arrow points upward */
.tooltip--top {
  top: calc(100% + var(--arrow-gap));
  left: 50%;
  transform: translateX(-50%);
}

.tooltip--top::before,
.tooltip--top::after {
  left: 50%;
  transform: translateX(-50%);
  top: calc(-1 * var(--arrow-size));
}

.tooltip--top::before {
  border-width: 0 calc(var(--arrow-size) + 2px) calc(var(--arrow-size) + 2px) calc(var(--arrow-size) + 2px);
  border-color: transparent transparent var(--tooltip-border) transparent;
}

.tooltip--top::after {
  border-width: 0 var(--arrow-size) var(--arrow-size) var(--arrow-size);
  border-color: transparent transparent var(--tooltip-bg) transparent;
  top: calc(-1 * var(--arrow-size) + 2px);
}

/* Left placement: tooltip sits to the right, arrow points left */
.tooltip--left {
  right: calc(100% + var(--arrow-gap));
  top: 50%;
  transform: translateY(-50%);
}

.tooltip--left::before,
.tooltip--left::after {
  top: 50%;
  transform: translateY(-50%);
  left: calc(100% - 1px); /* push to the right edge so arrow meets the body */
}

.tooltip--left::before {
  border-width: calc(var(--arrow-size) + 2px) calc(var(--arrow-size) + 2px) calc(var(--arrow-size) + 2px) 0;
  border-color: transparent var(--tooltip-border) transparent transparent;
}

.tooltip--left::after {
  border-width: var(--arrow-size) var(--arrow-size) var(--arrow-size) 0;
  border-color: transparent var(--tooltip-bg) transparent transparent;
  left: calc(100% - 1px); /* slight overlap to avoid a hairline gap */
}

/* Right placement: tooltip sits to the left, arrow points right */
.tooltip--right {
  left: calc(100% + var(--arrow-gap));
  top: 50%;
  transform: translateY(-50%);
}

.tooltip--right::before,
.tooltip--right::after {
  top: 50%;
  transform: translateY(-50%);
  right: calc(100% - 1px);
}

.tooltip--right::before {
  border-width: calc(var(--arrow-size) + 2px) 0 calc(var(--arrow-size) + 2px) calc(var(--arrow-size) + 2px);
  border-color: transparent transparent transparent var(--tooltip-border);
}

.tooltip--right::after {
  border-width: var(--arrow-size) 0 var(--arrow-size) var(--arrow-size);
  border-color: transparent transparent transparent var(--tooltip-bg);
  right: calc(100% - 1px);
}

How This Works (Code Breakdown)

Top placement flips the logic from Step 3. The bubble now sits below the trigger. The arrow attaches to the top edge and uses the bottom border of the triangle. For left and right placements, the strong border side moves to the left or right. Each rule repositions the bubble with top/left/right/bottom and then anchors the arrow’s pseudo-elements to the corresponding edge, centered vertically or horizontally as needed.

If you want a refresher on the orientation patterns, the recipes in how to make a triangle right with CSS show the border-color permutations for each direction. The approach here simply wraps those triangles in pseudo-elements and aligns them with the tooltip box.

The small -1px offsets keep the arrow and the bubble’s border fused on all displays. Subpixel rounding can reveal a hairline gap on high-density screens; the overlap avoids that without visible artifacts.

Advanced Techniques: Showing, Hiding, and Animating

The demo uses a data attribute to toggle visibility, but the same CSS works for hover, focus, or a JavaScript-controlled state. This block adds a subtle fade and a short directional slide to reinforce the placement. It also respects reduced motion preferences.

/* CSS */
/* Start hidden */
.tooltip {
  opacity: 0;
  transform-origin: 50% 50%;
  transition: opacity 120ms ease, transform 120ms ease;
}

/* Show state */
.tooltip[data-show="true"] {
  opacity: 1;
  pointer-events: auto;
}

/* Directional nudge based on placement */
.tooltip--bottom { transform: translateX(-50%) translateY(4px); }
.tooltip--bottom[data-show="true"] { transform: translateX(-50%) translateY(0); }

.tooltip--top { transform: translateX(-50%) translateY(-4px); }
.tooltip--top[data-show="true"] { transform: translateX(-50%) translateY(0); }

.tooltip--left { transform: translateY(-50%) translateX(-4px); }
.tooltip--left[data-show="true"] { transform: translateY(-50%) translateX(0); }

.tooltip--right { transform: translateY(-50%) translateX(4px); }
.tooltip--right[data-show="true"] { transform: translateY(-50%) translateX(0); }

/* Respect motion preferences */
@media (prefers-reduced-motion: reduce) {
  .tooltip {
    transition: none;
  }
}

Accessibility & Performance

A tooltip should be helpful without getting in the way. The arrow is purely decorative; the message content carries the meaning. Good semantics and input coverage go a long way here.

Accessibility

Use role=”tooltip” on the bubble and link it from the trigger with aria-describedby. That connection ensures screen readers announce the content. For keyboard users, reveal the tooltip on focus and keep it available while focus remains on the trigger. If the tooltip content is not needed by assistive tech at all times, switch aria-hidden between true and false in sync with visibility. Place the tooltip after the trigger in the DOM so focus order makes sense.

Mouse users expect the tooltip on hover; touch users need a tap strategy or no tooltip at all. For mobile, consider an inline help pattern or a popover that the user can dismiss. Respect motion preferences by removing transitions and keyframes for users who prefer reduced motion, which the CSS above handles with a media query.

Performance

Border-based triangles are fast to paint and scale well, so they suit large interfaces with many tooltips. The approach avoids images, extra HTTP requests, and SVG parsing. Keep transitions short and avoid heavy box-shadow animations. You do not need will-change for a simple opacity/transform transition here; overusing it can degrade rendering on memory-limited devices. If a page hosts dozens of live tooltips, render them on demand instead of keeping them all in the DOM with data-show=”true”.

Sharpen Your UI Without Extra Markup

You built a tooltip arrow with a layered CSS triangle, matched the bubble’s border, and shipped four placements with the same pattern. The variables make theme changes simple, and the pseudo-elements keep the DOM lean. For more shape recipes that pair well with this component, explore the full recipe for a tooltip container in how to make a tooltip shape with CSS or revisit the triangle basics in the links above when you need a new orientation.

Leave a Comment