CSS Arrows: 18 Examples With Borders, clip-path and Masks

Most CSS arrows end up on tooltips, dropdown menus and speech bubbles, where a small pointer shows what belongs to what. An empty box and a few declarations draw one that scales and recolors with the page.

Below are 18 CSS arrow examples you can try and copy, followed by a guide to each method and its limits.

By Bogdan Sandu · Updated October 27, 2026

Arrows on tooltips, bubbles and popovers

The pointer that ties a floating box to the thing it describes. Border triangles for flat tails, a stacked pair for outlined bubbles, a rotated square when the tail needs a border and a shadow, and anchor positioning to keep the pointer on its target.

Toolbar tooltips with a border-triangle tail

  • border triangle
  • ::after
  • role="tooltip"

Hover or tab to a button. Escape hides the tooltip.

The tail is a zero-size ::after with a 6px top border in the tooltip color and transparent side borders, placed at top: 100% and centered with translate. Each icon button takes its name from the tooltip through aria-labelledby, so the visible label and the accessible name are the same text.

View code
.ar-tip {
  display: flex;
  gap: 6px;
  margin-top: 56px;
  padding: 6px;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 8px 24px rgba(16, 22, 40, .1);
}
.ar-tip__item {
  position: relative;
}
.ar-tip__btn {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  border-radius: 10px;
  background: transparent;
  color: #283043;
  cursor: pointer;
}
.ar-tip__btn svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.ar-tip__btn:hover {
  background: #eef1f7;
}
.ar-tip__btn:focus-visible {
  outline: 2px solid #2b59c3;
  outline-offset: 2px;
}
.ar-tip__tt {
  position: absolute;
  bottom: calc(100% + 12px);
  left: 50%;
  translate: -50% 0;
  padding: 6px 10px;
  border-radius: 6px;
  background: #1b2030;
  color: #fff;
  font-size: 13px;
  line-height: 1.3;
  white-space: nowrap;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity .15s ease, visibility .15s;
}
.ar-tip__tt::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  translate: -50% 0;
  border: 6px solid transparent;
  border-top-color: #1b2030;
  border-bottom-width: 0;
}
.ar-tip__item:hover .ar-tip__tt,
.ar-tip__btn:focus-visible + .ar-tip__tt {
  opacity: 1;
  visibility: visible;
}
.ar-tip.is-dismissed .ar-tip__tt {
  opacity: 0;
  visibility: hidden;
}

Chat bubbles with outlined tails

  • stacked triangles
  • ::before + ::after
  • logical insets

Parcelly support

Hi Dana, your order left our warehouse this morning. It should reach you on Thursday.

Great, can I change the delivery address?

Yes, until 6 pm today. Open the tracking page and choose Edit address.

A triangle cannot carry a border, so the incoming bubble stacks two: ::before in the border color, and a 1px smaller ::after in the fill color sitting 1px further in, which leaves a thin outline on both slanted edges. The outgoing bubble has no border, so one triangle is enough.

View code
.ar-chat {
  display: flex;
  flex-direction: column;
  gap: 14px;
  width: min(100%, 380px);
  padding: 20px 18px 22px;
  border-radius: 18px;
  background: #f8f6f1;
  font-size: 14.5px;
  line-height: 1.45;
}
.ar-chat__who {
  margin: 0;
  color: #5e5a52;
  font-size: 12.5px;
  font-weight: 600;
}
.ar-chat__msg {
  position: relative;
  max-width: 80%;
  margin: 0;
  padding: 10px 14px;
  border-radius: 14px;
}
.ar-chat__msg--in {
  align-self: flex-start;
  margin-inline-start: 10px;
  border: 1px solid #cdc6b8;
  background: #fff;
  color: #2a2722;
}
.ar-chat__msg--in::before,
.ar-chat__msg--in::after {
  content: "";
  position: absolute;
  border-style: solid;
  border-color: transparent;
  border-left-width: 0;
}
.ar-chat__msg--in::before {
  top: 12px;
  right: 100%;
  border-width: 9px 10px 9px 0;
  border-right-color: #cdc6b8;
}
.ar-chat__msg--in::after {
  top: 13px;
  right: calc(100% - 1px);
  border-width: 8px 9px 8px 0;
  border-right-color: #fff;
}
.ar-chat__msg--out {
  align-self: flex-end;
  margin-inline-end: 10px;
  background: #1f6e5c;
  color: #fff;
}
.ar-chat__msg--out::after {
  content: "";
  position: absolute;
  bottom: 10px;
  left: 100%;
  border: 8px solid transparent;
  border-left: 10px solid #1f6e5c;
  border-right-width: 0;
}

Notification popover with a bordered, shadowed arrow

  • rotated square
  • filter: drop-shadow
  • aria-expanded

Notifications

  • Mira Holt commented on Spring launch plan4 min ago
  • Build 1.8.2 finished without errors22 min ago

Click the bell to close and reopen the panel.

When the tail needs the panel border and shadow, a rotated square does what a border triangle cannot. Only its top and left borders are drawn, it sits half inside the panel and covers the panel border where they meet. drop-shadow on the panel follows the arrow outline, which box-shadow would not.

