How to Make a Pure CSS Icon Set

Introduction

Chat UIs look simple until you try to design them with no images or SVGs. In this project, you’ll build a polished, production-ready chat bubble with message tails and circular avatars using only HTML and CSS. By the end, you’ll have a reusable component that scales cleanly, themes easily with CSS variables, and needs no external assets for its shapes.

Why Pure CSS Chat Bubbles Matter

Shipping shapes as CSS keeps the DOM lean and the network quiet. No PNG tail sprites. No SVG imports for avatars. CSS borders can draw the tails, and border-radius handles perfect circles for avatars. That makes your UI crisp on any display and easier to theme with variables. It also ties directly into your design tokens, so colors and spacing stay consistent across components. If you already use shape utilities, this piece fits right into your toolkit. For example, tails rely on triangles, the same technique used in an right-pointing triangle with CSS and a left-pointing triangle with CSS. Avatars use the same approach you’d apply to a circle with CSS.

Prerequisites

You don’t need a framework or build step for this. A single HTML file with a style block is enough. You should be comfortable writing selectors, targeting pseudo-elements, and tuning layout with grid or flex.

  • Basic HTML
  • CSS custom properties
  • CSS pseudo-elements (::before / ::after)

Step 1 – The HTML Structure

The chat is a semantic list. Each message is a list item with a bubble and optional avatar. Outgoing messages sit on the right without an avatar. Incoming messages sit on the left with a circular avatar. Time metadata sits at the edge of the row so it reads cleanly across the transcript.

<ul class="chat" role="list" aria-label="Conversation between Jess and Sam">
  <li class="message incoming" role="listitem">
    <div class="avatar" aria-hidden="true">
      <img src="https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&fit=crop&q=60" alt="">
    </div>
    <p class="bubble">Hey! Are we still on for the design review later today?</p>
    <time class="meta" datetime="2025-10-31T09:41:00">09:41</time>
  </li>

  <li class="message outgoing" role="listitem">
    <p class="bubble">Yes, meeting at 2pm. I'll bring the latest prototypes.</p>
    <time class="meta" datetime="2025-10-31T09:42:00">09:42</time>
  </li>

  <li class="message incoming typing" role="listitem" aria-live="polite">
    <div class="avatar" aria-hidden="true">
      <img src="https://images.unsplash.com/photo-1527980965255-d3b416303d12?w=96&h=96&fit=crop&q=60" alt="">
    </div>
    <p class="bubble" aria-label="Jess is typing"><span class="dots" aria-hidden="true"></span></p>
    <time class="meta" datetime="2025-10-31T09:42:10">typing…</time>
  </li>
</ul>

Step 2 – The Basic CSS & Styling

Set up design tokens, type, and layout. The chat uses CSS grid per message row, which gives a clean way to position avatar, bubble, and time. Variables handle theming for light/dark bubbles and text. Spacing uses a scale so tweaks stay consistent.

/* CSS: Base and tokens */
:root {
  --bg: #0f172a;             /* slate-900 */
  --panel: #111827;          /* gray-900 */
  --bubble-in: #1f2937;      /* gray-800 */
  --bubble-out: #4f46e5;     /* indigo-600 */
  --bubble-out-hi: #6366f1;  /* indigo-500 */
  --text: #e5e7eb;           /* gray-200 */
  --muted: #9ca3af;          /* gray-400 */
  --radius: 16px;
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-6: 24px;
  --max-bubble: 52ch;
  --shadow: 0 6px 18px rgba(0,0,0,.35);
}

* { box-sizing: border-box; }
html, body { height: 100%; }

body {
  margin: 0;
  font: 16px/1.45 system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue", Arial, "Noto Sans", "Apple Color Emoji", "Segoe UI Emoji";
  background: radial-gradient(1200px 600px at 80% -10%, #1f2937, transparent 70%), var(--bg);
  color: var(--text);
  display: grid;
  place-items: start center;
  padding: 48px 16px;
}

/* Chat container */
.chat {
  list-style: none;
  margin: 0;
  padding: var(--space-6);
  width: min(760px, 100%);
  background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02));
  border: 1px solid rgba(255,255,255,.08);
  border-radius: 20px;
  box-shadow: var(--shadow);
  display: grid;
  row-gap: var(--space-4);
}

/* Message row layout */
.message {
  display: grid;
  grid-template-columns: auto 1fr auto; /* avatar | bubble | time */
  align-items: end;
  column-gap: var(--space-3);
}

.message.outgoing {
  grid-template-columns: 1fr auto auto; /* space | bubble | time */
}

.meta {
  color: var(--muted);
  font-size: 12px;
  white-space: nowrap;
}

/* Avatar as a perfect circle */
.avatar {
  width: 36px;
  height: 36px;
  border-radius: 50%;
  overflow: hidden;
  background: #334155;
  box-shadow: 0 1px 0 rgba(255,255,255,.05) inset;
}

