CSS Speech Bubbles: 16 Examples + How to Build the Tail

Add a box-shadow to a CSS speech bubble that uses clip-path and watch it vanish. MDN documents that clip-path hides everything outside the clipping region, so the shadow has to move to a drop-shadow filter on a parent element.

That is why the tail method matters more than the bubble itself. Below are 16 CSS speech bubble examples you can try and copy, followed by a guide to building the tail.

By Bogdan Sandu · Updated October 28, 2026

Speech bubble tail methods

Five ways to draw the pointer, from the classic border triangle to a single clipped element. Each one changes what else keeps working: shadows, outlines, rounded tips or busy backgrounds behind the bubble.

Border triangle tails in four directions

  • border triangle
  • ::after
  • flex-direction

Points upbottom: 100%border-bottom-color

Points downtop: 100%border-top-color

Points leftright: 100%border-right-color

Points rightleft: 100%border-left-color

A zero-size pseudo-element with one colored border and three transparent ones draws the tail. The colored border is the side facing the bubble, and the offset (bottom, top, right or left at 100%) pushes the triangle out of the matching edge.

View code
.sb-dir {
  container-type: inline-size;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
  gap: 14px;
  width: min(600px, 100%);
}
.sb-dir__tile {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 16px;
  min-height: 150px;
  padding: 18px;
  border-radius: 16px;
  background: #fff;
}
.sb-dir__tile--down {
  flex-direction: column-reverse;
}
.sb-dir__tile--left {
  flex-direction: row;
}
.sb-dir__tile--right {
  flex-direction: row-reverse;
}
.sb-dir__dot {
  flex: none;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  background: #f2b84b;
  box-shadow: inset 0 0 0 4px #fff4dc;
}
.sb-dir__bubble {
  position: relative;
  display: grid;
  gap: 2px;
  margin: 0;
  padding: 12px 16px;
  border-radius: 12px;
  background: #23308f;
  color: #fff;
  font: inherit;
  font-size: 15px;
  line-height: 1.35;
}
.sb-dir__bubble strong {
  font-weight: 700;
}
.sb-dir__bubble code {
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12.5px;
  color: #d9defc;
  background: none;
  padding: 0;
}
.sb-dir__bubble::after {
  content: "";
  position: absolute;
  border: 9px solid transparent;
}
.sb-dir__tile--up .sb-dir__bubble::after {
  bottom: 100%;
  left: 50%;
  margin-left: -9px;
  border-bottom-color: #23308f;
}
.sb-dir__tile--down .sb-dir__bubble::after {
  top: 100%;
  left: 50%;
  margin-left: -9px;
  border-top-color: #23308f;
}
.sb-dir__tile--left .sb-dir__bubble::after {
  right: 100%;
  top: 50%;
  margin-top: -9px;
  border-right-color: #23308f;
}
.sb-dir__tile--right .sb-dir__bubble::after {
  left: 100%;
  top: 50%;
  margin-top: -9px;
  border-left-color: #23308f;
}

Rotated square with border and shadow

  • rotate(45deg)
  • filter: drop-shadow()
  • two-side border

Notifications

  • Lena Kraus approved your invoice 4 min ago
  • Tom Brandt mentioned you in Q4 budget 1 h ago

The tail is a small square turned 45 degrees, half hidden inside the panel. Only its top and left borders are drawn, so the outline runs around the point without a seam. A drop-shadow filter on the panel follows the combined shape, so the tail casts a shadow too.

View code
.sb-notif {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 14px;
  width: min(340px, 100%);
}
.sb-notif__bell {
  position: relative;
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  margin-right: 8px;
  border-radius: 12px;
  background: #fff;
  color: #1d2433;
  border: 1px solid #d5dae3;
}
.sb-notif__badge {
  position: absolute;
  top: -6px;
  right: -6px;
  min-width: 20px;
  height: 20px;
  padding: 0 5px;
  border-radius: 10px;
  background: #c8322b;
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  line-height: 20px;
  text-align: center;
}
.sb-notif__panel {
  position: relative;
  width: 100%;
  padding: 16px 16px 8px;
  border: 1px solid #d5dae3;
  border-radius: 14px;
  background: #fff;
  color: #1d2433;
  filter: drop-shadow(0 10px 14px rgba(16, 24, 40, .12));
}
.sb-notif__panel::before {
  content: "";
  position: absolute;
  top: -9px;
  right: 22px;
  width: 16px;
  height: 16px;
  background: #fff;
  border-top: 1px solid #d5dae3;
  border-left: 1px solid #d5dae3;
  border-top-left-radius: 3px;
  transform: rotate(45deg);
}
.sb-notif__title {
  margin: 0 0 8px;
  font-size: 15px;
  font-weight: 700;
}
.sb-notif__list {
  margin: 0;
  padding: 0;
  list-style: none;
}
.sb-notif__list li {
  display: flex;
  gap: 12px;
  align-items: flex-start;
  margin: 0;
  padding: 10px 0;
  border-top: 1px solid #edf0f4;
  font-size: 14px;
  line-height: 1.4;
}
.sb-notif__av {
  flex: none;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  color: #fff;
  font-size: 12px;
  font-weight: 700;
}
.sb-notif__av--a {
  background: #2f6b4f;
}
.sb-notif__av--b {
  background: #7a3e9d;
}
.sb-notif__time {
  display: block;
  color: #5b6475;
  font-size: 12.5px;
}

One element, clipped tail

  • clip-path: polygon()
  • shape() in @supports
  • drop-shadow on wrapper

Delivery update Your parcel is two stops away. Leave it with a neighbour?

The bubble and its tail are one element: extra bottom padding makes room for the tail, and clip-path cuts the box down to body plus pointer. Browsers with shape() get rounded corners from arc commands, others keep the square polygon. The shadow sits on the wrapper because clip-path would cut a box-shadow away.

View code
.sb-clip {
  width: min(320px, 100%);
  filter: drop-shadow(0 8px 10px rgba(6, 60, 56, .25));
}
.sb-clip__bubble {
  --sb-h: 16px;
  --sb-p: 30%;
  --sb-w: 12px;
  --sb-r: 16px;
  margin: 0;
  padding: 16px 20px calc(16px + var(--sb-h));
  background: #0f766e;
  color: #fff;
  font: inherit;
  font-size: 15px;
  line-height: 1.45;
  clip-path: polygon(0 0, 100% 0, 100% calc(100% - var(--sb-h)), calc(var(--sb-p) + var(--sb-w)) calc(100% - var(--sb-h)), var(--sb-p) 100%, calc(var(--sb-p) - var(--sb-w)) calc(100% - var(--sb-h)), 0 calc(100% - var(--sb-h)));
}
.sb-clip__bubble strong {
  display: block;
  margin-bottom: 2px;
}
@supports (clip-path: shape(from 0 0, line to 10px 10px)) {
  .sb-clip__bubble {
    clip-path: shape(from var(--sb-r) 0,
      hline to calc(100% - var(--sb-r)),
      arc to 100% var(--sb-r) of var(--sb-r) cw,
      vline to calc(100% - var(--sb-h) - var(--sb-r)),
      arc to calc(100% - var(--sb-r)) calc(100% - var(--sb-h)) of var(--sb-r) cw,
      hline to calc(var(--sb-p) + var(--sb-w)),
      line to var(--sb-p) 100%,
      line to calc(var(--sb-p) - var(--sb-w)) calc(100% - var(--sb-h)),
      hline to var(--sb-r),
      arc to 0 calc(100% - var(--sb-h) - var(--sb-r)) of var(--sb-r) cw,
      vline to var(--sb-r),
      arc to var(--sb-r) 0 of var(--sb-r) cw,
      close);
  }
}

