15 CSS Chat Bubbles09 / 15

Pure CSSMIT licensed

CSS Speech Bubble With Border

The hardest bubble to get right: an outlined one where the tail's stroke joins the border seamlessly. Three solutions side by side — the classic two stacked triangles, a single clip-path wedge outlined with filter: drop-shadow(), and and a nested-box stroke that can carry a gradient outline. Move the tail to any of the four sides with the pure-CSS control and watch all three follow.

Published

Live Demo
Try it

The code

<div class="cb-09">
  <input class="cb-09__sr" type="checkbox" id="cb-09-dark">
  <label class="cb-09__theme" for="cb-09-dark">
    <svg class="cb-09__moon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4 8.4 8.4 0 1 0 20 14.2" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
    <svg class="cb-09__sun" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.7"/><path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.9 5.9 7.3 7.3M16.7 16.7l1.4 1.4M18.1 5.9l-1.4 1.4M7.3 16.7l-1.4 1.4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
    <span class="cb-09__sr-text">Dark theme</span>
  </label>

  <input class="cb-09__sr" type="radio" name="cb-09-side" id="cb-09-left" checked>
  <input class="cb-09__sr" type="radio" name="cb-09-side" id="cb-09-right">
  <input class="cb-09__sr" type="radio" name="cb-09-side" id="cb-09-top">
  <input class="cb-09__sr" type="radio" name="cb-09-side" id="cb-09-bottom">

  <div class="cb-09__stage">
    <div class="cb-09__row">
      <figure class="cb-09__cell">
        <p class="cb-09__bubble cb-09__a">Two stacked triangles: outer stroke, inner fill, offset by the mitre distance.</p>
        <figcaption class="cb-09__tag">border ×2</figcaption>
      </figure>
      <figure class="cb-09__cell">
        <p class="cb-09__bubble cb-09__b">One clipped wedge, outlined by four drop-shadows that trace the silhouette.</p>
        <figcaption class="cb-09__tag">clip-path + drop-shadow</figcaption>
      </figure>
      <figure class="cb-09__cell">
        <p class="cb-09__bubble cb-09__c"><span class="cb-09__c-in">A nested box carries the fill, so the outer box is free to hold a gradient stroke.</span></p>
        <figcaption class="cb-09__tag">nested box · gradient stroke</figcaption>
      </figure>
    </div>

    <div class="cb-09__switch" role="radiogroup" aria-label="Tail side">
      <label class="cb-09__chip" for="cb-09-left">Left</label>
      <label class="cb-09__chip" for="cb-09-right">Right</label>
      <label class="cb-09__chip" for="cb-09-top">Top</label>
      <label class="cb-09__chip" for="cb-09-bottom">Bottom</label>
    </div>
  </div>
</div>
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap');

@property --cb-09-bw {
  syntax: '<length>';
  inherits: true;
  initial-value: 2px;
}

.cb-09 {
  --bg: #fbfaf7;
  --fill: #ffffff;
  --stroke: #141414;
  --ink: #141414;
  --muted: #6b6b66;
  --line: #e6e4dd;
  --acc: #ff4d2d;
  --acc-ink: #ffffff;
  --ring: #141414;
  --shadow: rgba(20,20,20,.12);
  --cb-09-bw: 2px;
  --tail: 16px;
  --radius: 1.1rem;
  --sans: "Sora",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
  --mono: "Geist Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
  position: relative;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.5rem,5vw,4rem);
  background-color: var(--bg);
  background-image: linear-gradient(var(--line) 1px, transparent 1px), linear-gradient(90deg, var(--line) 1px, transparent 1px);
  background-size: 2.5rem 2.5rem;
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

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

.cb-09__sr {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  opacity: 0;
}