.avatar img {
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
}

/* Bubble base */
.bubble {
  position: relative;
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius);
  max-width: var(--max-bubble);
  margin: 0;
  color: var(--text);
  line-height: 1.35;
  word-wrap: break-word;
  background: var(--bubble-in);
  filter: drop-shadow(0 2px 0 rgba(0,0,0,.25));
}

/* Outgoing bubble theme */
.message.outgoing .bubble {
  background: linear-gradient(180deg, var(--bubble-out-hi), var(--bubble-out));
}

/* Align bubbles per side */
.message.incoming .bubble { justify-self: start; }
.message.outgoing .bubble { justify-self: end; }
.message.outgoing .meta { justify-self: end; }

Pro Tip: Put colors, radii, and spacing in custom properties from day one. Theming later becomes a one-line swap. You can even flip bubbles for right-to-left languages by toggling a direction variable instead of rewriting selectors.

Step 3 – Building the Bubble Tails

Tails are classic border triangles. They piggyback on the bubble using pseudo-elements. Outgoing messages get a triangle on the right edge. Incoming messages get one on the left. The triangle’s solid side matches the bubble background; the other borders are transparent.

/* Outgoing tail (points to the right) */
.message.outgoing .bubble::after {
  content: "";
  position: absolute;
  right: -8px;
  bottom: 8px;
  width: 0;
  height: 0;
  border-top: 8px solid transparent;
  border-bottom: 8px solid transparent;
  border-left: 8px solid var(--bubble-out);
  /* create a subtle border/outline with another layer */
  filter: drop-shadow(0 0 0 rgba(0,0,0,.25));
}

/* Optional: blend for gradient top/bottom */
.message.outgoing .bubble::before {
  content: "";
  position: absolute;
  right: -9px;
  bottom: 8px;
  width: 0; height: 0;
  border-top: 9px solid transparent;
  border-bottom: 9px solid transparent;
  border-left: 9px solid rgba(0,0,0,.18); /* tiny outline shadow */
  z-index: -1;
}

/* Incoming tail (points to the left) */
.message.incoming .bubble::after {
  content: "";
  position: absolute;
  left: -8px;
  bottom: 8px;
  width: 0;
  height: 0;
  border-top: 8px solid transparent;
  border-bottom: 8px solid transparent;
  border-right: 8px solid var(--bubble-in);
}

.message.incoming .bubble::before {
  content: "";
  position: absolute;
  left: -9px;
  bottom: 8px;
  width: 0; height: 0;
  border-top: 9px solid transparent;
  border-bottom: 9px solid transparent;
  border-right: 9px solid rgba(0,0,0,.2);
  z-index: -1;
}

How This Works (Code Breakdown)

We anchor each tail to its bubble with position: absolute on a pseudo-element. The bubble itself sets position: relative, which creates the containing box for the tail. The triangle comes from borders: we set top and bottom borders to transparent, then color either the left or right border. That single colored border becomes the triangle. The outgoing tail uses border-left; the incoming tail uses border-right. The second pseudo-element adds a shadow outline that visually attaches the tail to the bubble. If you want the primer on this shape, the technique is the same as a right-pointing triangle with CSS or a left-pointing triangle with CSS. The bubble’s rounded corners and drop-shadow do the rest of the styling work around the rectangle itself.

The tail positions sit slightly inside the bubble’s vertical padding (bottom: 8px). That keeps the tip centered on the lower half of the bubble, which reads better than aligning to the base line of the text. You can move the tail up or down by changing the bottom value.

Step 4 – Avatars and a Typing Indicator

Avatars are perfect circles with border-radius: 50% and overflow: hidden. You can use real images or fallback initials. The typing indicator sits inside a bubble and shows three animated dots. This adds a nice level of polish without heavy assets.

/* Circle avatar: works with images or letters */
.avatar {
  width: 36px; height: 36px;
  border-radius: 50%;
  overflow: hidden;
  background: #334155;
  color: #cbd5e1;
  display: grid; place-items: center;
  font-weight: 600;
}

/* If you want initials instead of an <img>, drop text in .avatar */

/* Typing bubble sizing */
.message.typing .bubble {
  width: 64px;
  padding: var(--space-3) calc(var(--space-3) + 2px);
  display: grid; place-items: center;
}

/* Three dots using a single element and shadows */
.dots {
  position: relative;
  width: 8px; height: 8px;
  background: rgba(255,255,255,.85);
  border-radius: 50%;
  box-shadow:
    14px 0 0 0 rgba(255,255,255,.85),
    -14px 0 0 0 rgba(255,255,255,.85);
  animation: pulse 1s infinite ease-in-out;
}

