You have seen CSS triangles everywhere: carets next to dropdowns, tooltip tails, ribbon notches, and arrows. Many snippets call it the “border triangle” hack, but few explain the geometry. In this guided build, you will learn exactly why a zero-size box plus borders creates a triangle, how to control its direction and dimensions, and how to ship dependable UI pieces powered by this trick.
Why the Border Triangle Hack Matters
It removes the need for images or extra SVGs for small pointers and affordances. A triangle made from borders costs no HTTP requests, can inherit text color, and can be scaled with font-size. It is supported across evergreen browsers and even older ones. While clip-path and SVGs are great for complex silhouettes, border triangles excel at simple directional indicators and decorative notches that must be small, crisp, and themeable.
Prerequisites
You only need comfort with basic HTML and CSS. Variables help keep colors and sizes consistent, and pseudo-elements are useful to attach triangles without extra markup.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
We will build three small components that showcase border triangles: a dropdown caret, a ribbon with a pointer notch, and a tooltip with a tail. The markup stays lean and semantic. Aria attributes mark decorative triangles as hidden from assistive tech.
<!-- HTML -->
<div class="stage">
<button class="btn" aria-expanded="false">
Menu
<span class="caret" aria-hidden="true"></span>
</button>
<div class="ribbon">
Sale
<span class="ribbon__notch" aria-hidden="true"></span>
</div>
<div class="tooltip" role="tooltip" id="tip-1">
<span class="tooltip__bubble">
Saved!
<span class="tooltip__arrow" aria-hidden="true"></span>
</span>
</div>
</div>
Step 2: The Basic CSS & Styling
Set up variables, base layout, and a neutral surface for the components. The stage uses grid to separate examples. Buttons and ribbons share the same accent color so you can see inheritance and theme changes apply cleanly.
/* CSS */
:root {
--bg: #0f172a; /* slate-900 */
--panel: #111827; /* gray-900 */
--ink: #e5e7eb; /* gray-200 */
--muted: #94a3b8; /* slate-400 */
--accent: #22d3ee; /* cyan-400 */
--accent-ink: #062028; /* dark cyan for contrast on accent */
--border: #1f2937; /* gray-800 */
--radius: 10px;
--space: 12px;
}
* { box-sizing: border-box; }
html, body {
height: 100%;
background: var(--bg);
color: var(--ink);
font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
}
.stage {
display: grid;
gap: 24px;
padding: 32px;
max-width: 740px;
margin: 0 auto;
background: linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.0));
border-radius: var(--radius);
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: 1px solid var(--border);
background: var(--panel);
color: var(--ink);
border-radius: 8px;
cursor: pointer;
}
.ribbon {
position: relative;
display: inline-block;
background: var(--accent);
color: var(--accent-ink);
padding: 8px 18px 8px 12px;
border-radius: 6px;
font-weight: 600;
}
.tooltip {
display: inline-block;
}
.tooltip__bubble {
position: relative;
display: inline-block;
background: var(--panel);
color: var(--ink);
border: 1px solid var(--border);
padding: 8px 10px;
border-radius: 8px;
}
Advanced Tip: Keep triangle sizing in
emunits when the triangle sits inside text (like a caret). This lets the triangle scale with font-size and maintain visual balance across headings and buttons.
Step 3: Building the Caret
The caret sits to the right of the word “Menu.” It is a down-pointing isosceles triangle created by coloring the top border of a zero-size element. The left and right borders are transparent so you only see the colored side.
/* CSS */
.caret {
width: 0;
height: 0;
/* The triangle: two transparent sides + one colored side */
border-left: 0.35em solid transparent;
border-right: 0.35em solid transparent;
border-top: 0.45em solid currentColor; /* points downward */
display: inline-block;
translate: 0 1px; /* optical alignment with text baseline */
transition: transform 160ms ease;
}
/* Rotate the caret when the button is expanded */
.btn[aria-expanded="true"] .caret {
transform: rotate(180deg);
}
How This Works (Code Breakdown)
The key is width: 0 and height: 0. This collapses the element’s content box to a single point. When you add borders to a point, the borders meet at the center and extend outward. Each border becomes a right triangle. When three sides are transparent and one side has color, only that colored triangle remains visible.
border-left and border-right set the “base half-widths.” Their sum equals the triangle base length. border-top sets the triangle height. In this caret, the top border is colored, so the shape points downward. If you want a triangle that points upward, color the bottom border instead. For a reference pattern, see this guide on making a triangle pointing up with CSS.
currentColor inherits from the button’s text color. That makes the caret theme-aware without extra variables. You can turn the caret by rotating the element. Rotation is usually smoother and clearer than swapping borders on state change. The short transition on transform gives a crisp, responsive feel when toggling aria-expanded.
The geometry is stable and predictable: base = border-left + border-right, height = the colored border width. This simple model reduces trial and error when you need a specific proportion. If you need asymmetry or a right-angled triangle pointer for a special design, this pattern is still friendly. A pattern for a right‑angled triangle in CSS uses the same idea with one adjacent border set to 0.
Step 4: Building the Ribbon Notch and Tooltip Tail
Two classic uses of border triangles are a ribbon pointer and a tooltip tail. Both rely on absolute positioning and one colored border edge.
/* CSS */
/* Ribbon pointer: a small right-pointing triangle attached to the label */
.ribbon__notch {
position: absolute;
top: 50%;
right: -10px; /* pull the triangle out from the pill */
transform: translateY(-50%);
width: 0;
height: 0;
border-top: 10px solid transparent;
border-bottom: 10px solid transparent;
border-left: 10px solid var(--accent); /* points to the right */
filter: drop-shadow(0 0 0 rgba(0,0,0,0.0)); /* keeps subpixel edges crisp */
}
/* Tooltip tail: down-pointing triangle under the bubble */
.tooltip__arrow {
position: absolute;
top: 100%; /* anchor below the bubble box */
left: 16px;
width: 0;
height: 0;
border-left: 8px solid transparent;
border-right: 8px solid transparent;
border-top: 8px solid var(--panel); /* points downward */
}
/* Match the bubble stroke by layering a thin outline triangle under it */
.tooltip__arrow::before {
content: "";
position: absolute;
top: -9px; /* 1px above to mimic the bubble border thickness */
left: -8px;
width: 0;
height: 0;
border-left: 9px solid transparent;
border-right: 9px solid transparent;
border-top: 9px solid var(--border);
z-index: -1; /* sit behind the main arrow */
}
How This Works (Code Breakdown)
For the ribbon, the label is a rectangle with rounded corners. The notch sits halfway up, hugging the right edge. The triangle points to the right because the left border is colored. Since the notch is purely decorative, it carries aria-hidden="true". Keep the offset small so the notch feels attached to the label and not detached in space.
For the tooltip, the arrow is anchored to the bubble with top: 100%. The arrow’s apex touches the bottom edge of the bubble. If your bubble has a stroke, a single triangle will not show a stroke on the arrow. A neat trick is to layer two triangles: the larger one uses the stroke color and sits behind; the smaller one uses the fill and sits above. That is what the ::before pseudo-element does here with a 1px difference. Both triangles share the same point, so they appear as a tailed box with a continuous border.
To change direction, swap which border is colored and reposition. For a top-positioned tooltip, color the bottom border, and move it to bottom: 100%. Arrows that need a larger base just increase the transparent side borders. Save these tiny recipes; you will reuse them across tags, banners, and toasts. If you want a full component write-up, this tutorial pairs well with building a CSS tooltip shape.
Note: Anti-aliasing on diagonal edges can vary by device pixel ratio. If a triangle edge looks a bit soft, try integer border widths, avoid fractional transforms, and test at the zoom levels your users will use most.
Advanced Techniques: Animations & Hover Effects
Triangles react well to transforms. Instead of tweaking border widths live, which can cause relayout, prefer transforms and opacity for smoother motion. A common pattern is a caret that rotates when a menu opens, and a tooltip arrow that nudges slightly on hover.
/* CSS */
/* Caret rotation on expand */
.btn .caret { transition: transform 160ms ease; }
.btn[aria-expanded="true"] .caret { transform: rotate(180deg); }
/* Tooltip arrow nudge */
@keyframes nudge {
0% { transform: translateY(0); }
60% { transform: translateY(2px); }
100% { transform: translateY(0); }
}
.tooltip:hover .tooltip__arrow {
animation: nudge 420ms ease-out;
}
/* Respect reduced motion preferences */
@media (prefers-reduced-motion: reduce) {
.btn .caret,
.tooltip__arrow {
transition: none;
animation: none;
}
}
The caret rotation uses a simple 180-degree turn, which reads clearly and avoids reflow. The tooltip arrow “nudge” draws attention without being distracting. Always gate animations behind prefers-reduced-motion so users who prefer less motion get a calm experience.
Accessibility & Performance
Triangles are usually decorative accents. Treat them that way in your semantics and motion choices. Keep color contrast in mind when triangles imply direction or status.
Accessibility
Mark triangles as aria-hidden="true" when they do not carry meaning on their own, like the caret and the ribbon notch above. For a tooltip, the semantic role lives on the bubble container with role="tooltip". If the triangle implies direction in a context where the label is not enough, add a text label. For example, a stand-alone arrow button should have aria-label="Expand panel" or similar. Respect motion settings with prefers-reduced-motion and avoid rapid oscillations that can distract. If the triangle’s color signals a state, make sure the state is conveyed by text or an icon with an accessible label too.
Performance
Border triangles are light. They are simple boxes with borders and no complex paint. Animating transform and opacity stays on the compositor in modern engines, which keeps interactions responsive. Avoid animating lots of heavy shadows on the bubble or ribbon since shadow invalidation can add paint cost. If you need many arrows in a scrolling list, lean on currentColor to reduce custom declarations and let a parent set the theme once. Stick to whole-pixel border widths where possible for crisper rasterization.
A Practical Mental Model You Can Reuse
You built three UI flourishes with border triangles, and you now have a clear mental model: zero-size box, three transparent borders, one colored border, and the shape points opposite the colored side. Base equals the sum of the adjacent transparent borders; height equals the colored border width. With this, you can craft carets, pointers, and tails quickly. Extend these patterns to your menus, banners, and popovers, and you will ship tiny, sharp assets that style and scale cleanly.