Curved tail over a photo

  • radial-gradient hard stop
  • transparent cut-out
  • figcaption
Hiker with an orange backpack standing on a rocky ledge above a hazy mountain valley at sunrise
Made it to the ridge before sunrise. Worth every switchback. Day 3, 6:12 am

The tail is a small box filled with a radial gradient that switches from transparent to the bubble color at one radius. The hard stop leaves a concave curve and a sharp tip, and because the cut-out is truly transparent, the photo shows through it on any background.

View code
.sb-photo {
  position: relative;
  width: min(520px, 100%);
  margin: 0;
  border-radius: 18px;
  overflow: hidden;
  container-type: inline-size;
}
.sb-photo img {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}
.sb-photo__bubble {
  position: absolute;
  top: 7%;
  left: 6%;
  max-width: min(64%, 260px);
  padding: 12px 15px;
  border-radius: 16px;
  background: #1f1b16;
  color: #fff7ea;
  font: inherit;
  font-size: clamp(13px, 3.6cqi, 15px);
  line-height: 1.4;
  filter: drop-shadow(0 6px 10px rgba(0, 0, 0, .3));
}
.sb-photo__bubble span {
  display: block;
  margin-top: 4px;
  color: #e3c9a3;
  font-size: 12.5px;
}
.sb-photo__bubble::after {
  content: "";
  position: absolute;
  top: calc(100% - 1px);
  right: 22%;
  width: 30px;
  height: 30px;
  background: radial-gradient(circle at 0 100%, transparent 29.5px, #1f1b16 30px);
}

Outlined bubble with outlined tail

  • stacked triangles
  • ::before + ::after
  • border offset

Priya Nair on Pricing page, frame 3

Can the plan switcher sit above the fold on mobile? It is the first thing people look for.

A border on the body stops at the body, so the tail gets two triangles: a larger one in the border color behind, and a smaller one in the fill color in front, shifted toward the bubble. The 3px difference at 45 degrees leaves a line about 2px thick, matching the body border.

View code
.sb-out {
  display: flex;
  align-items: flex-start;
  gap: 18px;
  width: min(420px, 100%);
}
.sb-out__av {
  flex: none;
  display: grid;
  place-items: center;
  width: 42px;
  height: 42px;
  border: 2px solid #1b1b1b;
  border-radius: 50%;
  background: #ffcf5c;
  color: #1b1b1b;
  font-size: 13px;
  font-weight: 700;
}
.sb-out__bubble {
  position: relative;
  min-width: 0;
  padding: 12px 16px 14px;
  border: 2px solid #1b1b1b;
  border-radius: 18px;
  background: #fff;
  color: #1b1b1b;
  box-shadow: 4px 4px 0 #1b1b1b;
}
.sb-out__bubble::before,
.sb-out__bubble::after {
  content: "";
  position: absolute;
  right: 100%;
  border-style: solid;
  border-color: transparent;
}
.sb-out__bubble::before {
  top: 10px;
  border-width: 12px;
  border-right-color: #1b1b1b;
}
.sb-out__bubble::after {
  top: 13px;
  border-width: 9px;
  border-right-color: #fff;
}
.sb-out__who {
  margin: 0 0 4px;
  font-size: 14px;
  font-weight: 700;
}
.sb-out__who span {
  font-weight: 400;
  color: #555;
}
.sb-out__text {
  margin: 0;
  font-size: 15px;
  line-height: 1.45;
}

Speech bubble styles

The same rounded box and tail, pushed into comic thought clouds, a jagged shout, a dark code review comment and a bubble that pops out of its launcher.

Comic thought bubble

  • uneven border-radius
  • circle trail
  • ::before / ::after

What if the build passes on the first try?

A thought bubble swaps the pointed tail for a trail of shrinking circles. The cloud is one element with eight-value border-radius for a lumpy outline, the two larger circles are its pseudo-elements, and the smallest is a span so the trail can reach the character.

View code
.sb-think {
  position: relative;
  display: grid;
  justify-items: start;
  width: min(420px, 100%);
  padding: 22px 22px 18px;
  border: 3px solid #111;
  border-radius: 6px;
  background: #ffe066;
  box-sizing: border-box;
}
.sb-think__cloud {
  position: relative;
  justify-self: end;
  max-width: 78%;
  margin: 0 0 64px;
  padding: 22px 28px;
  border: 3px solid #111;
  border-radius: 48% 52% 46% 54% / 58% 46% 54% 42%;
  background: #fff;
  color: #111;
  font: inherit;
  font-size: 17px;
  font-weight: 700;
  line-height: 1.3;
  text-align: center;
}
.sb-think__cloud::before,
.sb-think__cloud::after,
.sb-think__dot {
  content: "";
  position: absolute;
  border: 3px solid #111;
  border-radius: 50%;
  background: #fff;
}
.sb-think__cloud::before {
  top: calc(100% + 6px);
  left: 18%;
  width: 22px;
  height: 18px;
}
.sb-think__cloud::after {
  top: calc(100% + 30px);
  left: 9%;
  width: 14px;
  height: 12px;
}
.sb-think__dot {
  top: calc(100% + 48px);
  left: 2%;
  width: 8px;
  height: 8px;
}
.sb-think__head {
  position: absolute;
  left: 18px;
  bottom: 14px;
  width: 52px;
  height: 52px;
  border: 3px solid #111;
  border-radius: 50%;
  background: #f28c6f;
}
.sb-think__head span,
.sb-think__head::after {
  content: "";
  position: absolute;
  top: 14px;
  width: 6px;
  height: 9px;
  border-radius: 50%;
  background: #111;
}
.sb-think__head span {
  left: 24px;
}
.sb-think__head::after {
  left: 36px;
}

Shout burst

  • clip-path: polygon()
  • stacked layers
  • hard drop-shadow

Wait! Your cart is still here

A 22-point polygon makes the jagged outline, and one point is pulled far out to act as the tail. The black outline is a second clipped layer behind the yellow one, inset by a few pixels, and the hard comic shadow comes from drop-shadow on the wrapper, since clip-path removes box-shadow.

View code
.sb-burst {
  width: min(380px, 100%);
  filter: drop-shadow(6px 6px 0 #c2261d);
}
.sb-burst__edge {
  aspect-ratio: 4 / 3;
  padding: 7px;
  background: #111;
  box-sizing: border-box;
}
.sb-burst__edge,
.sb-burst__fill {
  clip-path: polygon(50% 4%, 61% 13.3%, 75.4% 10.3%, 79.5% 23%, 92.8% 27.4%, 88.6% 39.4%, 96.5% 49.7%, 85.5% 57.3%, 85.5% 70.2%, 71.1% 70.9%, 63.2% 82.4%, 50% 76%, 22% 98%, 28.9% 70.9%, 14.5% 70.2%, 14.5% 57.3%, 3.5% 49.7%, 11.4% 39.4%, 7.2% 27.4%, 20.5% 23%, 24.6% 10.3%, 39% 13.3%);
}
.sb-burst__fill {
  display: grid;
  place-content: center;
  height: 100%;
  margin: 0;
  padding: 0 24% 14%;
  box-sizing: border-box;
  background: #ffd23f;
  color: #111;
  font: inherit;
  font-size: clamp(14px, 4.4vw, 19px);
  font-weight: 800;
  line-height: 1.15;
  text-align: center;
  text-transform: uppercase;
}
.sb-burst__fill strong {
  display: block;
  font-size: 1.7em;
  font-weight: 900;
  letter-spacing: .02em;
}

Dark code review comment

  • mask: conic-gradient()
  • background: inherit
  • dark surface
41const total = items.reduce(add, 0);42return total * 1.2;

Ines R. commented on line 42

Pull 1.2 into a named TAX_RATE so the next rate change is one edit.

On a dark surface the bubble needs a lighter fill, not a shadow, to stand out. The tail is a small box that inherits the bubble background and is cut to a triangle by a conic-gradient mask, so changing the theme color changes the tail with it.

View code
.sb-review {
  display: grid;
  gap: 14px;
  width: min(440px, 100%);
  color: #e8ecf3;
}
.sb-review__code {
  margin: 0;
  padding: 10px 0;
  border: 1px solid #2a3140;
  border-radius: 10px;
  background: #11151c;
  color: #c9d1de;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
  line-height: 1.7;
  overflow-x: auto;
}
.sb-review__code code {
  display: block;
  font: inherit;
  background: none;
  color: inherit;
  padding: 0;
}
.sb-review__line {
  display: block;
  padding: 0 14px;
  white-space: pre;
}
.sb-review__line--hit {
  background: #2b2414;
  box-shadow: inset 3px 0 0 #e5a93b;
}
.sb-review__ln {
  display: inline-block;
  width: 2.2em;
  color: #7d8799;
}
.sb-review__bubble {
  position: relative;
  padding: 14px 16px;
  border-radius: 12px;
  background: #262d3b;
}
.sb-review__bubble::before {
  content: "";
  position: absolute;
  bottom: calc(100% - 1px);
  left: 30px;
  width: 20px;
  height: 11px;
  background: inherit;
  -webkit-mask: conic-gradient(from 138deg at 50% 0, #000 84deg, transparent 0);
  mask: conic-gradient(from 138deg at 50% 0, #000 84deg, transparent 0);
}
.sb-review__who {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
  margin: 0 0 8px;
  color: #aab4c6;
  font-size: 13.5px;
}
.sb-review__who strong {
  color: #fff;
}
.sb-review__av {
  display: grid;
  place-items: center;
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: #8fa8ff;
  color: #10162a;
  font-size: 11px;
  font-weight: 700;
}
.sb-review__text {
  margin: 0;
  font-size: 15px;
  line-height: 1.5;
}
.sb-review__text code {
  padding: 1px 5px;
  border-radius: 5px;
  background: #11151c;
  color: #ffd38a;
  font-family: ui-monospace, Consolas, monospace;
  font-size: .9em;
}

Bubble that pops out of its launcher

  • transform-origin at the tip
  • hidden + aria-expanded
  • opt-in motion

Noor from Fieldnote

Questions about your trial? We usually reply in about five minutes.

The bubble scales up from the point of its tail, so it seems to come out of the launcher button. Only transform and opacity animate, which avoids layout work, and the keyframes live inside a prefers-reduced-motion: no-preference query, so the bubble simply appears when motion is reduced.

View code
.sb-pop {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 16px;
  width: min(320px, 100%);
  min-height: 230px;
  justify-content: flex-end;
}
.sb-pop__bubble {
  position: relative;
  padding: 14px 18px;
  border-radius: 18px 18px 6px 18px;
  background: #fff;
  color: #1f2430;
  box-shadow: 0 12px 28px rgba(31, 36, 48, .14);
  transform-origin: calc(100% - 24px) calc(100% + 12px);
}
.sb-pop__bubble[hidden] {
  display: none;
}
.sb-pop__bubble::after {
  content: "";
  position: absolute;
  top: 100%;
  right: 18px;
  border: 8px solid transparent;
  border-top-color: #fff;
  border-bottom: 0;
}
.sb-pop__name {
  margin: 0 0 2px;
  font-size: 14px;
  font-weight: 700;
  color: #b4441f;
}
.sb-pop__text {
  margin: 0;
  font-size: 15px;
  line-height: 1.45;
}
.sb-pop__launch {
  display: grid;
  place-items: center;
  width: 56px;
  height: 56px;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: #b4441f;
  color: #fff;
  cursor: pointer;
}
.sb-pop__launch:hover {
  background: #993817;
}
.sb-pop__launch:focus-visible {
  outline: 3px solid #1f2430;
  outline-offset: 3px;
}
@media (prefers-reduced-motion: no-preference) {
  .sb-pop__bubble:not([hidden]) {
    animation: sb-pop .42s cubic-bezier(.2, .9, .3, 1.25) both;
  }
  @keyframes sb-pop {
    from {
      opacity: 0;
      transform: scale(.3);
    }
  }
}

Speech bubbles in real interfaces

Where bubbles actually ship: testimonials, onboarding coach marks, map callouts and chat threads. Each one keeps its text in real markup and the tail out of the accessibility tree.

Testimonial quote bubble

  • blockquote + figcaption
  • border-radius tail
  • Fraunces

We moved four warehouses onto the new schedule in a week. Nobody asked for the old spreadsheet back.

Maya OkaforHead of Operations, Harbor & Pine

The quote is a real blockquote and the speaker sits in figcaption. The tail is a square with one corner rounded to 100%, which leaves a quarter circle: straight where it meets the bubble, curved on the side, and pointing down at the avatar.

View code
.sb-quote {
  width: min(440px, 100%);
  margin: 0;
}
.sb-quote__bubble {
  position: relative;
  margin: 0 0 30px;
  padding: 24px 26px;
  border: 0;
  border-radius: 22px;
  background: #f3e6cf;
  color: #2b2118;
  quotes: none;
}
.sb-quote__bubble::before {
  display: none;
}
.sb-quote__bubble::after {
  content: "";
  position: absolute;
  top: calc(100% - 1px);
  left: 34px;
  width: 26px;
  height: 24px;
  background: #f3e6cf;
  border-bottom-right-radius: 100%;
}
.sb-quote__bubble p {
  margin: 0;
  font-family: "Fraunces", Georgia, serif;
  font-size: clamp(18px, 4.6vw, 21px);
  font-style: normal;
  line-height: 1.4;
  color: #2b2118;
}
.sb-quote__by {
  display: flex;
  align-items: center;
  gap: 12px;
  padding-left: 20px;
  color: #4d4033;
  font-size: 14px;
  line-height: 1.35;
}
.sb-quote__by strong {
  display: block;
  color: #2b2118;
  font-size: 15px;
}
.sb-quote__av {
  flex: none;
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: #8a4b2a;
  color: #fff;
  font-size: 15px;
  font-weight: 700;
}

Onboarding coach mark

  • tail as a sibling
  • clamp() placement
  • non-modal dialog
  • Lumen Dental$18,400
  • Okra Studio$6,250
  • Bramble Bikes$11,900
  • Quayside Clinic$24,000

The tour bubble has to stay inside the card while its tail points at a toolbar button near the edge. So the tail is a sibling element placed by one --sb-x value, and the bubble clamps its own left edge between the card margins. Next and Done move focus predictably and the step text is announced politely.

View code
.sb-coach {
  --sb-x: 16.667%;
  position: relative;
  width: min(460px, 100%);
  min-height: 300px;
  padding: 0 0 12px;
  border-radius: 16px;
  background: #fff;
  color: #1c2230;
  box-shadow: 0 1px 0 #dfe3ea, 0 10px 30px rgba(28, 34, 48, .08);
  overflow: hidden;
}
.sb-coach[data-step="2"] {
  --sb-x: 50%;
}
.sb-coach[data-step="3"] {
  --sb-x: 83.333%;
}
.sb-coach__bar {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  border-bottom: 1px solid #e6e9ef;
}
.sb-coach__tool {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  margin: 8px;
  padding: 8px 0;
  border-radius: 9px;
  color: #3a4252;
  font-size: 14px;
  font-weight: 600;
}
.sb-coach[data-step="1"] .sb-coach__tool:nth-child(1),
.sb-coach[data-step="2"] .sb-coach__tool:nth-child(2),
.sb-coach[data-step="3"] .sb-coach__tool:nth-child(3) {
  background: #eef0ff;
  color: #3a2fc2;
  box-shadow: 0 0 0 2px #3a2fc2;
}
.sb-coach__rows {
  margin: 0;
  padding: 6px 18px;
  list-style: none;
}
.sb-coach__rows li {
  display: flex;
  justify-content: space-between;
  margin: 0;
  padding: 11px 0;
  border-bottom: 1px solid #f0f2f5;
  font-size: 14px;
  color: #5b6372;
}
.sb-coach__tail {
  position: absolute;
  top: 62px;
  left: var(--sb-x);
  margin-left: -10px;
  border: 10px solid transparent;
  border-top: 0;
  border-bottom-color: #3a2fc2;
  z-index: 2;
}
.sb-coach__mark {
  position: absolute;
  top: 72px;
  left: clamp(10px, calc(var(--sb-x) - 130px), calc(100% - min(260px, 100% - 20px) - 10px));
  z-index: 2;
  width: min(260px, calc(100% - 20px));
  padding: 14px 16px 12px;
  border-radius: 14px;
  background: #3a2fc2;
  color: #fff;
  box-shadow: 0 14px 30px rgba(58, 47, 194, .3);
  box-sizing: border-box;
}
.sb-coach__tail[hidden],
.sb-coach__mark[hidden] {
  display: none;
}
@media (prefers-reduced-motion: no-preference) {
  .sb-coach__mark,
  .sb-coach__tail {
    transition: left .3s ease;
  }
}
.sb-coach__title {
  margin: 0 0 4px;
  font-size: 16px;
  font-weight: 700;
}
.sb-coach__text {
  margin: 0 0 12px;
  font-size: 14.5px;
  line-height: 1.45;
  color: #e6e4ff;
}
.sb-coach__foot {
  display: flex;
  align-items: center;
  gap: 8px;
}
.sb-coach__count {
  margin-right: auto;
  font-size: 13px;
  color: #d6d3ff;
}
.sb-coach__skip,
.sb-coach__next,
.sb-coach__restart {
  min-height: 36px;
  padding: 0 14px;
  border-radius: 9px;
  font: inherit;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
}
.sb-coach__skip {
  border: 1px solid #8f88e8;
  background: transparent;
  color: #fff;
}
.sb-coach__next {
  border: 0;
  background: #fff;
  color: #3a2fc2;
}
.sb-coach__restart {
  position: absolute;
  top: 74px;
  left: 50%;
  transform: translateX(-50%);
  border: 0;
  background: #3a2fc2;
  color: #fff;
}
.sb-coach__restart[hidden] {
  display: none;
}
.sb-coach__skip:focus-visible,
.sb-coach__next:focus-visible {
  outline: 3px solid #fff;
  outline-offset: 2px;
}
.sb-coach__restart:focus-visible {
  outline: 3px solid #1c2230;
  outline-offset: 3px;
}

Map pin callout

  • aria-expanded pins
  • custom property placement
  • clamped callout
Street map of a neighbourhood with a park, a river and three marked places

Kettle & Crumb Bakery

Open until 6 pm · 4 min walk

Each pin is a button with aria-expanded. The open pin writes its name to data-open, which sets --sb-x and --sb-y for one shared callout. The tail sits exactly over the pin while the callout body clamps itself inside the map, so pins near the edge still get a readable card.

View code
.sb-map {
  --sb-x: 24%;
  --sb-y: 64%;
  position: relative;
  width: min(560px, 100%);
  aspect-ratio: 4 / 3;
  border-radius: 18px;
  overflow: hidden;
  box-shadow: 0 10px 30px rgba(40, 32, 20, .12);
}
.sb-map[data-open="b"] {
  --sb-x: 54%;
  --sb-y: 74%;
}
.sb-map[data-open="c"] {
  --sb-x: 80%;
  --sb-y: 60%;
}
.sb-map__img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.sb-map__pin {
  position: absolute;
  width: 34px;
  height: 34px;
  margin: -34px 0 0 -17px;
  padding: 0;
  border: 3px solid #fff;
  border-radius: 50% 50% 50% 0;
  background: #d4483b;
  transform: rotate(-45deg);
  transform-origin: 50% 50%;
  box-shadow: -2px 2px 6px rgba(0, 0, 0, .25);
  cursor: pointer;
}
.sb-map__pin::after {
  content: "";
  position: absolute;
  inset: 9px;
  border-radius: 50%;
  background: #fff;
}
.sb-map__pin[aria-expanded="true"] {
  background: #1d2a44;
}
.sb-map__pin:focus-visible {
  outline: 3px solid #1d2a44;
  outline-offset: 3px;
}
.sb-map__pin--a {
  left: 24%;
  top: 64%;
}
.sb-map__pin--b {
  left: 54%;
  top: 74%;
}
.sb-map__pin--c {
  left: 80%;
  top: 60%;
}
.sb-map__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.sb-map__tail {
  position: absolute;
  left: var(--sb-x);
  top: calc(var(--sb-y) - 56px);
  margin-left: -10px;
  border: 10px solid transparent;
  border-bottom: 0;
  border-top-color: #fff;
  z-index: 3;
  filter: drop-shadow(0 3px 2px rgba(29, 42, 68, .18));
}
.sb-map__card {
  position: absolute;
  bottom: calc(100% - var(--sb-y) + 55px);
  left: clamp(8px, calc(var(--sb-x) - 110px), calc(100% - min(220px, 100% - 16px) - 8px));
  z-index: 2;
  width: min(220px, calc(100% - 16px));
  padding: 11px 38px 11px 14px;
  border-radius: 12px;
  background: #fff;
  color: #1d2a44;
  box-shadow: 0 8px 20px rgba(29, 42, 68, .22);
  box-sizing: border-box;
}
.sb-map__tail[hidden],
.sb-map__card[hidden] {
  display: none;
}
.sb-map__name {
  margin: 0 0 2px;
  font-size: 15px;
  font-weight: 700;
  line-height: 1.3;
}
.sb-map__meta {
  margin: 0;
  font-size: 13px;
  color: #4f5a6e;
}
.sb-map__close {
  position: absolute;
  top: 6px;
  right: 6px;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: #4f5a6e;
  cursor: pointer;
}
.sb-map__close:hover {
  background: #eef1f6;
}
.sb-map__close:focus-visible {
  outline: 2px solid #1d2a44;
  outline-offset: 1px;
}

Chat thread with corner tails

  • curved corner tail
  • :has(+ sibling)
  • role="log"

Jonas Weber Today 18:02

Jonas: Are we still on for the climbing gym at 7?

Jonas: I can bring the spare harness

You: Yes! Running ten minutes late though

You: Save me a spot on the beginner wall

Jonas: Deal. See you there

The swooping corner tail is two pseudo-elements: one in the bubble color with a rounded corner, and one in the panel color that carves the inside curve. That carving only works on a solid background. :has() drops the tail and tightens the corner on every bubble that is followed by another from the same sender.

View code
.sb-chat {
  width: min(380px, 100%);
  border-radius: 20px;
  background: #fff;
  box-shadow: 0 10px 30px rgba(20, 24, 33, .1);
  overflow: hidden;
}
.sb-chat__head {
  margin: 0;
  padding: 12px 18px;
  border-bottom: 1px solid #eceef2;
  color: #5c6370;
  font-size: 13px;
  text-align: center;
}
.sb-chat__head strong {
  display: block;
  color: #15181f;
  font-size: 15px;
}
.sb-chat__log {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 16px 18px 18px;
}
.sb-chat__msg {
  position: relative;
  max-width: 75%;
  margin: 0;
  padding: 8px 14px;
  border-radius: 18px;
  font: inherit;
  font-size: 15px;
  line-height: 1.35;
  overflow-wrap: anywhere;
}
.sb-chat__msg--them {
  align-self: flex-start;
  background: #e9eaef;
  color: #15181f;
}
.sb-chat__msg--me {
  align-self: flex-end;
  background: #2563eb;
  color: #fff;
}
.sb-chat__msg--them + .sb-chat__msg--me,
.sb-chat__msg--me + .sb-chat__msg--them {
  margin-top: 10px;
}
.sb-chat__msg::before,
.sb-chat__msg::after {
  content: "";
  position: absolute;
  bottom: 0;
  height: 20px;
}
.sb-chat__msg--me::before {
  right: -7px;
  width: 20px;
  background: #2563eb;
  border-bottom-left-radius: 16px 14px;
}
.sb-chat__msg--me::after {
  right: -26px;
  width: 26px;
  background: #fff;
  border-bottom-left-radius: 10px;
}
.sb-chat__msg--them::before {
  left: -7px;
  width: 20px;
  background: #e9eaef;
  border-bottom-right-radius: 16px 14px;
}
.sb-chat__msg--them::after {
  left: -26px;
  width: 26px;
  background: #fff;
  border-bottom-right-radius: 10px;
}
.sb-chat__msg--me:has(+ .sb-chat__msg--me) {
  border-bottom-right-radius: 6px;
}
.sb-chat__msg--them:has(+ .sb-chat__msg--them) {
  border-bottom-left-radius: 6px;
}
.sb-chat__msg--me + .sb-chat__msg--me {
  border-top-right-radius: 6px;
}
.sb-chat__msg--them + .sb-chat__msg--them {
  border-top-left-radius: 6px;
}
.sb-chat__msg--me:has(+ .sb-chat__msg--me)::before,
.sb-chat__msg--me:has(+ .sb-chat__msg--me)::after,
.sb-chat__msg--them:has(+ .sb-chat__msg--them)::before,
.sb-chat__msg--them:has(+ .sb-chat__msg--them)::after {
  display: none;
}
.sb-chat__sr {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

Tail positioning with modern CSS

Custom properties, logical properties and anchor positioning move the tail without new class names. Every demo here keeps a fallback for browsers that lack the newer feature.

Tail placed by a custom property

  • --sb-p
  • clamp()
  • range input

The tail reads --sb-p and clamps it, so it never slides past the 18px corner.

--sb-p: 50%

The tail reads one custom property, and clamp() keeps it at least one corner radius plus half the tail base from either end. Drag the slider to 0 or 100 and the tail stops on the straight part of the edge instead of sliding off the corner.

View code
.sb-var {
  --sb-p: 50%;
  display: grid;
  gap: 30px;
  width: min(420px, 100%);
}
.sb-var__bubble {
  position: relative;
  margin: 0;
  padding: 16px 20px;
  border-radius: 18px;
  background: #5b3fd6;
  color: #fff;
  font: inherit;
  font-size: 15.5px;
  line-height: 1.45;
}
.sb-var__bubble code {
  padding: 1px 5px;
  border-radius: 5px;
  background: #4430a8;
  color: #fff;
  font-family: ui-monospace, Consolas, monospace;
  font-size: .9em;
}
.sb-var__bubble::after {
  content: "";
  position: absolute;
  top: 100%;
  left: clamp(30px, var(--sb-p), calc(100% - 30px));
  margin-left: -12px;
  border: 12px solid transparent;
  border-bottom: 0;
  border-top-color: #5b3fd6;
}
.sb-var__ctl {
  display: grid;
  grid-template-columns: 1fr auto;
  align-items: center;
  gap: 6px 12px;
  padding: 14px 16px;
  border-radius: 14px;
  background: #fff;
}
.sb-var__label {
  font-size: 14px;
  font-weight: 600;
  color: #2a2540;
}
.sb-var__out {
  font-family: ui-monospace, Consolas, monospace;
  font-size: 13px;
  color: #4a3fa0;
}
.sb-var__range {
  grid-column: 1 / -1;
  width: 100%;
  min-height: 28px;
  margin: 0;
  accent-color: #5b3fd6;
}
.sb-var__range:focus-visible {
  outline: 3px solid #5b3fd6;
  outline-offset: 3px;
}

One rule set for LTR and RTL

  • inset-inline-*
  • border-inline-*-color
  • margin-inline-start: auto

Hi! Did the parcel arrive?

Yes, it came this morning.

dir="ltr"

مرحبا! هل وصل الطرد؟

نعم، وصل هذا الصباح.

dir="rtl"

Written with logical properties, the same rules flip for right-to-left text. The tail offset uses inset-inline-start and inset-inline-end, the colored border is border-inline-start or -end, and sent messages align with margin-inline-start: auto, so dir="rtl" moves everything without an override.

View code
.sb-rtl {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
  gap: 14px;
  width: min(560px, 100%);
}
.sb-rtl__panel {
  margin: 0;
  padding: 16px 18px 10px;
  border-radius: 16px;
  background: #fffdf8;
  border: 1px solid #e8e1d3;
}
.sb-rtl__panel figcaption {
  margin-top: 12px;
  text-align: center;
}
.sb-rtl__panel figcaption code {
  font-family: ui-monospace, Consolas, monospace;
  font-size: 12.5px;
  color: #6a5a3c;
  background: none;
  padding: 0;
}
.sb-rtl__thread {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.sb-rtl__msg {
  position: relative;
  width: fit-content;
  max-width: 82%;
  margin: 0;
  padding: 9px 14px;
  border-radius: 14px;
  font: inherit;
  font-size: 15px;
  line-height: 1.4;
}
.sb-rtl__msg--them {
  margin-inline: 8px auto;
  border-end-start-radius: 4px;
  background: #e7ecdf;
  color: #22301a;
}
.sb-rtl__msg--me {
  margin-inline: auto 8px;
  border-end-end-radius: 4px;
  background: #2f5d3a;
  color: #fff;
}
.sb-rtl__msg::after {
  content: "";
  position: absolute;
  inset-block-end: 0;
  border: 8px solid transparent;
  border-block-end: 0;
}
.sb-rtl__msg--them::after {
  inset-inline-end: 100%;
  border-inline-end-color: #e7ecdf;
  border-inline-start: 0;
}
.sb-rtl__msg--me::after {
  inset-inline-start: 100%;
  border-inline-start-color: #2f5d3a;
  border-inline-end: 0;
}

Status bubble that follows its anchor

  • anchor-name
  • anchor() in clamp()
  • @supports fallback

Rui Heads down on the pricing table. Ping after lunch.

The pressed avatar becomes the anchor. With anchor positioning, the bubble clamps itself inside the card while a separate tail sits at anchor(center), so it always points at the right person. Without it, the bubble spans the card and :has() moves the tail to the matching column.

View code
.sb-anc {
  position: relative;
  width: min(460px, 100%);
  padding: 130px 0 0;
}
.sb-anc__row {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  padding: 10px 0;
  border-radius: 16px;
  background: #fff;
}
.sb-anc__btn {
  display: grid;
  justify-items: center;
  gap: 6px;
  margin: 0 auto;
  padding: 6px 4px;
  border: 0;
  border-radius: 12px;
  background: none;
  color: #3b4150;
  font: inherit;
  font-size: 13.5px;
  font-weight: 600;
  cursor: pointer;
}
.sb-anc__av {
  display: grid;
  place-items: center;
  width: 46px;
  height: 46px;
  border-radius: 50%;
  color: #fff;
  font-size: 13px;
  font-weight: 700;
}
.sb-anc__av--1 {
  background: #b5482f;
}
.sb-anc__av--2 {
  background: #1f6f8b;
}
.sb-anc__av--3 {
  background: #6b4fa0;
}
.sb-anc__av--4 {
  background: #3d7a3b;
}
.sb-anc__btn[aria-pressed="true"] {
  color: #10141c;
  anchor-name: --sb-anc-active;
}
.sb-anc__btn[aria-pressed="true"] .sb-anc__av {
  box-shadow: 0 0 0 3px #fff, 0 0 0 5px #10141c;
}
.sb-anc__btn:focus-visible {
  outline: 3px solid #10141c;
  outline-offset: 2px;
}
.sb-anc__bubble {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 104px;
  margin: 0;
  padding: 14px 16px;
  border-radius: 14px;
  background: #10141c;
  color: #eef1f6;
  font: inherit;
  font-size: 14.5px;
  line-height: 1.45;
  box-sizing: border-box;
}
.sb-anc__name {
  display: block;
  color: #fff;
}
.sb-anc__bubble::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 12.5%;
  margin-left: -10px;
  border: 10px solid transparent;
  border-bottom: 0;
  border-top-color: #10141c;
}
.sb-anc:has(.sb-anc__btn:nth-child(2)[aria-pressed="true"]) .sb-anc__bubble::after {
  left: 37.5%;
}
.sb-anc:has(.sb-anc__btn:nth-child(3)[aria-pressed="true"]) .sb-anc__bubble::after {
  left: 62.5%;
}
.sb-anc:has(.sb-anc__btn:nth-child(4)[aria-pressed="true"]) .sb-anc__bubble::after {
  left: 87.5%;
}
.sb-anc__tail {
  display: none;
}
@supports (anchor-name: --sb-a) {
  .sb-anc__bubble {
    position-anchor: --sb-anc-active;
    top: auto;
    right: auto;
    bottom: calc(anchor(top) + 16px);
    left: clamp(0px, calc(anchor(center) - 120px), calc(100% - min(240px, 100%)));
    width: min(240px, 100%);
    height: auto;
    min-height: 0;
  }
  .sb-anc__bubble::after {
    display: none;
  }
  .sb-anc__tail {
    display: block;
    position: absolute;
    position-anchor: --sb-anc-active;
    bottom: calc(anchor(top) + 6px);
    left: calc(anchor(center) - 10px);
    border: 10px solid transparent;
    border-bottom: 0;
    border-top-color: #10141c;
  }
}

What is a CSS speech bubble made of?

Mostly a rounded box and a pointer. The box is a p or div with a background, padding and border-radius. The pointer, called the tail, is a ::before or ::after pseudo-element positioned absolutely against it. The box also needs position: relative, otherwise the tail’s offsets get measured from some ancestor instead of the bubble’s own edges.

Nicolas Gallagher’s 2010 tutorial on pure CSS speech bubbles already kept most bubbles to a single element and pushed every visual detail into the stylesheet. That approach still holds.

Border-radius sets the curve (CSS Backgrounds and Borders Module Level 3 defines it), and padding keeps text off the corners. Add a max-width in ch so the bubble doesn’t stretch across the whole page.

Start with one radius on all four corners. A border radius generator helps once the corners need to differ, which happens as soon as a tail sits near one of them.

The tail needs content: "". Without it the pseudo-element is not generated at all.

.bubble {
  position: relative;
  max-width: 28ch;
  padding: 0.75em 1em;
  border-radius: 12px;
  background: #e8f0fe;
}

.bubble::after {
  content: "";
  position: absolute;
}

Put together, the build goes in this order.

  1. Write the body with background, padding, border-radius and a max-width in ch.
  2. Set position: relative on the body.
  3. Add ::after with an empty content string and position: absolute.
  4. Give the tail its shape (border triangle, clip-path or mask) and offset it with top, bottom, left or right.

How does the border triangle method build a tail?

A zero-size element with one colored border and three transparent ones renders as a triangle, and that triangle is the tail. It needs only border, position and content properties.

The colored border is the one facing the bubble. So border-top-color gives a tail that hangs below the bubble and points down.

.bubble::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 24px;
  border: 10px solid transparent;
  border-top-color: #e8f0fe;
}

The other directions follow the same logic, each with an offset on the free axis:

  • Tail above, pointing up: bottom: 100% and border-bottom-color
  • Tail on the left, pointing left: right: 100% and border-right-color
  • Tail on the right, pointing right: left: 100% and border-left-color

Code Boxx’s responsive bubble tutorial runs this trick through four modifier classes (up, down, left, right). A CSS triangle generator produces the border values for any size, which saves the trial and error, and the same border trick draws most CSS arrows.

The appeal is how little it takes. One pseudo-element, a handful of properties that matter, and no support checks in current browsers, since it relies on basic border and positioning rules. The tail size changes with a single border-width value, in px or em.

The downside is the tip. It’s sharp, the edges are straight, and the triangle can’t follow border-radius, so a tail placed at a rounded corner looks detached. An outline needs a second triangle (covered below).

How to build a tail with clip-path or mask

Clip-path and mask draw the tail on the same element as the body, so the basic shape needs no pseudo-element. Clip-path suits straight-edged tails, and mask suits rounded ones.

Using clip-path and border-image fill

Temani Afif’s two-property tooltip, published in Smashing Magazine in March 2024, sets the color with border-image and the fill keyword. The border-image outset paints an overflowing color past the box, covering the area where the tail goes, and clip-path with polygon() cuts out the rectangle plus the pointer.

The same series moves the tail with a –p custom property and reshapes it with –x. A clip-path generator is quicker for sketching the polygon points before converting them to percentages.

Everything lives on one element, with no pseudo-element, and tail size and position sit in custom properties. It has been available across browsers since January 2020 (MDN).

The catch is that clip-path clips everything outside the shape, box-shadow included, and it creates a new stacking context (MDN). The border-radius also needs adjusting to the tail position (Smashing Magazine, 2024).

Using mask and conic-gradient for rounded corners

Rounded corners are where clip-path polygons stop being practical, and the mask method takes over. In Afif’s version for Verpex, the tail is set by a height and an angle (a 20px tail at 60deg in the demo), where the clip-path version uses a width and a height.

His CSS-Tip bubble rounds the body with border-radius and carves a curved tail with a radial-gradient mask, driven by two variables: –r for the radius and –t for the tail size.

One rule gives a rounded body and a rounded tail, and the tail angle is adjustable. The stacked gradients are hard to read, though, and some published snippets still pair mask with -webkit-mask.

The unprefixed mask shorthand has been available across browsers since December 2023, and conic-gradient since November 2020 (both per MDN). A mask generator saves the manual gradient math.

Resizable tails with the shape() function

The older path() function takes SVG path data in fixed CSS pixels, so a bubble drawn with it does not grow with its text. MDN’s example shows the failure: on a larger element the shape stays the same size, and the top and left borders end up partly visible while the right and bottom borders are clipped away.

The shape() function accepts percentages, calc() and custom properties, so the clipped shape scales with the element. It became Baseline Newly Available in February 2026, when Firefox 148 shipped.

BrowserFirst version with shape()Release date
Safari18.431 March 2025
Chrome1351 April 2025
Firefox14824 February 2026

Source: Web Platform DX web-features data, as of October 2026.

Browsers older than those versions drop the declaration, so write a polygon() clip-path first and the shape() rule after it.

How to add an outline and shadow to a bubble with a tail

A border on the bubble body stops at the body, and neither tail method carries it around the tail. Both get fixed the same way, by drawing a slightly smaller layer on top of a first layer in the border color.

On border triangle bubbles that means stacking two triangles. The back one is 1px larger and carries the border color, and the front one carries the background color and sits 1px to the side (30secondsofcode’s example uses an 11px triangle behind a 10px one).

Clip-path bubbles get a pseudo-element inset by the border thickness, with clip-path: inherit so it reuses the body’s shape. Part 2 of the Smashing Magazine series (March 2024) reports that the tail’s border comes out thinner than the body’s until the tail is defined by an angle and a height instead of a base and a height.

Shadows split the two methods further. Box-shadow works on the body of a border triangle bubble, and a box shadow generator speeds up the tuning, but the tail gets no shadow from it.

Clip-path and mask-image clip a box-shadow along with the element, so any shadow outside the shape disappears (CSS-IRL). The fix is filter: drop-shadow() on a parent wrapper around the clipped element. On a border triangle bubble, drop-shadow on the bubble itself covers body and tail together.

Drop-shadow follows the alpha shape, so the tail casts a shadow too. CSS-IRL also notes that box-shadow renders darker and heavier than drop-shadow at the same values, so lower the alpha when switching.

If the two layers drift apart, untick the clip-path declaration in Chrome DevTools. The unclipped boxes show exactly where the misalignment starts.

Which tail method should you pick?

Use the border triangle for flat-color bubbles with square corners, clip-path for single-element components with a variable-driven tail, and mask only when the body and tail both need rounding. The rotated square sits between them: a small square turned 45 degrees behind the body, which gives the tail its own shadow.

MethodRounded tailShadow routeSetup cost
Border triangleNodrop-shadow on the bubbleLow
Rotated squareOptional, via border-radiusIts own box-shadowLow
clip-path polygon()Nodrop-shadow on a wrapperMedium
mask with gradientsYesdrop-shadow on a wrapperHigh

Skip mask when a border triangle will do. A notification bubble with a flat color and straight corners gains nothing from stacked gradients, and the extra rule is one more thing to debug.

Browser support and failure conditions

The border triangle only works with a flat color. Give the body a gradient and you get a visible color jump at the tail, because the triangle cannot continue the gradient.

The rotated square has half of itself sitting inside the body, so it needs the same background and a z-index behind the text. H5P’s JoubelUI stylesheet does exactly this with a 12px square rotated 45deg, a box-shadow of its own and z-index: -1.

Clip-path means a wrapper if you want the shadow, so the component carries two nodes in the DOM.

Mask hides the shape inside gradient stops, so a misaligned tail means reading layered gradients. FreeFrontend’s listing of a single-element version highlights a gradient that flows across both body and tail, which a triangle cannot do.

Support rarely decides the choice. Every method above sits at Widely available status in Baseline except shape(), which keeps its polygon() fallback, and Can I use gives the exact version floor before a fallback is dropped.

How to position the tail and align chat bubbles

Pick the side with top, bottom, left or right, set the offset with one value or variable, and mirror the rule for the opposite side. Chat lists add one step: the tail side flips between sent and received messages.

Setting the tail side and offset

A fixed offset like left: 24px holds the tail at a constant distance from the corner. Use left: 20% and it follows the bubble as its width changes. Centering takes left: 50% with transform: translateX(-50%), and if you want the whole thing variable-driven, a –p custom property can be read by left or by the clip-path polygon.

Offsets near the end of an edge fail first. In Temani Afif’s Smashing Magazine tutorial, pushing the position variable to its extremes slides the tail off the end of the bubble.

Clamp the offset instead. Keep it at least one corner radius plus half the tail base away from either end, so the tail stays on the straight part of the edge.

Sent and received messages in a chat list

Bottom-aligning a message list with justify-content: flex-end makes the earliest messages unreachable once the list overflows, according to Chen Hui Jing’s box alignment write-up. Her fix is margin-top: auto on the first child, which keeps the list pinned to the bottom and still scrollable.

Sent bubbles take align-self: flex-end, and received ones keep the default start alignment. The tail moves with them, right side for sent and left side for received.

  • Flex alignment follows dir=”rtl” without extra rules, while margin-based alignment needs manual overrides (Alex Chan, 2026)
  • Facebook Messenger varies corner rounding across consecutive bubbles, with three states that differ for sender and recipient (Ahmad Shadeed’s component breakdown)
  • Showing the tail only on the last bubble of a run keeps a long thread from looking spiky

An iMessage-style corner tail has a catch. freeCodeCamp’s React Native build overlaps two elements at the bubble corner and colors one to match the chat screen, so the cut-out only works on a solid chat background.

Bubbles are one piece of a CSS chat box, which also needs a scrolling message list, a header and an input row.

How to make a speech bubble responsive

Size the body in ch or % so the text wraps, size the tail in em so it scales with the font, and offset the tail in % so it holds its place as the width changes.

A max-width in ch counts characters, so line length stays readable at any font size. Padding derived from the radius keeps text clear of the curve. And if the bubble width isn’t fixed, use % for the tail offset, not px.

Pick em when the tail should follow the bubble’s own text size, and rem when it should follow the root. The em versus rem trade-off is the same one that applies everywhere else in a stylesheet.

Container queries became Baseline Widely available in August 2025 (web.dev), after landing in all major engines in 2023.

A bubble in a narrow chat column and the same bubble in a wide card can switch tail side or font size from the container’s width. Media queries react to the screen instead, which is the wrong signal for a component dropped into different slots.

Container units go further. One cqi equals 1% of the container’s inline size, so the tail size can follow the container directly.

How to keep a speech bubble accessible

Most of the accessibility work sits in the text, not the tail. The tail needs no treatment while its generated content stays an empty string.

Test the text against the bubble background, not the page behind it. WCAG 2.2 Success Criterion 1.4.3 sets the thresholds, and the W3C’s Understanding document says computed ratios are not rounded, so 4.499:1 fails. Normal text needs at least 4.5:1. Large-scale text gets by with 3:1, which means 18pt or 14pt bold (about 24px and 18.5px).

A pastel bubble with white text is the usual offender. Run the pair through a color contrast check before the shape work starts.

Pick the element to match the content. A single message fits a p. Quotes and testimonials belong in a blockquote (Nicolas Gallagher’s demo styles blockquote elements as bubbles), and a message list is a ul or ol with li items.

For a live chat list, put role=”log” on the container. MDN names chat logs as the example, and the role announces new messages when the user is idle. It carries an implicit aria-live of polite, and an accessible name is required, so name the log with aria-label or aria-labelledby. See the ARIA role basics if the attribute set is new.

Generated content is exposed to assistive tools, per MDN’s breadcrumb recipe. An empty string has nothing to read, so the tail stays silent.

If the tail uses an image, add an empty alternative: content: url(tail.svg) / "";. And do not let bubble color be the only sign of who sent a message, since a visible name or a visually hidden label covers readers who cannot tell the colors apart.

CSS Speech Bubble FAQ

Is a speech bubble the same thing as a tooltip?

No, but they share one design idea: a text box with a pointing tail, according to Temani Afif’s tutorial for Verpex. A tooltip adds behavior, appearing on hover or focus and anchoring to a trigger element.

How do you turn a speech bubble into a thought bubble?

Replace the pointed tail with a trail of shrinking circles. FreeFrontend’s Pure CSS Thought Bubbles example builds the trail from shadows that inherit the background color, so the circles always match the body.

Is a generator faster than writing the CSS by hand?

Yes, for a known shape. Afif’s CSS Generators site lists more than 70 tooltip and speech bubble shapes, each built from one element and each with copy-ready CSS.

Hand-written CSS pays off once the bubble needs behavior a preset lacks.

Does Bootstrap or Tailwind CSS include a speech bubble component?

No. Bootstrap ships tooltips and popovers with an arrow, positioned by Popper, which is a hover pattern rather than a static bubble. Tailwind CSS has no bubble component either, but its after: variant reproduces the pseudo-element tail.

Can a speech bubble animate in without causing layout shift?

Yes, when the animation changes only transform and opacity. web.dev’s animation guidance says to stick to those two properties, because animating layout properties forces layout work.

CSS keyframes handle the timing, and a transform-origin at the tail tip makes the bubble grow out of the speaker.

Can the same bubble work as a popover?

Yes. The HTML popover attribute, Baseline Newly available since January 2025 (web-features data), renders the bubble in the top layer, so a parent’s overflow or position rules cannot clip it.

MDN’s examples pair it with anchor positioning to place the element beside its trigger. For hover-only cases, a plain CSS tooltip pattern is enough.

When an Inline SVG Bubble Beats CSS

A CSS speech bubble stops being the right build when the tail must curve along an arbitrary outline. An inline SVG bubble draws any curve exactly.

The switch has a cost: the W3C’s SVG 1.1 specification states that SVG performs no automatic line breaking or word wrapping. Stuff & Nonsense placed a foreignObject paragraph inside each of the eight speech bubbles on its about page to get text that wraps.

The trade-off is exact curves in exchange for owning the text flow, which HTML handles for free.

The shape() function draws curves with percentage units and is expected to reach Baseline Widely available on 24 August 2028 (web-features data, checked October 2026). From that date a curved tail can stay in CSS without a polygon() fallback.

Until then, embedding SVG in HTML covers the cases CSS cannot.