/* Subtle vertical motion and opacity change */
@keyframes pulse {
  0%, 100% { transform: translateY(0); opacity: 1; }
  40%      { transform: translateY(-2px); opacity: .8; }
  60%      { transform: translateY(0); opacity: .9; }
}

/* Outgoing alignment and spacing tweaks */
.message.outgoing .bubble { margin-left: auto; }
.message.incoming .bubble { margin-right: auto; }

/* Keep time text aligned near the bubble edge */
.message.incoming .meta { justify-self: start; }

/* Optional: hover affordance on desktop */
.bubble:hover {
  filter: drop-shadow(0 3px 0 rgba(0,0,0,.28));
  transition: filter .15s ease;
}

How This Works (Code Breakdown)

An avatar circle needs one property: border-radius: 50%. That turns any square box into a circle. It’s the same base as a standard circle with CSS. Overflow: hidden clips the image to the circle. If an image is not available, you can render initials with centered text. For consistent sizing across devices, object-fit: cover on the image fills the circle without stretching faces.

The typing indicator uses a single dot element with two shadows offset left and right. This keeps the DOM tiny. The animation runs on transform and opacity, which are paint-friendly and smooth on modern GPUs. The bubble width is small and uses grid centering to keep the dots perfectly aligned. For a lighter theme, change the dot color to a dark gray to keep contrast inside a light bubble.

Advanced Techniques: Animations, Theming, and RTL

Let’s make the bubbles feel grounded with a soft entrance animation and add a quick way to flip the layout for right-to-left languages. We’ll also support reduced motion to respect user preferences.

/* Appear animation for new messages */
@keyframes pop-in {
  0%   { transform: translateY(6px) scale(.98); opacity: 0; }
  100% { transform: translateY(0)    scale(1);   opacity: 1; }
}

.message .bubble {
  animation: pop-in .18s ease-out both;
}

/* Theming: easy light mode */
.light {
  --bg: #f1f5f9;
  --panel: #ffffff;
  --bubble-in: #e5e7eb;
  --bubble-out: #2563eb;
  --bubble-out-hi: #3b82f6;
  --text: #0f172a;
  --muted: #6b7280;
  background: var(--bg);
}

/* Right-to-left flip: place on .chat[dir="rtl"] */
.chat[dir="rtl"] .message.incoming {
  grid-template-columns: auto 1fr auto; /* avatar | bubble | time (mirrors) */
}
.chat[dir="rtl"] .message.outgoing {
  grid-template-columns: auto auto 1fr; /* time | bubble | space */
}
.chat[dir="rtl"] .message.incoming .bubble::after {
  /* in RTL, incoming tail points right */
  left: auto; right: -8px;
  border-right: 0; border-left: 8px solid var(--bubble-in);
}
.chat[dir="rtl"] .message.incoming .bubble::before {
  left: auto; right: -9px;
  border-right: 0; border-left: 9px solid rgba(0,0,0,.2);
}
.chat[dir="rtl"] .message.outgoing .bubble::after {
  /* outgoing tail flips to left */
  right: auto; left: -8px;
  border-left: 0; border-right: 8px solid var(--bubble-out);
}
.chat[dir="rtl"] .message.outgoing .bubble::before {
  right: auto; left: -9px;
  border-left: 0; border-right: 9px solid rgba(0,0,0,.18);
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .message .bubble,
  .dots {
    animation: none !important;
  }
}

Accessibility & Performance

Even decorative components can block users if they grab focus or steal attention with flashy motion. Keep the DOM readable and let assistive tech focus on content, not ornament.

Accessibility

The chat list uses role=”list” and items use role=”listitem”. Time elements provide machine-readable timestamps via datetime, which helps screen readers and any future parsing. Decorative elements, the tails and dot animations, carry aria-hidden=”true” or have no text content, so they stay silent. The typing message sets aria-live=”polite” to hint at live updates, but the bubble itself only says “Jess is typing,” which keeps verbosity under control. If you render initials inside avatars, add aria-hidden=”true” on the avatar and include the sender’s name in the message’s text or as an adjacent label. Maintain color contrast for text inside both light and dark bubbles; a quick check with a contrast tool can save you from accessibility bugs.

Performance

Border-based triangles are cheap to draw. Rounded rectangles and drop-shadows perform well for static states. Animations stay on transform and opacity, which are fast paths in modern browsers. The typing dots reuse a single element with box-shadow clones, keeping the DOM small. Images load at an appropriate size, and object-fit keeps client-side resizing minimal. If your chat grows to hundreds of messages, consider virtualizing the list or capping the DOM count, but the shape techniques here do not add overhead by themselves.

Final Thoughts

You now have a clean chat bubble with tails, avatars, and a typing indicator, no images or SVGs for the shapes. The same primitives power many UI parts: triangles for pointers, circles for avatars and badges. With these patterns and your design tokens, you can ship consistent, lightweight visuals across your app.

Leave a Comment