View code
.ar-pop {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  width: min(100%, 320px);
  min-height: 250px;
}
.ar-pop__bell {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  margin-inline-end: 6px;
  padding: 0;
  border: 1px solid #d6dae4;
  border-radius: 50%;
  background: #fff;
  color: #20263a;
  cursor: pointer;
}
.ar-pop__bell svg {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.8;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.ar-pop__bell:focus-visible {
  outline: 2px solid #6b3fd4;
  outline-offset: 3px;
}
.ar-pop__dot {
  position: absolute;
  top: 9px;
  right: 10px;
  width: 9px;
  height: 9px;
  border: 2px solid #fff;
  border-radius: 50%;
  background: #d6336c;
}
.ar-pop__panel {
  position: relative;
  width: 100%;
  margin-top: 14px;
  padding: 14px 16px 6px;
  border: 1px solid #d6dae4;
  border-radius: 12px;
  background: #fff;
  filter: drop-shadow(0 10px 16px rgba(24, 28, 48, .12));
}
.ar-pop__panel[hidden] {
  display: none;
}
.ar-pop__panel::before {
  content: "";
  position: absolute;
  top: -8px;
  right: 21px;
  width: 14px;
  height: 14px;
  box-sizing: border-box;
  border-top: 1px solid #d6dae4;
  border-left: 1px solid #d6dae4;
  border-top-left-radius: 3px;
  background: #fff;
  rotate: 45deg;
}
.ar-pop__title {
  margin: 0 0 6px;
  color: #20263a;
  font-weight: 700;
  font-size: 15px;
}
.ar-pop__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.ar-pop__list li {
  padding: 10px 0;
  border-top: 1px solid #eceef3;
  color: #353b4d;
  font-size: 14px;
  line-height: 1.4;
}
.ar-pop__list span {
  display: block;
  margin-top: 2px;
  color: #5d6476;
  font-size: 12.5px;
}

Map callout tethered with anchor positioning

  • anchor-name
  • position-area
  • @supports fallback
Harbor Hall Concert venue, open until 11 pm

Pick a different pin. The callout and its arrow move to it.

The pressed pin gets anchor-name, and the callout uses position-area: top to sit above it, so the arrow at its bottom center always points at the pin, and pointer-events: none keeps it from blocking other pins. Browsers without anchor positioning skip the @supports block and pin the callout to the bottom of the map instead, with no arrow.

View code
.ar-map {
  width: min(100%, 480px);
}
.ar-map__area {
  position: relative;
  height: 280px;
  border-radius: 16px;
  overflow: hidden;
  border: 1px solid #d3dbcc;
}
.ar-map__art {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.ar-map__pin {
  position: absolute;
  width: 28px;
  height: 28px;
  margin: -28px 0 0 -14px;
  padding: 0;
  border: 3px solid #fff;
  border-radius: 50% 50% 50% 0;
  background: #b4442f;
  color: transparent;
  font-size: 0;
  rotate: -45deg;
  box-shadow: 0 2px 6px rgba(40, 30, 20, .3);
  cursor: pointer;
}
.ar-map__pin.is-active {
  background: #1d2b4f;
}
.ar-map__pin:focus-visible {
  outline: 3px solid #1d2b4f;
  outline-offset: 3px;
}
.ar-map__card {
  position: absolute;
  right: 10px;
  bottom: 10px;
  left: 10px;
  padding: 10px 14px;
  border-radius: 10px;
  background: #1d2b4f;
  color: #fff;
  font-size: 13.5px;
  line-height: 1.35;
}
.ar-map__card strong {
  display: block;
  font-size: 14.5px;
}
.ar-map__card span {
  color: #d9e0f2;
}
@supports (anchor-name: --ar-a) {
  .ar-map__pin.is-active {
    anchor-name: --ar-pin;
  }
  .ar-map__card {
    position: absolute;
    position-anchor: --ar-pin;
    inset: auto;
    position-area: top;
    width: max-content;
    max-width: 170px;
    margin: 0 0 14px;
    pointer-events: none;
  }
  .ar-map__card::after {
    content: "";
    position: absolute;
    top: 100%;
    left: 50%;
    translate: -50% 0;
    border: 8px solid transparent;
    border-top-color: #1d2b4f;
    border-bottom-width: 0;
  }
}

Arrows in navigation

Chevrons that say where you are and where you can go next: breadcrumbs, a custom select, a checkout stepper, pagination and a back-to-top button. Every separator is decorative, so none of them reaches a screen reader.

Custom select caret on a wrapper

  • appearance: none
  • pointer-events: none
  • :focus-within

appearance: none removes the native arrow, and the chevron is drawn on the wrapper, because a select does not reliably render ::before or ::after. pointer-events: none lets clicks on the chevron reach the select, and :focus-within recolors it together with the focus ring.

View code
.ar-sel {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  width: min(100%, 460px);
  padding: 20px;
  border-radius: 14px;
  background: #fff;
  border: 1px solid #e3e1dc;
}
.ar-sel__field {
  display: grid;
  flex: 1 1 150px;
  gap: 6px;
}
.ar-sel__field label {
  color: #3d3a35;
  font-size: 13px;
  font-weight: 600;
}
.ar-sel__wrap {
  position: relative;
  display: block;
}
.ar-sel__wrap::after {
  content: "";
  position: absolute;
  top: 50%;
  right: 16px;
  width: 7px;
  height: 7px;
  margin-top: -6px;
  border-right: 2px solid #6b665d;
  border-bottom: 2px solid #6b665d;
  rotate: 45deg;
  pointer-events: none;
}
.ar-sel__wrap:focus-within::after {
  border-color: #a4461d;
}
.ar-sel select {
  width: 100%;
  min-height: 44px;
  padding: 0 40px 0 14px;
  border: 1.5px solid #c9c4ba;
  border-radius: 10px;
  background: #faf9f6;
  color: #2b2925;
  font: inherit;
  font-size: 15px;
  appearance: none;
  cursor: pointer;
}
.ar-sel select:hover {
  border-color: #8f897d;
}
.ar-sel select:focus-visible {
  border-color: #a4461d;
  outline: 3px solid rgba(164, 70, 29, .25);
  outline-offset: 1px;
}

Checkout stepper with clip-path chevron steps

  • clip-path polygon
  • aria-current="step"
  • negative margin
  1. Cart
  2. Shipping
  3. Payment
  4. Review

Use Back and Continue to move through the steps.

Every step is a box clipped into a chevron with a six-point polygon, a notch on the left and a point on the right. A negative inline margin tucks each step into the notch of the next, and the first step drops its notch. The current step carries aria-current="step", so the color is not the only cue.

View code
.ar-step {
  width: min(100%, 560px);
}
.ar-step__list {
  display: flex;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ar-step__list li {
  flex: 1 1 0;
  min-width: 0;
  padding: 12px 4px 12px 18px;
  background: #e3e6ee;
  color: #3e4555;
  font-size: 13.5px;
  font-weight: 600;
  text-align: center;
  clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 50%, calc(100% - 14px) 100%, 0 100%, 14px 50%);
}
.ar-step__list li + li {
  margin-inline-start: -10px;
}
.ar-step__list li:first-child {
  padding-left: 12px;
  border-radius: 10px 0 0 10px;
  clip-path: polygon(0 0, calc(100% - 14px) 0, 100% 50%, calc(100% - 14px) 100%, 0 100%);
}
.ar-step__list li:last-child {
  border-radius: 0 10px 10px 0;
  clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, 14px 50%);
}
.ar-step__list li.is-done {
  background: #c9ecdf;
  color: #12503f;
}
.ar-step__list li[aria-current="step"] {
  background: #0f6b52;
  color: #fff;
}
.ar-step__actions {
  display: flex;
  justify-content: space-between;
  margin-top: 18px;
}
.ar-step__actions button {
  min-height: 44px;
  padding: 0 20px;
  border: 1.5px solid #0f6b52;
  border-radius: 10px;
  background: #fff;
  color: #0f6b52;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.ar-step__actions .ar-step__next {
  background: #0f6b52;
  color: #fff;
}
.ar-step__actions button:disabled {
  border-color: #c3c8d2;
  background: #f1f2f5;
  color: #6c7383;
  cursor: not-allowed;
}
.ar-step__actions button:focus-visible {
  outline: 3px solid #e2a400;
  outline-offset: 2px;
}

Pagination with silent Unicode arrows

  • content: "\2190" / ""
  • aria-current="page"
  • container query

Click a page number or Next.

The arrows are U+2190 and U+2192 inserted through content, with an empty alternative text after the slash so screen readers skip them. The plain declaration comes first as a fallback. Under 420px the words Previous and Next are hidden visually but stay in the accessibility tree.

View code
.ar-pg {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: min(100%, 520px);
  container-type: inline-size;
  justify-self: stretch;
}
.ar-pg__list {
  display: flex;
  gap: 4px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ar-pg button {
  min-width: 44px;
  min-height: 44px;
  padding: 0 12px;
  border: 0;
  border-radius: 999px;
  background: transparent;
  color: #2a2f3d;
  font: inherit;
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
}
.ar-pg button:hover {
  background: #e4e8f1;
}
.ar-pg button[aria-current="page"] {
  background: #2a2f3d;
  color: #fff;
}
.ar-pg button:focus-visible {
  outline: 3px solid #c2410c;
  outline-offset: 2px;
}
.ar-pg__edge {
  display: inline-flex;
  align-items: center;
  gap: 8px;
}
.ar-pg .ar-pg__edge {
  border: 1.5px solid #c4cad6;
}
.ar-pg .ar-pg__edge:disabled {
  color: #6b7280;
  background: transparent;
  cursor: not-allowed;
}
.ar-pg__prev::before {
  content: "\2190";
  content: "\2190" / "";
}
.ar-pg__next::after {
  content: "\2192";
  content: "\2192" / "";
}
.ar-pg__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
@container (max-width: 420px) {
  .ar-pg__word {
    position: absolute;
    width: 1px;
    height: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  .ar-pg__edge {
    gap: 0;
  }
}

Back-to-top button that appears after scrolling

  • position: sticky
  • chevron + shaft
  • aria-label

Packing light for a week on the coast

A 30 liter pack fits a week of clothes if every piece works with every other piece. Pick two neutral colors and one accent, and leave anything that only matches a single outfit at home.

Roll soft layers, fold anything with a collar, and keep the heaviest item close to your back. A small dry bag doubles as a laundry sack on the way home.

Wear your bulkiest shoes on travel days. One pair of sandals and one pair of trail runners cover beach days, ferry rides and the occasional rocky path.

Toiletries go in a clear zip bag at the top of the pack. Solid shampoo and a bar of soap save space and never leak into your clothes.

Leave room for one thing you buy on the trip. A pack that is full on day one turns every market into a problem.

Check the forecast the night before, not the week before. Coastal weather changes fast, and a thin shell weighs less than the extra sweater you packed just in case.

Scroll the article. The button shows up after the first screen.

The arrow is two pseudo-elements on one span: a rotated-border chevron for the head and a 2px bar for the shaft. The button is sticky at the bottom of the scroller and only becomes visible after 200px of scrolling. It scrolls back up smoothly unless reduced motion is set, then moves focus to the article title.

View code
.ar-top {
  height: 100%;
  overflow-y: auto;
  background: #fbfaf7;
  color: #2c2a26;
}
.ar-top__doc {
  padding: 24px 22px 8px;
  font-size: 15px;
  line-height: 1.6;
}
.ar-top__title {
  margin: 0 0 12px;
  font-family: "Fraunces", Georgia, serif;
  font-size: 24px;
  line-height: 1.2;
}
.ar-top__title:focus-visible {
  outline: 2px solid #b4532a;
  outline-offset: 4px;
}
.ar-top__doc p {
  margin: 0 0 14px;
}
.ar-top__btn {
  position: sticky;
  bottom: 18px;
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  margin: 0 18px 18px auto;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #b4532a;
  color: #fff;
  box-shadow: 0 6px 16px rgba(60, 30, 10, .25);
  cursor: pointer;
  opacity: 0;
  visibility: hidden;
  transition: opacity .2s ease, visibility .2s;
}
.ar-top.is-scrolled .ar-top__btn {
  opacity: 1;
  visibility: visible;
}
.ar-top__btn:focus-visible {
  outline: 3px solid #2c2a26;
  outline-offset: 3px;
}
.ar-top__icon {
  position: relative;
  display: block;
  width: 16px;
  height: 18px;
}
.ar-top__icon::before {
  content: "";
  position: absolute;
  top: 2px;
  left: 3px;
  width: 10px;
  height: 10px;
  box-sizing: border-box;
  border-top: 2px solid currentColor;
  border-left: 2px solid currentColor;
  rotate: 45deg;
}
.ar-top__icon::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 7px;
  width: 2px;
  height: 16px;
  background: currentColor;
}

Arrows on buttons and controls

Arrows that do something when you press them or that show state: carousel buttons, a CTA that nudges on hover, a scroll cue, an accordion caret and table sort indicators. Arrow-only buttons get an accessible name, and the motion only runs when the visitor has not asked for less of it.

CTA arrow that grows and slides on hover

  • scaleX shaft
  • translate
  • prefers-reduced-motion

Team plan

Plan sprints your whole studio can see.

Hover or focus the buttons.

The shaft is a 2px ::before that scales along the x axis from its left end, and the chevron head on ::after slides by the same amount, so the arrow seems to stretch toward the next page. Both use transform properties only, and the movement sits inside a no-preference media query.

View code
.ar-cta {
  width: min(100%, 460px);
  color: #f2efe8;
}
.ar-cta__kicker {
  margin: 0 0 6px;
  color: #c9c3b6;
  font-size: 14px;
}
.ar-cta__title {
  margin: 0 0 22px;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(24px, 6vw, 32px);
  line-height: 1.15;
}
.ar-cta__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 14px 24px;
}
.ar-cta__btn,
.ar-cta__link {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  min-height: 48px;
  font-weight: 600;
  text-decoration: none;
}
.ar-cta__btn {
  padding: 0 22px;
  border-radius: 999px;
  background: #f4c84a;
  color: #1c1708;
}
.ar-cta__link {
  color: #f2efe8;
}
.ar-cta__btn:focus-visible,
.ar-cta__link:focus-visible {
  outline: 3px solid #f2efe8;
  outline-offset: 3px;
}
.ar-cta__arrow {
  position: relative;
  display: inline-block;
  width: 26px;
  height: 12px;
}
.ar-cta__arrow::before {
  content: "";
  position: absolute;
  top: 5px;
  left: 0;
  width: 14px;
  height: 2px;
  background: currentColor;
  transform-origin: 0 50%;
}
.ar-cta__arrow::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 8px;
  width: 8px;
  height: 8px;
  box-sizing: border-box;
  border-top: 2px solid currentColor;
  border-right: 2px solid currentColor;
  rotate: 45deg;
}
@media (prefers-reduced-motion: no-preference) {
  .ar-cta__arrow::before {
    transition: scale .25s ease;
  }
  .ar-cta__arrow::after {
    transition: translate .25s ease;
  }
  .ar-cta__btn:hover .ar-cta__arrow::before,
  .ar-cta__btn:focus-visible .ar-cta__arrow::before,
  .ar-cta__link:hover .ar-cta__arrow::before,
  .ar-cta__link:focus-visible .ar-cta__arrow::before {
    scale: 1.7 1;
  }
  .ar-cta__btn:hover .ar-cta__arrow::after,
  .ar-cta__btn:focus-visible .ar-cta__arrow::after,
  .ar-cta__link:hover .ar-cta__arrow::after,
  .ar-cta__link:focus-visible .ar-cta__arrow::after {
    translate: 10px 0;
  }
}
.ar-cta__link:hover span {
  color: #f4c84a;
}