.cb-09__sr-text {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cb-09__theme {
  position: absolute;
  inset-block-start: clamp(.9rem,3vw,1.6rem);
  inset-inline-end: clamp(.9rem,3vw,1.6rem);
  inline-size: 44px;
  block-size: 44px;
  display: grid;
  place-items: center;
  cursor: pointer;
  border-radius: 14px;
  border: 2px solid var(--stroke);
  background: var(--fill);
  color: var(--ink);
  transition: transform .25s cubic-bezier(.16,1,.3,1);
}

.cb-09__theme:hover {
  transform: translate(-1px,-2px);
}

.cb-09__theme svg {
  inline-size: 20px;
  block-size: 20px;
}

.cb-09__sun {
  display: none;
}

.cb-09__sr:focus-visible + .cb-09__theme {
  outline: 2px solid var(--acc);
  outline-offset: 3px;
}

.cb-09__stage {
  inline-size: min(100%,66rem);
  display: grid;
  gap: clamp(2rem,5vw,3.25rem);
  justify-items: center;
}

.cb-09__row {
  display: grid;
  gap: clamp(2rem,4vw,3rem);
  grid-template-columns: repeat(auto-fit,minmax(15rem,1fr));
  inline-size: 100%;
}

.cb-09__cell {
  margin: 0;
  display: grid;
  gap: 1rem;
  justify-items: center;
  align-content: start;
}

.cb-09__tag {
  font-family: var(--mono);
  font-size: .68rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
}

.cb-09__bubble {
  position: relative;
  margin: 0;
  inline-size: 100%;
  max-inline-size: 19rem;
  padding: 1.05rem 1.2rem;
  border-radius: var(--radius);
  font-size: .95rem;
  line-height: 1.5;
  letter-spacing: -.008em;
  text-wrap: pretty;
  background: var(--fill);
  color: var(--ink);
  transition: --cb-09-bw .35s cubic-bezier(.16,1,.3,1), translate .35s cubic-bezier(.16,1,.3,1);
}

.cb-09__cell:hover .cb-09__bubble {
  --cb-09-bw: 3px;
  translate: 0 -3px;
}
/* ═══ A · two stacked triangles ═══ */

.cb-09__a {
  border: var(--cb-09-bw) solid var(--stroke);
}

.cb-09__a::before,
.cb-09__a::after {
  content: '';
  position: absolute;
  inline-size: 0;
  block-size: 0;
}
/* ═══ B · clip-path wedge + drop-shadow outline ═══ */

.cb-09__b {
  filter: drop-shadow(var(--cb-09-bw) 0 0 var(--stroke)) drop-shadow(calc(var(--cb-09-bw) * -1) 0 0 var(--stroke)) drop-shadow(0 var(--cb-09-bw) 0 var(--stroke)) drop-shadow(0 calc(var(--cb-09-bw) * -1) 0 var(--stroke));
}

.cb-09__b::after {
  content: '';
  position: absolute;
  background: var(--fill);
}
/* ═══ C · nested box — the only technique that supports a GRADIENT stroke ═══ */

.cb-09__c {
  border: 0;
  padding: var(--cb-09-bw);
  background: var(--stroke);
}

@supports (background: linear-gradient(in oklab,red,blue)) {
  .cb-09__c {
    background: linear-gradient(140deg in oklab, var(--stroke), color-mix(in oklab,var(--stroke) 45%,var(--acc)));
  }
}

.cb-09__c-in {
  position: relative;
  display: block;
  padding: 1.05rem 1.2rem;
  border-radius: calc(var(--radius) - var(--cb-09-bw));
  background: var(--fill);
  color: var(--ink);
}

.cb-09__c::before,
.cb-09__c::after {
  content: '';
  position: absolute;
  inline-size: 0;
  block-size: 0;
}
/* ── TAIL GEOMETRY, one block per side ── */
/* LEFT (default) */

.cb-09__a::before {
  inset-block-start: 50%;
  inset-inline-start: calc(var(--tail) * -1);
  translate: 0 -50%;
  border-block: calc(var(--tail) * .8) solid transparent;
  border-inline-end: var(--tail) solid var(--stroke);
}

.cb-09__a::after {
  inset-block-start: 50%;
  inset-inline-start: calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6);
  translate: 0 -50%;
  border-block: calc(var(--tail) * .8) solid transparent;
  border-inline-end: var(--tail) solid var(--fill);
}

.cb-09__b::after {
  inset-block-start: 50%;
  inset-inline-start: calc(var(--tail) * -1 + 1px);
  translate: 0 -50%;
  inline-size: var(--tail);
  block-size: calc(var(--tail) * 1.5);
  clip-path: polygon(100% 0, 0 50%, 100% 100%);
}
/* RIGHT */

.cb-09:has(#cb-09-right:checked) .cb-09__a::before {
  inset-inline: auto calc(var(--tail) * -1);
  border-inline: 0;
  border-inline-start: var(--tail) solid var(--stroke);
  border-block: calc(var(--tail) * .8) solid transparent;
}

.cb-09:has(#cb-09-right:checked) .cb-09__a::after {
  inset-inline: auto calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6);
  border-inline: 0;
  border-inline-start: var(--tail) solid var(--fill);
  border-block: calc(var(--tail) * .8) solid transparent;
}

.cb-09:has(#cb-09-right:checked) .cb-09__b::after {
  inset-inline: auto calc(var(--tail) * -1 + 1px);
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
/* TOP */

.cb-09:has(#cb-09-top:checked) .cb-09__a::before {
  inset-block: calc(var(--tail) * -1) auto;
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-end: var(--tail) solid var(--stroke);
}

.cb-09:has(#cb-09-top:checked) .cb-09__a::after {
  inset-block: calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6) auto;
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-end: var(--tail) solid var(--fill);
}

