You want a message UI that feels alive: soft rounded bubbles, tidy tails pointing to the speaker, and colors that switch cleanly from incoming to outgoing without touching markup. In this tutorial you will build a flexible, themeable speech bubble using only HTML and CSS. You will learn how to draw the tail with a border-based triangle, how to keep the border looking continuous around the corner, and how to ship a compact component that scales from tooltips to chat threads.
Why Styling an HTML to Look Like a Speech Bubble Matters
Speech bubbles show up in chat UIs, comments, onboarding hints, and tooltips. Many teams still drop in images or SVG paths for the tail. That adds assets, slows theme changes, and complicates alignment. A CSS-only speech bubble keeps everything in one place, cuts download size, and responds instantly to variables for color, radius, and spacing. You also gain fine control over pointer direction and can flip sides with a single class.
Prerequisites
You will work with a single HTML structure and layer CSS on top. If you are comfortable with positioning and pseudo-elements, you will be right at home.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
The markup is lean on purpose: a container, a few message items, an optional avatar, and a paragraph for the bubble content. The tail and visual decoration are added entirely with CSS so you do not ship extra elements. The same structure supports left and right alignment through modifier classes.
<!-- HTML -->
<div class="chat" role="list" aria-label="Conversation">
<article class="message message--left" role="listitem" aria-label="Message from Alex">
<span class="avatar" aria-hidden="true"></span>
<p class="bubble">Hey, do you have a minute to review the layout?</p>
</article>
<article class="message message--right" role="listitem" aria-label="Message from You">
<p class="bubble">Yes. Send it over, I am at my desk.</p>
</article>
<article class="message message--left" role="listitem" aria-label="Message from Alex">
<span class="avatar" aria-hidden="true"></span>
<p class="bubble">Great. Pushed a new build with a tighter header and adjusted spacing on cards.</p>
</article>
</div>
The .chat container groups messages. Each .message describes who speaks with a side modifier (message–left for incoming, message–right for outgoing). The .bubble element holds text content. The avatar is decorative here, so it is hidden from assistive tech with aria-hidden. The tail will be drawn with a pseudo-element on .bubble.
Step 2: The Basic CSS & Styling
Start with variables for colors, spacing, and tail size. This keeps the component themeable. Then add layout rules for the page and the chat list. A small shadow and a consistent radius give the bubbles a friendly look.
/* CSS */
:root {
--page-bg: #f5f7fb;
--text: #111827;
--bubble-incoming: #ffffff;
--bubble-outgoing: #2563eb;
--bubble-outgoing-text: #ffffff;
--bubble-border: rgba(0, 0, 0, 0.08);
--shadow: 0 1px 2px rgba(0,0,0,0.06), 0 4px 12px rgba(0,0,0,0.04);
--radius: 18px;
--tail-size: 12px;
--gap: 14px;
}
*,
*::before,
*::after { box-sizing: border-box; }
body {
margin: 0;
font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
color: var(--text);
background: var(--page-bg);
}
.chat {
max-width: 720px;
margin: 40px auto;
padding: 24px 16px;
display: grid;
gap: 12px;
}
.message {
display: flex;
align-items: flex-end;
gap: 8px;
}
.message--right {
justify-content: flex-end;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #c7d2fe, #a5b4fc);
box-shadow: var(--shadow);
}
Advanced Tip: Theme your chat with a single class. For example, add .chat–dark on the container and override the variables inside it. This approach keeps selectors short and avoids repainting individual properties across many rules.
Step 3: Building the Bubble Base
Now create the pill shape and basic color scheme. The bubble gets relative positioning so the tail can anchor to its edges with pseudo-elements. The border provides definition on light backgrounds, and the shadow adds lift without being heavy.
/* CSS */
.bubble {
position: relative;
margin: 0;
padding: 10px 14px;
max-width: 62ch;
border-radius: var(--radius);
border: 1px solid var(--bubble-border);
box-shadow: var(--shadow);
background: var(--bubble-incoming);
color: var(--text);
word-wrap: break-word;
}
/* Side color variants */
.message--left .bubble {
background: var(--bubble-incoming);
color: var(--text);
}
.message--right .bubble {
background: var(--bubble-outgoing);
color: var(--bubble-outgoing-text);
margin-left: auto; /* push to the right line end */
}
How This Works (Code Breakdown)
The .bubble uses position: relative to create a coordinate system for its tail. That keeps the pointer glued to the bubble even when the line wraps or the message width changes. A generous border-radius produces the classic chat shape and avoids sharp corners that fight with the pointer angle.
Incoming and outgoing colors are split by side. The right side bubble has margin-left: auto to hug the right edge while the left side stays near the avatar. The border is subtle, which helps on white incoming bubbles. The shadow uses two layers to soften the bottom edge and reduce halo artifacts.
If you choose to display avatars as CSS-only shapes, you can make a circle with CSS and skip external images. This helps keep the component consistent with your theme variables.
Step 4: Adding and Aligning the Tail
The tail is a triangle formed by CSS borders. Draw a slightly larger triangle for the border and place the color triangle on top. This keeps the outline continuous from the bubble edge into the pointer, even when you change border thickness. The left and right sides use mirrored versions of the same trick.
/* CSS */
/* Base tail placeholders */
.bubble::before,
.bubble::after {
content: "";
position: absolute;
width: 0;
height: 0;
}
/* Left tail (border underlay) */
.message--left .bubble::before {
left: calc(-1 * var(--tail-size) - 1px);
bottom: 12px;
border-top: calc(var(--tail-size) - 3px) solid transparent;
border-bottom: calc(var(--tail-size) - 3px) solid transparent;
border-right: calc(var(--tail-size) + 1px) solid var(--bubble-border);
}
/* Left tail (fill on top) */
.message--left .bubble::after {
left: calc(-1 * var(--tail-size));
bottom: 12px;
border-top: calc(var(--tail-size) - 4px) solid transparent;
border-bottom: calc(var(--tail-size) - 4px) solid transparent;
border-right: var(--tail-size) solid var(--bubble-incoming);
}
/* Right tail (border underlay) */
.message--right .bubble::before {
right: calc(-1 * var(--tail-size) - 1px);
bottom: 12px;
border-top: calc(var(--tail-size) - 3px) solid transparent;
border-bottom: calc(var(--tail-size) - 3px) solid transparent;
border-left: calc(var(--tail-size) + 1px) solid var(--bubble-border);
}
/* Right tail (fill on top) */
.message--right .bubble::after {
right: calc(-1 * var(--tail-size));
bottom: 12px;
border-top: calc(var(--tail-size) - 4px) solid transparent;
border-bottom: calc(var(--tail-size) - 4px) solid transparent;
border-left: var(--tail-size) solid var(--bubble-outgoing);
}
How This Works (Code Breakdown)
Classic CSS triangles use borders. When three borders are transparent and one has a color, the element appears as a triangle that points toward the colored border. For a left-pointing tail you need a border-right triangle; for a right-pointing tail you need a border-left triangle. If you need a refresher on the border trick, see the left-pointing CSS triangle and the right-pointing CSS triangle guides.
Two layers create a clean seam. The ::before triangle acts as the border underlay. It is placed 1px farther out than the fill so its colored border exactly aligns with the bubble border. The ::after triangle is slightly smaller and sits above, using the same background color as the bubble. The bottom position is set to 12px so the pointer meets the rounded side at a comfortable height. You can match your typography by tying that offset to the line height or to a spacing variable.
This approach avoids aliasing gaps where the tail meets the pill edge. By sizing the underlay larger than the fill, the bubble border stays continuous around the corner even on high DPI displays.
Advanced Techniques: Animations, Themes, and Variants
You can layer subtle motion and theming without changing the HTML. The example below adds an entry animation, a hover lift for demos, and a dark theme variant that flips colors via variables. Motion respects prefers-reduced-motion to avoid distractions.
/* CSS */
/* Motion: enter and hover */
@keyframes bubble-in {
from {
transform: translateY(6px) scale(0.98);
opacity: 0;
filter: blur(2px);
}
to {
transform: translateY(0) scale(1);
opacity: 1;
filter: none;
}
}
.message {
animation: bubble-in 360ms cubic-bezier(.2,.7,.25,1) both;
}
.bubble {
transition: transform 160ms ease, box-shadow 160ms ease;
}
.bubble:hover {
transform: translateY(-1px);
box-shadow: 0 2px 3px rgba(0,0,0,.05), 0 10px 20px rgba(0,0,0,.05);
}
/* Reduced motion preference */
@media (prefers-reduced-motion: reduce) {
.message { animation: none; }
.bubble { transition: none; }
}
/* Dark theme override */
.chat.chat--dark {
--page-bg: #0e1320;
--text: #e5e7eb;
--bubble-incoming: #1f2937;
--bubble-outgoing: #3b82f6;
--bubble-outgoing-text: #f8fafc;
--bubble-border: rgba(0, 0, 0, 0.4);
--shadow: 0 1px 2px rgba(0,0,0,0.45), 0 8px 24px rgba(0,0,0,0.35);
}
/* Compact variant: great for tooltips */
.chat.chat--compact {
--radius: 10px;
--tail-size: 10px;
}
.chat.chat--compact .bubble {
padding: 8px 10px;
max-width: 46ch;
font-size: 14px;
line-height: 1.35;
}
Animations use transform and opacity to keep layout stable and repaint costs low. The blur in the first frame introduces a soft rise without heavy shadows. Theme switching happens at the container level, which means you can drop .chat–dark on a demo page or toggle it with a single class in a real app. The compact variant tightens spacing and radius, which fits tooltips or system hints that still need a bubble and pointer.
Accessibility & Performance
Visual polish should not fight readability or motion comfort. The rules below keep the component usable while freeing you to extend it.
Accessibility
Keep real text in the DOM, not in pseudo-elements; pseudo-elements are visual, not semantic. The tail uses ::before and ::after with empty content strings, which is correct because they are decorative. If the avatar is decorative, mark it with aria-hidden=”true” as shown. When the avatar conveys identity, provide an img with an alt value or an aria-label on the message group that names the speaker.
Check color contrast. Outgoing bubbles often use tinted backgrounds. Make sure text over the outgoing color meets contrast guidelines. This is why the example assigns a separate –bubble-outgoing-text variable. Avoid long lines; max-width on the bubble prevents wide measures that slow reading.
Respect user motion settings. The prefers-reduced-motion rule removes the entry animation and hover lift. This avoids vestibular triggers and aligns with inclusive defaults.
Performance
The tail uses borders, which render fast and do not require extra elements or images. Transforms and opacity animate on the compositor in modern browsers, which keeps interactions smooth on low-power devices. Avoid animating box-shadow continuously, as that can trigger expensive repaints; short hover lifts are fine for demos, but keep production motion subtle and rare.
CSS variables avoid duplication and reduce selector churn. Changing themes or spacing means updating a handful of properties at the container, rather than overriding many class rules. This lowers maintenance and helps avoid cascade battles that create specificity bloat.
Keep Shipping Bubbles That Fit Your UI
You built a speech bubble with a crisp tail, a clean border seam, and theme support, all with a single HTML structure. You learned the border triangle trick, split it into two layers for a seamless outline, and added motion and variants that respect user preferences. With these patterns you can wire bubbles into chat threads, tooltips, or callouts and still keep your CSS small and maintainable.