Scroll-down cue that bounces three times

  • @keyframes
  • animation-iteration-count: 3
  • no-preference

Driftline Studio

Field recordings from twelve coastlines

Scroll to the sessions

Latest session: Basalt cove at low tide, 42 min

Two chevrons drawn with borders bounce down and back, but only three times at one second each, so the motion ends well inside the five-second limit of WCAG 2.2.2. The animation lives inside a no-preference media query, so visitors who ask for reduced motion see still arrows.

View code
.ar-cue {
  display: flex;
  flex-direction: column;
  align-items: center;
  width: min(100%, 440px);
  padding: 34px 20px 20px;
  border: 1px solid #2a3346;
  border-radius: 18px;
  background: #121a2b;
  color: #e9edf5;
  text-align: center;
}
.ar-cue__brand {
  margin: 0 0 6px;
  color: #9fb0cc;
  font-size: 13px;
  letter-spacing: .08em;
}
.ar-cue__title {
  margin: 0 0 26px;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(22px, 6vw, 28px);
  line-height: 1.2;
}
.ar-cue__link {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 10px;
  padding: 8px 12px;
  border-radius: 10px;
  color: #e9edf5;
  font-size: 14px;
  text-decoration: none;
}
.ar-cue__link:focus-visible {
  outline: 2px solid #7fd1c3;
  outline-offset: 2px;
}
.ar-cue__icon {
  position: relative;
  display: block;
  width: 18px;
  height: 22px;
}
.ar-cue__icon::before,
.ar-cue__icon::after {
  content: "";
  position: absolute;
  left: 3px;
  width: 12px;
  height: 12px;
  box-sizing: border-box;
  border-right: 2px solid #7fd1c3;
  border-bottom: 2px solid #7fd1c3;
  rotate: 45deg;
}
.ar-cue__icon::before {
  top: -2px;
  opacity: .55;
}
.ar-cue__icon::after {
  top: 6px;
}
.ar-cue__next {
  margin: 26px 0 0;
  padding-top: 16px;
  border-top: 1px solid #2a3346;
  color: #b9c4d8;
  font-size: 13.5px;
}
@media (prefers-reduced-motion: no-preference) {
  .ar-cue__icon {
    animation: ar-cue-bounce 1s ease-in-out 3;
  }
}
@keyframes ar-cue-bounce {
  0%, 100% {
    translate: 0 0;
  }
  50% {
    translate: 0 8px;
  }
}