.cb-09:has(#cb-09-top:checked) .cb-09__b::after {
  inset-block: calc(var(--tail) * -1 + 1px) auto;
  inset-inline-start: 50%;
  translate: -50% 0;
  inline-size: calc(var(--tail) * 1.5);
  block-size: var(--tail);
  clip-path: polygon(50% 0, 0 100%, 100% 100%);
}
/* BOTTOM */

.cb-09:has(#cb-09-bottom:checked) .cb-09__a::before {
  inset-block: auto calc(var(--tail) * -1);
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-start: var(--tail) solid var(--stroke);
}

.cb-09:has(#cb-09-bottom:checked) .cb-09__a::after {
  inset-block: auto calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6);
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-start: var(--tail) solid var(--fill);
}

.cb-09:has(#cb-09-bottom:checked) .cb-09__b::after {
  inset-block: auto calc(var(--tail) * -1 + 1px);
  inset-inline-start: 50%;
  translate: -50% 0;
  inline-size: calc(var(--tail) * 1.5);
  block-size: var(--tail);
  clip-path: polygon(50% 100%, 0 0, 100% 0);
}
/* the nested-box variant draws its tail with the same two-triangle overlap, on the OUTER box */

.cb-09__c::after {
  content: '';
  position: absolute;
  inline-size: 0;
  block-size: 0;
  inset-block-start: 50%;
  inset-inline-start: calc(var(--tail) * -1);
  translate: 0 -50%;
  border-block: calc(var(--tail) * .8) solid transparent;
  border-inline-end: var(--tail) solid var(--stroke);
}

.cb-09__c::before {
  content: '';
  position: absolute;
  inline-size: 0;
  block-size: 0;
  z-index: 1;
  inset-block-start: 50%;
  inset-inline-start: calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6);
  translate: 0 -50%;
  border-block: calc(var(--tail) * .8) solid transparent;
  border-inline-end: var(--tail) solid var(--fill);
}

.cb-09:has(#cb-09-right:checked) .cb-09__c::after {
  inset-inline: auto calc(var(--tail) * -1);
  border-inline: 0;
  border-inline-start: var(--tail) solid var(--stroke);
  border-block: calc(var(--tail) * .8) solid transparent;
}

.cb-09:has(#cb-09-right:checked) .cb-09__c::before {
  inset-inline: auto calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6);
  border-inline: 0;
  border-inline-start: var(--tail) solid var(--fill);
  border-block: calc(var(--tail) * .8) solid transparent;
}

.cb-09:has(#cb-09-top:checked) .cb-09__c::after {
  inset-block: calc(var(--tail) * -1) auto;
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-end: var(--tail) solid var(--stroke);
}

.cb-09:has(#cb-09-top:checked) .cb-09__c::before {
  inset-block: calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6) auto;
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-end: var(--tail) solid var(--fill);
}

.cb-09:has(#cb-09-bottom:checked) .cb-09__c::after {
  inset-block: auto calc(var(--tail) * -1);
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-start: var(--tail) solid var(--stroke);
}

.cb-09:has(#cb-09-bottom:checked) .cb-09__c::before {
  inset-block: auto calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6);
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-start: var(--tail) solid var(--fill);
}
/* ── switch ── */

.cb-09__switch {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
  justify-content: center;
  padding: .4rem;
  border-radius: 1rem;
  border: 2px solid var(--stroke);
  background: var(--fill);
  box-shadow: 4px 4px 0 var(--stroke);
}

.cb-09__chip {
  min-block-size: 44px;
  display: grid;
  place-items: center;
  padding: .5rem 1.15rem;
  cursor: pointer;
  border-radius: .7rem;
  color: var(--muted);
  font-size: .85rem;
  font-weight: 600;
  letter-spacing: -.01em;
  transition: background .2s, color .2s;
}

.cb-09__chip:hover {
  color: var(--ink);
}

#cb-09-left:checked ~ .cb-09__stage .cb-09__chip[for="cb-09-left"],
#cb-09-right:checked ~ .cb-09__stage .cb-09__chip[for="cb-09-right"],
#cb-09-top:checked ~ .cb-09__stage .cb-09__chip[for="cb-09-top"],
#cb-09-bottom:checked ~ .cb-09__stage .cb-09__chip[for="cb-09-bottom"] {
  background: var(--acc);
  color: var(--acc-ink);
}

