GODRICH

9 CSS Chat Bubble Animations, One Line, Copy-Paste

A css chat bubble animation is the transform-and-opacity motion a bubble makes as it pops in, waits, gets read, or reacts — nine of them are collected here.

Auto-plays · click a tile to jump to its section · all nine in one zip

The nine below follow one message's trip across the screen rather than any popularity ranking. 01 and 02 cover how a bubble first appears and which way its tail points. 03 through 05 cover waiting states — someone typing, a message getting read, a photo still loading. 06 and 07 layer context and feedback onto a bubble that's already on screen: a quoted reply and a tapped reaction. 08 and 09 sit outside any single bubble, marking a new day and keeping the freshest message in view. Watch the grid above run through all nine without any input: each thumbnail lives inside a fixed, click-proof iframe. Every state plays on a timer instead of waiting for a cursor no iframe can receive.

01Bubble pop-in

A new message bubble scales up from 0.4, overshoots past its full size, then settles back to a scale of 1 while its opacity rises from 0 to 1 across the same span. It fits a fresh bubble in a one-on-one thread or a preview bubble inside a notification toast — anywhere a message needs to read as having just landed.

scale(0.8→1)opacity0.18초
.bubble--new {
  transform-origin: center;
  animation: bubble-pop $duration $easing;
}