Accordion caret driven by aria-expanded

  • [aria-expanded="true"]
  • rotate: 180deg
  • hidden

Open a question.

The caret is a 5px border triangle that points down, and a rule on [aria-expanded="true"] turns it 180 degrees. Because the style reads the same attribute the script sets for assistive technology, the arrow can never show open while the panel is announced as closed.

View code
.ar-acc {
  width: min(100%, 480px);
  min-height: 250px;
  border-top: 1px solid #d9d4ea;
}
.ar-acc__h {
  margin: 0;
  border-bottom: 1px solid #d9d4ea;
  font-size: 15.5px;
}
.ar-acc__h button {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  width: 100%;
  min-height: 52px;
  padding: 10px 4px;
  border: 0;
  background: transparent;
  color: #2b2540;
  font: inherit;
  font-weight: 600;
  text-align: start;
  cursor: pointer;
}
.ar-acc__h button:hover {
  color: #5b3fc4;
}
.ar-acc__h button:focus-visible {
  outline: 2px solid #5b3fc4;
  outline-offset: 2px;
}
.ar-acc__caret {
  flex: none;
  width: 0;
  height: 0;
  border: 6px solid transparent;
  border-top-color: currentColor;
  border-bottom-width: 0;
}
.ar-acc__h button[aria-expanded="true"] .ar-acc__caret {
  rotate: 180deg;
}
.ar-acc__panel {
  border-bottom: 1px solid #d9d4ea;
}
.ar-acc__panel p {
  margin: 0;
  padding: 4px 4px 16px;
  color: #4a4560;
  font-size: 14.5px;
  line-height: 1.55;
}
@media (prefers-reduced-motion: no-preference) {
  .ar-acc__caret {
    transition: rotate .2s ease;
  }
}