#cb-09-left:focus-visible ~ .cb-09__stage .cb-09__chip[for="cb-09-left"],
#cb-09-right:focus-visible ~ .cb-09__stage .cb-09__chip[for="cb-09-right"],
#cb-09-top:focus-visible ~ .cb-09__stage .cb-09__chip[for="cb-09-top"],
#cb-09-bottom:focus-visible ~ .cb-09__stage .cb-09__chip[for="cb-09-bottom"] {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}
/* ── dark ── */

.cb-09:has(#cb-09-dark:checked) {
  --bg: #0d0d0c;
  --fill: #161615;
  --stroke: #f4f2ec;
  --ink: #f4f2ec;
  --muted: #9a9a93;
  --line: #1f1f1d;
  --acc: #ff6a4d;
  --acc-ink: #14100f;
}

.cb-09:has(#cb-09-dark:checked) .cb-09__moon {
  display: none;
}

.cb-09:has(#cb-09-dark:checked) .cb-09__sun {
  display: block;
}

@media (prefers-color-scheme: dark) {
  .cb-09:not(:has(#cb-09-dark:checked)) {
    --bg: #0d0d0c;
    --fill: #161615;
    --stroke: #f4f2ec;
    --ink: #f4f2ec;
    --muted: #9a9a93;
    --line: #1f1f1d;
    --acc: #ff6a4d;
    --acc-ink: #14100f;
  }

  .cb-09:not(:has(#cb-09-dark:checked)) .cb-09__moon {
    display: none;
  }

  .cb-09:not(:has(#cb-09-dark:checked)) .cb-09__sun {
    display: block;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cb-09 *,
    .cb-09 *::before,
    .cb-09 *::after {
    animation: none !important;
    transition: none !important;
  }
}

@media (forced-colors: active) {
  .cb-09__bubble {
    border: 2px solid CanvasText !important;
    filter: none !important;
    background: Canvas !important;
  }
}
Paste this into ChatGPT, Claude, Cursor, or any coding assistant. The block below is pre-framed with everything the AI needs to integrate this demo into your project — markup, styles, scoping notes, and the source URL. Hit Copy and paste straight into your chat.
Here's a working CSS Chat Bubble from CodeFronts. Use it as-is or adapt to your framework. All classes are scoped under a unique prefix so the code won't collide with your existing styles. MIT licensed.
Demo: CSS Speech Bubble With Border
Source: https://codefronts.com/snippets/css-chat-bubbles/css-speech-bubble-with-border/

The hardest bubble to get right: an outlined one where the tail's stroke joins the border seamlessly. Three solutions side by side — the classic two stacked triangles, a single clip-path wedge outlined with filter: drop-shadow(), and and a nested-box stroke that can carry a gradient outline. Move the tail to any of the four sides with the pure-CSS control and watch all three follow.
## HTML
```html
<div class="cb-09">
  <input class="cb-09__sr" type="checkbox" id="cb-09-dark">
  <label class="cb-09__theme" for="cb-09-dark">
    <svg class="cb-09__moon" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4 8.4 8.4 0 1 0 20 14.2" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg>
    <svg class="cb-09__sun" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"><circle cx="12" cy="12" r="4" stroke="currentColor" stroke-width="1.7"/><path d="M12 3.2v2M12 18.8v2M3.2 12h2M18.8 12h2M5.9 5.9 7.3 7.3M16.7 16.7l1.4 1.4M18.1 5.9l-1.4 1.4M7.3 16.7l-1.4 1.4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
    <span class="cb-09__sr-text">Dark theme</span>
  </label>

  <input class="cb-09__sr" type="radio" name="cb-09-side" id="cb-09-left" checked>
  <input class="cb-09__sr" type="radio" name="cb-09-side" id="cb-09-right">
  <input class="cb-09__sr" type="radio" name="cb-09-side" id="cb-09-top">
  <input class="cb-09__sr" type="radio" name="cb-09-side" id="cb-09-bottom">

  <div class="cb-09__stage">
    <div class="cb-09__row">
      <figure class="cb-09__cell">
        <p class="cb-09__bubble cb-09__a">Two stacked triangles: outer stroke, inner fill, offset by the mitre distance.</p>
        <figcaption class="cb-09__tag">border ×2</figcaption>
      </figure>
      <figure class="cb-09__cell">
        <p class="cb-09__bubble cb-09__b">One clipped wedge, outlined by four drop-shadows that trace the silhouette.</p>
        <figcaption class="cb-09__tag">clip-path + drop-shadow</figcaption>
      </figure>
      <figure class="cb-09__cell">
        <p class="cb-09__bubble cb-09__c"><span class="cb-09__c-in">A nested box carries the fill, so the outer box is free to hold a gradient stroke.</span></p>
        <figcaption class="cb-09__tag">nested box · gradient stroke</figcaption>
      </figure>
    </div>

    <div class="cb-09__switch" role="radiogroup" aria-label="Tail side">
      <label class="cb-09__chip" for="cb-09-left">Left</label>
      <label class="cb-09__chip" for="cb-09-right">Right</label>
      <label class="cb-09__chip" for="cb-09-top">Top</label>
      <label class="cb-09__chip" for="cb-09-bottom">Bottom</label>
    </div>
  </div>
</div>
```
## CSS
```css
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Geist+Mono:wght@400;500&display=swap');

@property --cb-09-bw {
  syntax: '<length>';
  inherits: true;
  initial-value: 2px;
}

.cb-09 {
  --bg: #fbfaf7;
  --fill: #ffffff;
  --stroke: #141414;
  --ink: #141414;
  --muted: #6b6b66;
  --line: #e6e4dd;
  --acc: #ff4d2d;
  --acc-ink: #ffffff;
  --ring: #141414;
  --shadow: rgba(20,20,20,.12);
  --cb-09-bw: 2px;
  --tail: 16px;
  --radius: 1.1rem;
  --sans: "Sora",ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;
  --mono: "Geist Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
  position: relative;
  width: 100%;
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  place-items: center;
  padding: clamp(1.5rem,5vw,4rem);
  background-color: var(--bg);
  background-image: linear-gradient(var(--line) 1px, transparent 1px), linear-gradient(90deg, var(--line) 1px, transparent 1px);
  background-size: 2.5rem 2.5rem;
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
}

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

.cb-09__sr {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  opacity: 0;
}

.cb-09__sr-text {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cb-09__theme {
  position: absolute;
  inset-block-start: clamp(.9rem,3vw,1.6rem);
  inset-inline-end: clamp(.9rem,3vw,1.6rem);
  inline-size: 44px;
  block-size: 44px;
  display: grid;
  place-items: center;
  cursor: pointer;
  border-radius: 14px;
  border: 2px solid var(--stroke);
  background: var(--fill);
  color: var(--ink);
  transition: transform .25s cubic-bezier(.16,1,.3,1);
}

.cb-09__theme:hover {
  transform: translate(-1px,-2px);
}

.cb-09__theme svg {
  inline-size: 20px;
  block-size: 20px;
}

.cb-09__sun {
  display: none;
}

.cb-09__sr:focus-visible + .cb-09__theme {
  outline: 2px solid var(--acc);
  outline-offset: 3px;
}

.cb-09__stage {
  inline-size: min(100%,66rem);
  display: grid;
  gap: clamp(2rem,5vw,3.25rem);
  justify-items: center;
}

.cb-09__row {
  display: grid;
  gap: clamp(2rem,4vw,3rem);
  grid-template-columns: repeat(auto-fit,minmax(15rem,1fr));
  inline-size: 100%;
}

.cb-09__cell {
  margin: 0;
  display: grid;
  gap: 1rem;
  justify-items: center;
  align-content: start;
}

.cb-09__tag {
  font-family: var(--mono);
  font-size: .68rem;
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--muted);
}

.cb-09__bubble {
  position: relative;
  margin: 0;
  inline-size: 100%;
  max-inline-size: 19rem;
  padding: 1.05rem 1.2rem;
  border-radius: var(--radius);
  font-size: .95rem;
  line-height: 1.5;
  letter-spacing: -.008em;
  text-wrap: pretty;
  background: var(--fill);
  color: var(--ink);
  transition: --cb-09-bw .35s cubic-bezier(.16,1,.3,1), translate .35s cubic-bezier(.16,1,.3,1);
}

.cb-09__cell:hover .cb-09__bubble {
  --cb-09-bw: 3px;
  translate: 0 -3px;
}
/* ═══ A · two stacked triangles ═══ */

.cb-09__a {
  border: var(--cb-09-bw) solid var(--stroke);
}

.cb-09__a::before,
.cb-09__a::after {
  content: '';
  position: absolute;
  inline-size: 0;
  block-size: 0;
}
/* ═══ B · clip-path wedge + drop-shadow outline ═══ */

.cb-09__b {
  filter: drop-shadow(var(--cb-09-bw) 0 0 var(--stroke)) drop-shadow(calc(var(--cb-09-bw) * -1) 0 0 var(--stroke)) drop-shadow(0 var(--cb-09-bw) 0 var(--stroke)) drop-shadow(0 calc(var(--cb-09-bw) * -1) 0 var(--stroke));
}

.cb-09__b::after {
  content: '';
  position: absolute;
  background: var(--fill);
}
/* ═══ C · nested box — the only technique that supports a GRADIENT stroke ═══ */

.cb-09__c {
  border: 0;
  padding: var(--cb-09-bw);
  background: var(--stroke);
}

@supports (background: linear-gradient(in oklab,red,blue)) {
  .cb-09__c {
    background: linear-gradient(140deg in oklab, var(--stroke), color-mix(in oklab,var(--stroke) 45%,var(--acc)));
  }
}

.cb-09__c-in {
  position: relative;
  display: block;
  padding: 1.05rem 1.2rem;
  border-radius: calc(var(--radius) - var(--cb-09-bw));
  background: var(--fill);
  color: var(--ink);
}

.cb-09__c::before,
.cb-09__c::after {
  content: '';
  position: absolute;
  inline-size: 0;
  block-size: 0;
}
/* ── TAIL GEOMETRY, one block per side ── */
/* LEFT (default) */

.cb-09__a::before {
  inset-block-start: 50%;
  inset-inline-start: calc(var(--tail) * -1);
  translate: 0 -50%;
  border-block: calc(var(--tail) * .8) solid transparent;
  border-inline-end: var(--tail) solid var(--stroke);
}

.cb-09__a::after {
  inset-block-start: 50%;
  inset-inline-start: calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6);
  translate: 0 -50%;
  border-block: calc(var(--tail) * .8) solid transparent;
  border-inline-end: var(--tail) solid var(--fill);
}

.cb-09__b::after {
  inset-block-start: 50%;
  inset-inline-start: calc(var(--tail) * -1 + 1px);
  translate: 0 -50%;
  inline-size: var(--tail);
  block-size: calc(var(--tail) * 1.5);
  clip-path: polygon(100% 0, 0 50%, 100% 100%);
}
/* RIGHT */

.cb-09:has(#cb-09-right:checked) .cb-09__a::before {
  inset-inline: auto calc(var(--tail) * -1);
  border-inline: 0;
  border-inline-start: var(--tail) solid var(--stroke);
  border-block: calc(var(--tail) * .8) solid transparent;
}

.cb-09:has(#cb-09-right:checked) .cb-09__a::after {
  inset-inline: auto calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6);
  border-inline: 0;
  border-inline-start: var(--tail) solid var(--fill);
  border-block: calc(var(--tail) * .8) solid transparent;
}

.cb-09:has(#cb-09-right:checked) .cb-09__b::after {
  inset-inline: auto calc(var(--tail) * -1 + 1px);
  clip-path: polygon(0 0, 100% 50%, 0 100%);
}
/* TOP */

.cb-09:has(#cb-09-top:checked) .cb-09__a::before {
  inset-block: calc(var(--tail) * -1) auto;
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-end: var(--tail) solid var(--stroke);
}

.cb-09:has(#cb-09-top:checked) .cb-09__a::after {
  inset-block: calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6) auto;
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-end: var(--tail) solid var(--fill);
}

.cb-09:has(#cb-09-top:checked) .cb-09__b::after {
  inset-block: calc(var(--tail) * -1 + 1px) auto;
  inset-inline-start: 50%;
  translate: -50% 0;
  inline-size: calc(var(--tail) * 1.5);
  block-size: var(--tail);
  clip-path: polygon(50% 0, 0 100%, 100% 100%);
}
/* BOTTOM */

.cb-09:has(#cb-09-bottom:checked) .cb-09__a::before {
  inset-block: auto calc(var(--tail) * -1);
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-start: var(--tail) solid var(--stroke);
}

.cb-09:has(#cb-09-bottom:checked) .cb-09__a::after {
  inset-block: auto calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6);
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-start: var(--tail) solid var(--fill);
}

.cb-09:has(#cb-09-bottom:checked) .cb-09__b::after {
  inset-block: auto calc(var(--tail) * -1 + 1px);
  inset-inline-start: 50%;
  translate: -50% 0;
  inline-size: calc(var(--tail) * 1.5);
  block-size: var(--tail);
  clip-path: polygon(50% 100%, 0 0, 100% 0);
}
/* the nested-box variant draws its tail with the same two-triangle overlap, on the OUTER box */

.cb-09__c::after {
  content: '';
  position: absolute;
  inline-size: 0;
  block-size: 0;
  inset-block-start: 50%;
  inset-inline-start: calc(var(--tail) * -1);
  translate: 0 -50%;
  border-block: calc(var(--tail) * .8) solid transparent;
  border-inline-end: var(--tail) solid var(--stroke);
}

.cb-09__c::before {
  content: '';
  position: absolute;
  inline-size: 0;
  block-size: 0;
  z-index: 1;
  inset-block-start: 50%;
  inset-inline-start: calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6);
  translate: 0 -50%;
  border-block: calc(var(--tail) * .8) solid transparent;
  border-inline-end: var(--tail) solid var(--fill);
}

.cb-09:has(#cb-09-right:checked) .cb-09__c::after {
  inset-inline: auto calc(var(--tail) * -1);
  border-inline: 0;
  border-inline-start: var(--tail) solid var(--stroke);
  border-block: calc(var(--tail) * .8) solid transparent;
}

.cb-09:has(#cb-09-right:checked) .cb-09__c::before {
  inset-inline: auto calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6);
  border-inline: 0;
  border-inline-start: var(--tail) solid var(--fill);
  border-block: calc(var(--tail) * .8) solid transparent;
}

.cb-09:has(#cb-09-top:checked) .cb-09__c::after {
  inset-block: calc(var(--tail) * -1) auto;
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-end: var(--tail) solid var(--stroke);
}

.cb-09:has(#cb-09-top:checked) .cb-09__c::before {
  inset-block: calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6) auto;
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-end: var(--tail) solid var(--fill);
}

.cb-09:has(#cb-09-bottom:checked) .cb-09__c::after {
  inset-block: auto calc(var(--tail) * -1);
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-start: var(--tail) solid var(--stroke);
}

.cb-09:has(#cb-09-bottom:checked) .cb-09__c::before {
  inset-block: auto calc(var(--tail) * -1 + var(--cb-09-bw) * 1.6);
  inset-inline-start: 50%;
  translate: -50% 0;
  border-inline: calc(var(--tail) * .8) solid transparent;
  border-block: 0;
  border-block-start: var(--tail) solid var(--fill);
}
/* ── switch ── */

.cb-09__switch {
  display: flex;
  flex-wrap: wrap;
  gap: .4rem;
  justify-content: center;
  padding: .4rem;
  border-radius: 1rem;
  border: 2px solid var(--stroke);
  background: var(--fill);
  box-shadow: 4px 4px 0 var(--stroke);
}

.cb-09__chip {
  min-block-size: 44px;
  display: grid;
  place-items: center;
  padding: .5rem 1.15rem;
  cursor: pointer;
  border-radius: .7rem;
  color: var(--muted);
  font-size: .85rem;
  font-weight: 600;
  letter-spacing: -.01em;
  transition: background .2s, color .2s;
}

.cb-09__chip:hover {
  color: var(--ink);
}

#cb-09-left:checked ~ .cb-09__stage .cb-09__chip[for="cb-09-left"],
#cb-09-right:checked ~ .cb-09__stage .cb-09__chip[for="cb-09-right"],
#cb-09-top:checked ~ .cb-09__stage .cb-09__chip[for="cb-09-top"],
#cb-09-bottom:checked ~ .cb-09__stage .cb-09__chip[for="cb-09-bottom"] {
  background: var(--acc);
  color: var(--acc-ink);
}

#cb-09-left:focus-visible ~ .cb-09__stage .cb-09__chip[for="cb-09-left"],
#cb-09-right:focus-visible ~ .cb-09__stage .cb-09__chip[for="cb-09-right"],
#cb-09-top:focus-visible ~ .cb-09__stage .cb-09__chip[for="cb-09-top"],
#cb-09-bottom:focus-visible ~ .cb-09__stage .cb-09__chip[for="cb-09-bottom"] {
  outline: 2px solid var(--ring);
  outline-offset: 2px;
}
/* ── dark ── */

.cb-09:has(#cb-09-dark:checked) {
  --bg: #0d0d0c;
  --fill: #161615;
  --stroke: #f4f2ec;
  --ink: #f4f2ec;
  --muted: #9a9a93;
  --line: #1f1f1d;
  --acc: #ff6a4d;
  --acc-ink: #14100f;
}

.cb-09:has(#cb-09-dark:checked) .cb-09__moon {
  display: none;
}

.cb-09:has(#cb-09-dark:checked) .cb-09__sun {
  display: block;
}

@media (prefers-color-scheme: dark) {
  .cb-09:not(:has(#cb-09-dark:checked)) {
    --bg: #0d0d0c;
    --fill: #161615;
    --stroke: #f4f2ec;
    --ink: #f4f2ec;
    --muted: #9a9a93;
    --line: #1f1f1d;
    --acc: #ff6a4d;
    --acc-ink: #14100f;
  }

  .cb-09:not(:has(#cb-09-dark:checked)) .cb-09__moon {
    display: none;
  }

  .cb-09:not(:has(#cb-09-dark:checked)) .cb-09__sun {
    display: block;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cb-09 *,
    .cb-09 *::before,
    .cb-09 *::after {
    animation: none !important;
    transition: none !important;
  }
}

@media (forced-colors: active) {
  .cb-09__bubble {
    border: 2px solid CanvasText !important;
    filter: none !important;
    background: Canvas !important;
  }
}
```

How this works

1 — two triangles. Draw the outer triangle in the border colour, then a second, slightly smaller triangle in the fill colour on top, pushed in by exactly the border width. The visible sliver between them is the border:

.cb-09__a::before{           /* outer: the stroke */
  border-block:12px solid transparent;
  border-inline-end:14px solid var(--stroke);
  inset-inline-start:-14px;
}
.cb-09__a::after{            /* inner: the fill, inset by the border width */
  border-block:12px solid transparent;
  border-inline-end:14px solid var(--fill);
  inset-inline-start:calc(-14px + var(--bw) * 1.6);
}

The 1.6 factor is not arbitrary: a 45° mitre means the perpendicular distance you need is the border width divided by cos(45°) ≈ 1.41, plus a hair to avoid a seam.

2 — clip-path + drop-shadow. Clip the wedge, then outline it with four tightly-offset drop shadows. Because filter operates on the composited shape, the outline traces the silhouette — including the tail — instead of the box:

.cb-09__b{ filter:
  drop-shadow(var(--bw) 0 0 var(--stroke)) drop-shadow(calc(var(--bw)*-1) 0 0 var(--stroke))
  drop-shadow(0 var(--bw) 0 var(--stroke)) drop-shadow(0 calc(var(--bw)*-1) 0 var(--stroke)); }

3 — the nested box. Two boxes: the outer one is the stroke, the inner one is the fill, and the padding between them is the border width. It is the only one of the three that can carry a gradient outline, because the stroke is a real background:

.cb-09__c{ padding:var(--bw);
  background:linear-gradient(140deg in oklab, var(--stroke), var(--acc)); }
.cb-09__c-in{ display:block; background:var(--fill);
  border-radius:calc(var(--radius) - var(--bw)); padding:1.05rem 1.2rem; }

Variations within a technique. The border approach gives you two useful shapes depending on how many neighbours you make transparent: one (border-block-start) puts the wedge flat on the baseline, two (border-block) makes an isosceles arrow you then centre on the side with inset-block: 50%. The clip-path approach varies by point list alone — the box, the anchor and the inherited fill never change:

/* concave curl */
clip-path:polygon(100% 0, 74% 40%, 40% 74%, 0 100%, 100% 100%);
/* pixel staircase */
clip-path:polygon(100% 0, 100% 100%, 0 100%, 0 66%, 34% 66%, 34% 33%, 67% 33%, 67% 0);

Mirror any of them for the opposite side by subtracting each x from 100%. All of it comes from one attribute-driven block, so adding a seventh silhouette means one more selector, not a new component.

Make it yours

  • --bw is a registered length: change it once and all three techniques re-stroke, and because it is registered you can transition it on hover.
  • Set --stroke to currentColor and the outline follows the text colour — handy for inverted or themed callouts.
  • For a dashed or gradient outline, use technique 3 and swap the outer background for a repeating-linear-gradient.
  • Turn the bubble into a tooltip by adding role="tooltip" and pairing it with aria-describedby on the trigger; the geometry needs no change.
  • Move the tail off-centre with the --pos percentage — useful when the bubble points at a specific word rather than the middle of an element.
  • For a thicker cartoon outline, push --bw to 3–4px and increase the tail size proportionally, or the tail's stroke will look heavier than the body's.

Gotchas — read before shipping

  • The inner-triangle offset must scale with --bw × ~1.41, not --bw — a 45° mitre needs the diagonal distance or you get a visible notch.
  • filter: drop-shadow() outlines cost a composite layer; use them on a handful of bubbles, not a thousand-row virtualised list.
  • A gradient stroke cannot flow into a border-triangle tail — the tail is a solid colour. If the gradient must continue through the tail, draw the whole silhouette with clip-path and stroke it with technique 2.
  • Any overflow: hidden on the bubble clips the tail; use overflow: clip with overflow-clip-margin if you must clip content.
  • Do not give the bubble a real border and a clip-path at the same time — clip-path cuts through the border, leaving a half-stroked edge.
  • Under forced-colors, filter-based outlines disappear. Add a real border inside a @media (forced-colors: active) block, which this stylesheet does.

Browser support

ChromeSafariFirefoxEdge
114+17.5+128+114+

Floor set by :has(), color-mix(), @property, text-wrap as this demo is written. The core technique itself goes back further — Chrome 111+.

Technique 1 works everywhere. Technique 2 needs filter (universal) and clip-path (Chrome 55 / Safari 9.1 / Firefox 54). Technique 3 is plain padding and background — the oklab gradient interpolation sits behind @supports and falls back to a solid stroke. :has() (Chrome 105 / Safari 15.4 / Firefox 121) drives the position switch. @property for animatable --bw is Chrome 85 / Safari 16.4 / Firefox 128.

Techniques used in this demo

Search CodeFronts

Loading…