@keyframes bubble-pop {
  0%   { transform: scale(.4); opacity: 0; }
  65%  { transform: scale(1.15); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}

02Tail direction switch

A triangular tail carved with clip-path: polygon() sits at the bubble's bottom corner, and the whole bubble slides left or right depending on whether the message is received or sent. A matching left or right tail fades in at the same time to line up with it. It's the piece that lets a one-on-one or group thread tell sender from recipient without reading a single word.

clip-pathpolygon()좌우 반전
.bubble__tail {
  position: absolute;
  bottom: 10px;
  width: 14px;
  height: 14px;
  background: currentColor;
}

.bubble--in .bubble__tail {
  left: -6px;
  clip-path: polygon(100% 0, 100% 100%, 0 50%);
}

.bubble--out .bubble__tail {
  right: -6px;
  clip-path: polygon(0 0, 0 100%, 100% 50%);
}

03Typing indicator dots

Three dots bounce up and down one after another, each one starting its translateY cycle a fraction of a second behind the last. The motion reads as a single wave instead of three dots moving in sync. It signals that the other person in a messenger thread — or a chatbot mid-response — is typing right now.

translateYanimation-delay무한 반복
.dot {
  animation: dot-bounce $duration $easing infinite;
}
.dot:nth-child(2) { animation-delay: .2s; }
.dot:nth-child(3) { animation-delay: .4s; }

@keyframes dot-bounce {
  0%, 60%, 100% { transform: translateY(0); opacity: .4; }
  30% { transform: translateY(-9px); opacity: 1; }
}

04Read receipt transition

The unread count fades and shrinks away via opacity and scale the instant a message is read, and a check icon overshoots past full size before settling into the spot the count left behind. It confirms a read receipt in a one-on-one chat, or lets a support agent see that their reply actually reached the customer.

opacityscale0.2초
.status {
  position: relative;
  width: 20px;
  height: 20px;
}
.unread, .check {
  position: absolute;
  inset: 0;
}

.status--read .unread {
  animation: fade-out $duration $easing forwards;
}
.status--read .check {
  animation: fade-in $duration $easing forwards;
}

@keyframes fade-out {
  to { opacity: 0; transform: scale(.4); }
}
@keyframes fade-in {
  from { opacity: 0; transform: scale(.4); }
  to   { opacity: 1; transform: scale(1); }
}

05Image bubble loading

A gray skeleton panel holds a diagonal highlight that sweeps across it on transform: translateX() while the photo underneath waits at opacity: 0, then the photo fades in once it's ready. It covers any chat that exchanges photos or GIFs, including a support thread where a customer attaches a product image.

skeleton shimmer@keyframes photo-in-loop0.3초
.skeleton {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background: rgba(0, 0, 0, .08);
}
.skeleton::after {
  content: "";
  position: absolute;
  inset: 0;
  transform: translateX(-100%);
  background: linear-gradient(100deg, transparent, rgba(255, 255, 255, .5), transparent);
  animation: shine 1.4s linear infinite;
}
.photo {
  opacity: 0;
  transition: opacity $duration $easing;
}
.bubble--loaded .photo { opacity: 1; }

@keyframes shine {
  to { transform: translateX(100%); }
}

06Reply quote slide

A quoted snippet of the original message slides in a few pixels from the left while fading in from opacity: 0, sitting just above the new reply so it's clear which line prompted the answer. It suits a thread-style chat that replies to one specific message, or a review or comment section built the same way.

translateXborder-left0.15초
.quote {
  border-left: 3px solid var(--accent);
  opacity: 0;
  transform: translateX(-18px);
  animation: quote-in $duration $easing forwards;
}

@keyframes quote-in {
  to { opacity: 1; transform: translateX(0); }
}

07Reaction emoji pop

An emoji badge pinned to the bubble's corner scales from 0 past 1.3 before settling at 1, and the bubble itself dips slightly and springs back at the same moment. The whole thing reads as one tap landing. It's built for a chat where people react to messages with emoji, or a reaction button on a community post.

scale overshootcubic-bezier0.25초
.reaction {
  transform: scale(0);
  transform-origin: center;
  animation: reaction-pop $duration $easing forwards;
}

@keyframes reaction-pop {
  0%   { transform: scale(0); opacity: 0; }
  65%  { transform: scale(1.3); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}

08Date divider appear

A pill-shaped date divider eases down a few pixels while fading in via opacity, then settles into place between two days' worth of messages. It marks where one day's conversation ends and the next begins when scrolling back through a chat, or where a notification list breaks into date groups.

opacitytranslateY(-4px)0.2초
.divider {
  opacity: 0;
  transform: translateY(-14px) scale(.9);
  animation: divider-in $duration $easing forwards;
}

@keyframes divider-in {
  to { opacity: 1; transform: translateY(0) scale(1); }
}

09New message auto-scroll pin

A new outgoing bubble slides up from just below the visible thread and fades in as it arrives, but only when the thread was already scrolled to the bottom. Scroll up to read older messages and the newest bubble waits quietly off-screen instead of yanking the view down. That condition takes a few lines of JavaScript comparing scrollTop against scrollHeight; the CSS below only covers the motion itself, once the script decides to add the class.

@keyframes pin-in-looptranslateY(46px)조건부 자동스크롤
.bubble--out {
  transform-origin: bottom center;
  animation: pin-in $duration $easing forwards;
}

@keyframes pin-in {
  0%   { transform: translateY(46px); opacity: 0; }
  100% { transform: translateY(0); opacity: 1; }
}

Where the tail trick breaks

Every one of these nine only ever moves transform and opacity. Nothing here touches width, top, or left while it's animating, because those properties force the browser to recompute layout on every frame instead of handing the frame straight to compositing. MDN's guide to animation performance walks through why that split matters. Item 02's tail looks like the kind of thing a single flipped shape should handle: draw one triangle, then mirror it with transform: scaleX(-1) when a message switches from received to sent. That shortcut breaks the moment the bubble carries any text or an asymmetric border-radius, because scaleX(-1) mirrors the whole element it's applied to, not just the point of a shape. A label sitting inside reads backward, and a corner rounded unevenly on purpose flips to the wrong side. The demo instead keeps two separate triangles, .bubble--in and .bubble--out, each cut with its own mirrored clip-path: polygon() coordinates, and only crossfades which one is visible. clip-path itself needs no prefix in any current desktop or mobile browser, and caniuse's clip-path table covers exactly how far back that support reaches for anyone shipping to older devices. All nine files built this same way sit in one archive, and the key that opens it is e469fwnd, typed in on its own with nothing extra added around it.

Accessibility

prefers-reduced-motion: reduce doesn't just mute these nine — it swaps the animated approach for the same end state landing without a run-up, item by item. 01's pop, 06's quote slide, 07's reaction, and 08's divider each skip straight to their finished position: full size, full opacity, in place. 02's tail transition and 04's read-receipt swap behave the same way, jumping straight to whichever side or icon is currently correct instead of crossfading into it. 03's dots stop bouncing and sit as three flat, low-opacity marks instead. Motion is what signals typing in the first place, so reduced motion trades that signal for a static placeholder rather than dropping it outright. 09's auto-scroll pin keeps its conditional logic intact — a message still only jumps into view when the thread is already at the bottom — but drops the sliding entrance itself. Chrome, Firefox, and Safari all read this setting from the operating system's own motion toggle; MDN's @media reference lays out where each platform exposes it.

@media (prefers-reduced-motion: reduce) {
  .bubble--new, .dot, .divider { animation-duration: .01ms !important; }
}

The rest of this site's feedback and state animations live on the CSS animation hub; what this project does and doesn't sell is on the about page.

FAQ

Can I use a single css chat bubble animation instead of pulling in all nine?

Yes — each one is scoped to its own class or two, .bubble--new, .status--read, .bubble--loaded, and so on, with no shared state or parent selector holding the set together. Copying one item's SCSS and its matching markup is enough on its own; nothing here depends on the other eight being present in the same file.

How is a css chat bubble animation different from a plain hover effect?

None of these nine wait for a pointer at all — every one fires when a state changes: a message arrives, gets read, or a thread scrolls to the bottom. So touch devices and keyboard-only sessions see the exact same motion a mouse would trigger. A hover effect needs a cursor sitting over something by definition, which rules it out for a phone screen or a screen-reader user tabbing through a thread.

What if I want the dots or the pop-in to run just once instead of looping?

The grid demo above loops every animation continuously so it's visible without waiting for a real message, but most of the SCSS shown fires once per class toggle by default. Two exceptions already ship with infinite built into their @keyframes calls — 03's typing dots and 05's loading shimmer both need to keep moving for as long as their state holds. Everything else stays a single-run transition, triggered by a script adding a class when the matching event happens.

Enter the archive password

The password is inside this article. You will find it as you read.