Table sort arrows with aria-sort

  • two border triangles
  • aria-sort
  • button in th
Trail shoes in stock
Alder Run268129
Fernpeak301149
Kestrel Lite232139
Scree Pro315165

Click a column header to sort, click again to reverse.

Each header button holds one span whose ::before and ::after are an up and a down triangle. The th carries aria-sort, and CSS darkens the matching triangle from that attribute, so the visual and the announced sort direction come from one source. Columns that are not sorted get no aria-sort at all.

View code
.ar-sort {
  width: min(100%, 480px);
  padding: 8px 14px 14px;
  border-radius: 14px;
  background: #fff;
  box-shadow: 0 8px 24px rgba(20, 30, 30, .08);
}
.ar-sort table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14.5px;
}
.ar-sort caption {
  padding: 8px 0 10px;
  color: #23302e;
  font-weight: 700;
  text-align: start;
}
.ar-sort th {
  padding: 0;
  border-bottom: 2px solid #23302e;
  text-align: start;
}
.ar-sort th button {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 40px;
  padding: 0 4px;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: #23302e;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
}
.ar-sort th button:focus-visible {
  outline: 2px solid #0d7a6a;
  outline-offset: 1px;
}
.ar-sort td {
  padding: 10px 4px;
  border-bottom: 1px solid #e3e8e7;
  color: #34403e;
}
.ar-sort__icon {
  display: inline-flex;
  flex-direction: column;
  gap: 2px;
}
.ar-sort__icon::before,
.ar-sort__icon::after {
  content: "";
  border: 4px solid transparent;
  color: #b8c2c0;
}
.ar-sort__icon::before {
  border-bottom-color: currentColor;
  border-top-width: 0;
}
.ar-sort__icon::after {
  border-top-color: currentColor;
  border-bottom-width: 0;
}
.ar-sort th[aria-sort="ascending"] .ar-sort__icon::before,
.ar-sort th[aria-sort="descending"] .ar-sort__icon::after {
  color: #0d7a6a;
}

Icons, lines and direction

Arrows that go beyond a small triangle: mask-image icons that take the text color, triangles that flip in right-to-left layouts, long connector arrows for diagrams and two ways to draw a curved arrow.

mask-image arrow icons that follow currentColor

  • mask-image
  • SVG data URI
  • currentColor

Hover the links. The arrows change color with the text.

Each icon is an empty span with background-color: currentColor, and an SVG arrow in mask-image cuts it to shape. The arrow therefore takes whatever color the link has, in the light card and the dark one, on hover and in any theme, without editing the SVG.

View code
.ar-mask {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 14px;
  width: min(100%, 560px);
}
.ar-mask__card {
  margin: 0;
  padding: 6px 16px;
  border-radius: 14px;
  background: #fff;
  list-style: none;
  border: 1px solid #e4e1f0;
}
.ar-mask__card li + li {
  border-top: 1px solid #ebe8f4;
}
.ar-mask__card a {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  min-height: 48px;
  color: #2d2a3e;
  font-size: 14.5px;
  font-weight: 500;
  text-decoration: none;
}
.ar-mask__card a:hover {
  color: #6a35d8;
}
.ar-mask__card a:focus-visible {
  outline: 2px solid #6a35d8;
  outline-offset: 2px;
  border-radius: 4px;
}
.ar-mask__card--dark {
  border-color: #2e2b40;
  background: #1c1a28;
}
.ar-mask__card--dark li + li {
  border-top-color: #2e2b40;
}
.ar-mask__card--dark a {
  color: #ece9f7;
}
.ar-mask__card--dark a:hover {
  color: #c9b3ff;
}
.ar-mask__card--dark a:focus-visible {
  outline-color: #c9b3ff;
}
.ar-mask__i {
  flex: none;
  width: 18px;
  height: 18px;
  background-color: currentColor;
  -webkit-mask: var(--ar-icon) center / contain no-repeat;
  mask: var(--ar-icon) center / contain no-repeat;
}
.ar-mask__i--right {
  --ar-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12h15M13 6l6 6-6 6'/%3E%3C/svg%3E");
}
.ar-mask__i--out {
  --ar-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7M9 7h8v8'/%3E%3C/svg%3E");
}
.ar-mask__i--down {
  --ar-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 4v12M6 11l6 6 6-6M5 21h14'/%3E%3C/svg%3E");
}

Arrows that flip in right-to-left layouts

  • border-inline-start
  • border-block
  • dir="rtl"

Chapter 4 of 9

Each arrow is one empty element with border-block in transparent and a colored border-inline-start or border-inline-end. Inline-start is the left side in English and the right side in Arabic, so the same CSS points Next toward the reading direction in both cards, with no transform and no :dir() rule.

View code
.ar-rtl {
  display: grid;
  gap: 14px;
  width: min(100%, 440px);
}
.ar-rtl__card {
  padding: 14px 18px 16px;
  border-radius: 14px;
  background: #fff;
  border-inline-start: 4px solid #c2410c;
}
.ar-rtl__label {
  margin: 0 0 8px;
  color: #5c5650;
  font-size: 13px;
}
.ar-rtl__nav {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 8px 16px;
}
.ar-rtl__nav a {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  min-height: 40px;
  color: #2b2724;
  font-weight: 600;
  text-decoration: none;
}
.ar-rtl__nav a:hover {
  color: #c2410c;
}
.ar-rtl__nav a:focus-visible {
  outline: 2px solid #c2410c;
  outline-offset: 2px;
  border-radius: 4px;
}
.ar-rtl__next,
.ar-rtl__back {
  width: 0;
  height: 0;
  border-block: 6px solid transparent;
}
.ar-rtl__next {
  border-inline-start: 8px solid currentColor;
}
.ar-rtl__back {
  border-inline-end: 8px solid currentColor;
}

Pipeline diagram with long connector arrows

  • ::before shaft
  • ::after head
  • container query
  1. Commitmain branch
  2. Build2 min
  3. Test418 checks
  4. Deployeu-west

Every step except the last draws its own connector: ::before is the shaft that spans the gap and ::after is a border-triangle head at its end. A container query switches the list to a column when it gets narrow, and the same two pseudo-elements turn into a downward arrow.

View code
.ar-flow {
  width: min(100%, 640px);
  justify-self: stretch;
  margin-inline: auto;
  container-type: inline-size;
}
.ar-flow__list {
  display: flex;
  gap: 40px;
  margin: 0;
  padding: 0;
  list-style: none;
}
.ar-flow__list li {
  position: relative;
  display: grid;
  flex: 1 1 0;
  gap: 2px;
  padding: 12px 10px;
  border: 1.5px solid #2f3b52;
  border-radius: 10px;
  background: #fff;
  text-align: center;
}
.ar-flow__list strong {
  color: #1f2738;
  font-size: 15px;
}
.ar-flow__list span {
  color: #555f73;
  font-size: 12.5px;
}
.ar-flow__list .ar-flow__end {
  border-color: #1b7a4b;
  background: #e6f4ec;
}
.ar-flow__list li:not(:last-child)::before {
  content: "";
  position: absolute;
  top: 50%;
  left: calc(100% + 1.5px);
  width: 31px;
  height: 2px;
  margin-top: -1px;
  background: #2f3b52;
}
.ar-flow__list li:not(:last-child)::after {
  content: "";
  position: absolute;
  top: 50%;
  left: calc(100% + 31.5px);
  margin-top: -6px;
  border: 6px solid transparent;
  border-left: 8px solid #2f3b52;
  border-right-width: 0;
}
@container (max-width: 520px) {
  .ar-flow__list {
    flex-direction: column;
    gap: 34px;
    max-width: 260px;
    margin-inline: auto;
  }
  .ar-flow__list li:not(:last-child)::before {
    top: calc(100% + 1.5px);
    left: 50%;
    width: 2px;
    height: 24px;
    margin: 0 0 0 -1px;
  }
  .ar-flow__list li:not(:last-child)::after {
    top: calc(100% + 25px);
    left: 50%;
    margin: 0 0 0 -6px;
    border: 6px solid transparent;
    border-top: 8px solid #2f3b52;
    border-bottom-width: 0;
  }
}

Curved arrow: inline SVG next to the border-radius trick

  • SVG path
  • border-radius arc
  • border triangle head

Start here

SVG path

Start here

CSS border-radius

The left hint uses an inline SVG path with a separate path for the head, so the curve can take any shape. The right one is pure CSS: a circle with only its top border colored gives a quarter arc, and a border triangle on ::after caps the end. CSS covers a simple arc, SVG covers anything else.

View code
.ar-curve {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 14px;
  width: min(100%, 520px);
}
.ar-curve__panel {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  min-height: 210px;
  padding: 16px 16px 14px;
  border-radius: 14px;
  background: #fffdf6;
  border: 1px solid #ece4cc;
}
.ar-curve__note {
  margin: 0 0 0 4px;
  color: #a43c19;
  font-family: "Fraunces", Georgia, serif;
  font-size: 19px;
  font-style: italic;
}
.ar-curve__svg {
  display: block;
  margin: 2px 0 0 14px;
  fill: none;
  stroke: #a43c19;
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.ar-curve__css {
  position: relative;
  display: block;
  width: 96px;
  height: 96px;
  margin: 14px 0 -66px 2px;
  box-sizing: border-box;
  border: 2.2px solid transparent;
  border-top-color: #a43c19;
  border-radius: 50%;
}
.ar-curve__css::after {
  content: "";
  position: absolute;
  top: 6px;
  left: 74px;
  border: 6px solid transparent;
  border-left: 10px solid #a43c19;
  border-right-width: 0;
  rotate: 45deg;
}
.ar-curve__btn {
  align-self: flex-end;
  min-height: 44px;
  margin-top: 2px;
  padding: 0 18px;
  border: 0;
  border-radius: 10px;
  background: #2a2620;
  color: #fff;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.ar-curve__btn:focus-visible {
  outline: 3px solid #a43c19;
  outline-offset: 2px;
}
.ar-curve__tag {
  margin: auto 0 0;
  padding-top: 12px;
  color: #6b6252;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12px;
}

What Are the Ways to Make CSS Arrows?

The usual options are a border triangle, a clip-path polygon, a rotated square, a Unicode character and an SVG. Most of them need nothing beyond plain CSS and one empty element.

MethodDraws the arrow withBest forMain limit
Border triangleZero-size box, 3 sides colored or transparentTooltip tails, dropdown caretsFlat color only, no own border
clip-path polygonpolygon() points on a filled boxScaling or gradient arrowsClips the element’s own border
Rotated square2 borders on a square turned 45degChevrons, accordion togglesOpen shape, no solid fill
Unicode characterAn arrow glyph through contentArrows inside running textLook depends on the font
SVGA path or polygon elementCurves, shafts, multi-colorExtra markup or a data URI

No method wins everywhere. What decides it is the job the arrow has to do once it exists: take a gradient, animate, carry a border, or stay a few pixels wide.

For a small solid tail, I’d still start with the border triangle. Reach for clip-path when the arrow needs a gradient or has to move.

How Does the Border Triangle Method Work?

Give a box zero width and height plus thick solid borders, and the borders meet on diagonals. Color one border and make the other two transparent, and the visible part is a triangle. The colored border sits opposite the direction the tip points.

  1. Start with an empty element, or a pseudo-element with content set to an empty string.
  2. Set display to block or inline-block, because width and height do not apply to inline boxes.
  3. Set width and height to 0.
  4. Set border-style to solid. Give each side border half the arrow width and the base border the full arrow height.
  5. Make the two side borders transparent and color the base border.

Pointing the arrow in each direction

  • For an arrow pointing up, color border-bottom and make border-left and border-right transparent.
  • Pointing down, it’s border-top that gets the color, and the left and right borders stay transparent.
  • Left needs border-right colored, with border-top and border-bottom cleared.
  • Right works the other way around. Color border-left and make border-top and border-bottom transparent.

Setting size and color

The arrow height equals the width of the colored border. The arrow width equals the two transparent borders added together, so border-left and border-right of 10px each give a 20px base.

Leave the border color unset and it falls back to currentColor, which keeps the arrow in step with the text color of its parent.

When the arrow needs uneven proportions, a CSS triangle generator that outputs the border values saves the arithmetic.

The catch is the flat fill. A border triangle cannot take a gradient or carry its own border, so clip-path handles the first case and two stacked triangles handle the second.

How to Make a CSS Arrow With clip-path

Clip a filled box with polygon(), putting one point at the tip and two along the base. The background then fills the arrow, so the box can hold a solid color, a gradient or an image.

MDN lists clip-path as widely available, working across browsers since January 2021.

  • Up uses polygon(50% 0, 100% 100%, 0 100%)
  • polygon(0 0, 100% 0, 50% 100%) points it down
  • For left, write polygon(100% 0, 100% 100%, 0 50%)
  • And right is polygon(0 0, 100% 50%, 0 100%)

Percentages scale with the box, so one rule fits any arrow size. For uneven proportions, a CSS clip-path generator outputs the points from a visual editor.

CodyHouse replaced the border hack with clip-path in its Tooltip component. The arrow now follows the tooltip’s background, and the tip can be rounded.

The same component wraps the rule in an @supports query for clip-path, so a browser without it gets a tooltip with no arrow instead of a broken one.

Polygon values animate, so an arrow can slide or morph between states, as long as both states keep the same number of points.

Support claims differ by source. A CSS triangle tutorial on pineco.de calls clip-path partially supported, while MDN’s Baseline data says otherwise. Check cross-browser compatibility in MDN’s table before trusting an undated tutorial.

It breaks in one place. clip-path hides everything outside the polygon, border and shadow included, so the arrow cannot carry an outline. MDN also notes that any clip-path other than none creates a new stacking context, the same way opacity below 1 does.

How to Build a Chevron or Double Arrow With Transform Rotate

Take an inline-block square, give it border-right and border-bottom in the arrow color, and rotate it 45 degrees. Only two borders show, so the corner becomes the tip.

With those two borders, rotate(45deg) points down, rotate(-45deg) points right, rotate(135deg) points left and rotate(-135deg) points up. Positive angles turn clockwise, whatever the text direction.

Set box-sizing to border-box so the borders count toward the square’s size.

Rotation pivots on the element’s center by default, which keeps a chevron where it was placed. Set transform-origin to move the pivot when an arrow should swing like a hinge.

rotate() has worked across browsers since July 2015, according to MDN.

  • A double arrow is two chevrons. The element draws one, and its ::after draws the second, offset along the pointing axis.
  • For a line arrow, add a thin rectangle as the shaft, centered on the chevron’s axis, and let the chevron act as the head.
  • Order matters when transforms combine. MDN shows that rotating before translating moves the translation along the rotated axis.

The downsides are an open shape and softer edges. A chevron takes no solid fill, and rotated borders are antialiased, so small chevrons look less crisp than an upright border triangle.

How to Attach an Arrow to a Tooltip, Dropdown or Speech Bubble

Draw the arrow on a ::before or ::after pseudo-element with position absolute, then offset it from the edge of the container that carries the pointer. The container needs position relative, or the arrow lands relative to some distant ancestor.

Centering and offsetting the arrow

For a tail below a box, set top to 100% and color the top border. Above it, bottom goes to 100% and the bottom border gets the color.

To center the tail horizontally, use left 50% plus transform translateX(-50%). A corner tail swaps the 50% for a fixed offset such as 16px. Side tails sit at left or right 100%, with top 50% and translateY(-50%).

Match the colored border to the tooltip background so the two read as one shape. Finished CSS tooltip examples show these offsets in working components.

Bordered arrows

The usual way to give an arrow a border is to stack two triangles. A larger arrow in the border color sits behind a slightly smaller arrow in the fill color, so a thin line of border color shows along both slanted edges.

Draw one with ::before and the other with ::after. Outlined tails in CSS speech bubble designs use the same pair.

A clip-path arrow cannot carry a border either, so the pair is the standard workaround there too.

Library and newer CSS options

Bootstrap 5.3 draws its tooltip arrow as a border triangle, 0.8rem wide and 0.4rem tall by default, with both values exposed as CSS custom properties (Bootstrap documentation).

  • Popper.js handles the positioning, and Bootstrap’s tooltips depend on it.
  • Floating UI has an arrow() middleware that returns coordinates keeping the arrow pointing at the center of the reference element.
  • CSS Anchor Positioning tethers the tooltip to its anchor in pure CSS. Its core properties became Baseline newly available in January 2026, when Firefox 147 shipped them (web.dev).
  • border-shape draws the box outline with the arrow included, so border and shadow follow it.

Floating UI’s docs require the arrow element to be a child of the floating element, and arrow() should generally go after shift() or autoPlacement() in the middleware array. Its FloatingArrow component draws an SVG arrow and needs a strokeWidth that matches (or is less than) the tooltip’s border width.

Early write-ups covered border-shape behind a flag in Chrome Canary, but it has since shipped: Chrome 147 (April 2026) and Edge 147 support it by default, while Firefox and Safari do not. MDN still lists it as experimental and not Baseline.

For a plain tooltip tail, the border triangle still does the job. Move to clip-path or border-shape only when the arrow needs a gradient, a rounded tip or a shadow that follows its outline.

How to Animate a CSS Arrow

Animate the arrow’s pseudo-element with transform or opacity, driven by a transition on hover or focus, or by a keyframe loop for scroll cues. Those two properties are also the cheapest ones a browser can animate.

  • A hover nudge is translateX(4px) on a button’s arrow, with a short transition on transform.
  • For a dropdown menu caret, rotate(180deg) flips it while the menu is open.
  • A scroll cue is a keyframe loop that moves the arrow a few pixels down and back.

Hover nudges and caret flips are small micro-interactions, so keep the transition short and the distance small.

web.dev’s performance guidance names transform and opacity as the 2 properties a browser can animate without triggering layout or paint. A border triangle that grows through border-width changes its geometry, so scale it with transform instead.

For a scroll cue, define the movement with CSS keyframes and attach it through the animation shorthand with a finite iteration count.

The finite count is not cosmetic. WCAG Success Criterion 2.2.2 requires a way to pause, stop or hide moving content that starts automatically, lasts more than 5 seconds and sits beside other content (W3C).

An endless bounce fails that test, while a bounce that runs 3 times at 1 second each ends inside the limit.

Add a prefers-reduced-motion: reduce rule that sets animation to none. MDN lists the feature as Baseline widely available, and Chrome DevTools can emulate it from the Rendering tab (developer.chrome.com).

When Should You Use SVG, a Unicode Character or an Icon Font Instead?

Switch when the arrow needs a curve, a shaft of any length, several colors or a spot inside running text. A border triangle or clip-path polygon covers a straight pointer on a box and little else.

The Unicode Arrows block spans U+2190 to U+21FF, which is 112 code points (Unicode Consortium code chart). Insert one through an HTML entity such as → or a CSS escape like \2192 in the content property.

Size follows font-size and color follows the text color, which suits an arrow inside a sentence or a link.

SVG draws curved arrows, long shafts and multi-color shapes, and it scales without quality loss.

Placing SVG in HTML markup lets the arrow take its color from the surrounding text through currentColor, so it still follows the theme.

An icon font such as Font Awesome loads a whole font file to deliver one glyph here. It only pays off when the project already uses one.

Stick with CSS for a pointer attached to a box and move to SVG as soon as the shape needs a curve. Unicode fits the arrow sitting inside text.

Why Is a CSS Arrow Not Showing or Misaligned?

Most of the time the pseudo-element isn’t rendering at all, or the side borders aren’t transparent. Less often the parent is clipping the arrow or never positioned it.

SymptomCauseFix
No arrow at allPseudo-element has no content declarationSet content to an empty string
Box instead of a triangleSide borders are not transparentMake the two side borders transparent
Arrow in the wrong placeParent has no position valueSet position: relative on the container
Arrow cut off at the edgeParent overflow hidden clips the overhangMove the arrow inside or drop the clipping
Soft edges after centeringOdd width puts translateX(-50%) on a half pixelUse an even arrow width

Select the ::before or ::after node in the Elements panel of Chrome DevTools to read the arrow’s computed size and position before changing any value.

How to Keep CSS Arrows Accessible

Most CSS arrows are decorative, so screen readers shouldn’t announce them. Contrast and forced colors mode still need a check.

Hiding decorative arrows from screen readers

An arrow drawn with a border triangle or clip-path on an empty pseudo-element carries no text, so assistive technology has nothing to announce. A Unicode character is different.

MDN’s breadcrumb recipe warns that arrows added through the content property are exposed to screen readers and braille displays. An empty alternative text after a slash silences the glyph, as in content: "\2192" / "".

  • Declare the plain content first and the slash version second, because a browser that cannot parse the slash syntax drops the whole declaration.
  • For an arrow character placed in markup, add aria-hidden=”true” to its span, since MDN lists decorative icons as the use case.
  • Never put aria-hidden on a focusable element such as the button itself.

Arrow-only buttons, such as a carousel’s previous and next controls, need an accessible name. ARIA attributes handle it: aria-label on the button, aria-hidden on the icon inside.

Contrast against the background

WCAG 2.2 Success Criterion 1.4.11 sets a minimum contrast ratio of 3:1 against adjacent colors for user interface components and meaningful graphics (W3C).

A tooltip tail shares its color with the tooltip, so the page behind it is the background to test. A color contrast checker takes the two color values and returns the ratio.

An arrow that only repeats a visible text label is exempt, according to the DAISY Accessible Publishing Knowledge Base, so arrow-only controls carry the real risk.

Forced colors mode and transparent borders

Forced colors mode, usually triggered by Windows High Contrast, replaces authored colors with a system palette. The CSS Color Adjust spec keeps transparency only for background-color and leaves forced border colors to the browser, so a border triangle can lose its shape.

A Drupal issue reports a chevron built from borders turning into a diamond in Edge’s forced colors mode, because the transparent borders were painted in a solid system color.

Test in the mode itself. Chrome DevTools emulates the mode under Rendering, with forced-colors: active selected (developer.chrome.com).

If a box appears, set forced-color-adjust to none on the arrow inside a forced-colors: active rule, keep the side borders transparent and color the pointing border with the CanvasText system color.

CSS Arrows FAQ

How do you make a curved CSS arrow?

Draw it as an SVG path, which handles curves, shafts and arrowheads in one element. clip-path also accepts shape() and path(), and MDN notes that shape() grows with the element while path() keeps its fixed coordinates.

Can a CSS arrow have a gradient fill?

Yes, but not with a border triangle. Give a box a linear-gradient background and clip it with polygon(). The gradient spans the whole box, so only the part inside the triangle shows.

How do you add arrows to a breadcrumb trail or a bulleted list?

Attach the arrow to each list item’s ::before or ::after with the content property and a Unicode arrow.

For a trail of breadcrumbs, use li:not(:last-child)::after so the final item carries no separator.

How do you replace the native arrow on a select menu?

Set appearance to none on the menu to drop the native arrow. Draw your own on a wrapper, since ::before and ::after do not work reliably on a select element itself, and add pointer-events: none so clicks reach the menu.

Are CSS arrows lighter than image arrows?

Yes. A CSS arrow is a few declarations inside a stylesheet the page already loads, so it adds no extra file request.

A PNG arrow needs its own file, plus another version for every color.

Which generators output CSS arrow code?

CSS Portal and CodeItBro both publish triangle generators that output border or clip-path code, and MDN offers a Shape generator for CSS shapes.

Use the border output for plain tooltip tails and the clip-path output when the arrow needs a gradient.

Choosing a Method for CSS Arrows in a Component Library

A component library should standardize on the border triangle for solid-fill pointers, with its color read from the same custom property as the tooltip background. When the theme changes, the arrow follows.

The order of work is simple. Bind the arrow color to the shared custom property, add a rotated square variant for bordered or shadowed tooltips, and test forced colors mode before release.

The order matters because a border triangle cannot carry a border or box-shadow of its own, and clip-path cuts both off at the polygon edge. The trade-off is two variants to maintain instead of one.

Verified in October 2026, this split holds until Firefox and Safari ship border-shape, which lets one declaration shape the box and its border together.