How to create iOS chat bubbles in CSS post image

How to create iOS chat bubbles in CSS

(Updated Mar 08, 2021)

Back in 2013 I created this CodePen of the chat bubble UI from iOS 7's messages app. It has since received an impressive 50k views and 170+ likes, apparently people like to build chat apps πŸ˜‰. In this post I'll walk you through how it works, while also improving my previous code a bit.

Here's what we're building:

  1. Hey there! What's up
  2. Checking out iOS7 you know..
  3. Check out this bubble!
  4. It's pretty cool!
  5. And it's in css?
  6. Yeah it's pure CSS & HTML
  7. (ok.. almost, I added a tiny bit of JS to remove sibling message tails)
  8. Wow that's impressive. But what's even more impressive is that this bubble is really high.

I'm building this in a Next.js/react app with css modules & sass. If you're looking for a more bare-bones css solution check out the codepen above.

Let's start by creating a list of messages, we'll add a sent key to our objects in order to differentiate between you own sent messages and the received messages. Add a .shared class for each message, and another class based on the sent key.

import cn from 'classnames'
import styles from './messages.module.scss'

const messages = [
  { text: "Hey there! What's up", sent: true },
  { text: 'Checking out iOS7 you know..' },
  { text: 'Check out this bubble!', sent: true },
  { text: "It's pretty cool!" },
  { text: "And it's in css?" },
  { text: "Yeah it's pure CSS & HTML", sent: true },
  { text: "(ok.. almost, I added a tiny bit of JS to remove sibling message tails)", sent: true },
  { text: "Wow that's impressive. But what's even more impressive is that this bubble is really high." },

const Messages = (): JSX.Element => (
  <ol className={styles.list}>
    {{ text, sent }) => (
      <li key={text} className={cn(styles.shared, sent ? styles.sent : styles.received)}>{text}</li>

export default Messages

That's all we need for the markup, let's create our css (or scss) file and add some styling!

We start by adding some basic styles like a max-width to contain our messages, remove the default list styling and change the display to flex. We do this because then we can make our messages collapse/decrease in width based on the message length by using the align-self property. If all messages were block elements they would always be the same size regardless of content.

If you don't already have a theme setup this is also a good place to add some css variables for the message colors and our page background-color. We need to know our page's background-color as we're going to overlay two different elements, one of which should mimic our background to achieve our effect.

.list {
  --sentColor: #0b93f6;
  --receiveColor: #e5e5ea;
  --bg: #fff;

  display: flex;
  flex-direction: column;
  max-width: 450px;
  margin: 0 auto;
  padding: 0;
  list-style: none;

[data-theme='dark'] {
  .list {
    --bg: #161515;

If you don't know your background-color or if your background is an image I'm pretty sure you can accomplish the same effect with css mask-image and a background-gradient. Let me know if this is something you'd like to see, or why not open a PR?

There are some shared styles both sending and receiving messages should have, so let's make a .shared class. It's styles are pretty basic; we set the max-width of each message to a little bit more than half of our list width, add some padding and margin etc.

Really the only tricky part about this component are the bubble "tails". We will render them using psuedo-elements to not add unnecessary items to the DOM. For each tail we are using two psuedo elements, :before for the colored part and :after overlayed on top to "cut out" the colored part.

Since all tails will all have the same size and be positioned absolutely at the bottom of our bubbles let's add that to our shared class.

.shared {
  position: relative; /* Setup a relative container for our psuedo elements */
  max-width: 255px;
  margin-bottom: 15px;
  padding: 10px 20px;
  line-height: 24px;
  word-wrap: break-word; /* Make sure the text wraps to multiple lines if long */
  border-radius: 25px;

  &:before {
    width: 20px;

  &:after {
    width: 26px;
    background-color: var(--bg); /* All tails have the same bg cutout */

  &:after {
    position: absolute;
    bottom: 0;
    height: 25px; /* height of our bubble "tail" - should match the border-radius above */
    content: '';

Let's continue to the specific messages, starting with the 'sent' styles. As mentioned before we want to make sure our message collapses if it's a very short one and we can do this by adding align-self: flex-end . Our element will then only take up as much space as the content needs (maxing out at 255px which we set in our shared class), aligned to flex-end which in this case is right. For our receiving message we'll instead use align-self: flex-start as we want those aligned to the left.

Styling our tails

If we take a closer look at the tails we're trying to accomplish we'll notice that there are basically two curved shapes. One curved shape extending from the bottom edge (the colored section, marked as blue below) and another one extending from the outer edge (our cutout/backgrund, marked as green below). Hover the message below and see how the blue creates our tail shape when overlaid with the green one.

  1. Hello

Hover to arrange tail πŸ‘†

Let's recreate these shapes with our pseudo elements. Start by setting the color of our bordered shape, then set the curve using border-radius, we can mimic the iOS one using different values for the horizontal and vertical axis. Then tweak the right value until it's positioned correctly.

Now we need to hide the parts that extend to the right of the bubble. We do this by almost recreating the same shape but filled with our background-color instead, and position it over the previous shape.

.sent {
  align-self: flex-end;
  color: white;
  background: var(--sentColor);

  &:before {
    right: -7px;
    background-color: var(--sentColor);
    border-bottom-left-radius: 16px 14px;

  &:after {
    right: -26px;
    border-bottom-left-radius: 10px;

For our received messages we do the same thing but to the left instead of to the right and change the colors.

.received {
  align-self: flex-start;
  color: black;
  background: var(--receiveColor);

  &:before {
    left: -7px;
    background-color: var(--receiveColor);
    border-bottom-right-radius: 16px 14px;

  &:after {
    left: -26px;
    border-bottom-right-radius: 10px;

We're almost there but there's one tiny detail left. Notice that when there are multiple messages in a row from the same person there's a tail for each message? We only want to add a tail for the most recent message in that case. Unfortunately there is no "previous sibling selector" in css so we have to go back to our markup and add a noTail class. We'll add the class if the next message's sent key is the same as the current message.

{{ text, sent }, i) => {
  const isLast = i === messages.length - 1
  const noTail = !isLast && messages[i + 1]?.sent === sent
  return (
    <li key={text} className={cn(styles.shared, sent ? styles.sent : styles.received, noTail && styles.noTail)}>

Back in our css we can now hide the tail when the class is applied. We can also optionally decrease the margin a bit so our messages feel more "connected".

.noTail {
  margin-bottom: 2px;

  &:after {
    opacity: 0;

That's it! We can now display a list of nicely styled messages, and based on the "sent" key the messages will either display as sending or receiving. If there are multiple messages in a row from the same person only the most recent message will get a tail.

✨ Bonus: Animating our messages!

Building a chat app? We can easily animate our component with just a couple of lines of code using Framer Motion!

Wrap your messages in AnimatePresence and then make each message a Apply the layout prop to make our messages magically animate smoothly when messages are added or removed. Then specify how the enter animation should look like by adding the initial state (slightly offset with 0 opacity) and the entered state (reset position and full opacity). This time I'm generating the messages with random-words to make sure our component works with different types of content. See the full source code on my GitHub.

import { motion, AnimatePresence } from 'framer-motion'
import styles from './messages.module.scss'

const transition = {
  type: 'spring',
  stiffness: 200,
  mass: 0.2,
  damping: 20,

const variants = {
  initial: {
    opacity: 0,
    y: 300,
  enter: {
    opacity: 1,
    y: 0,

const Messages = (): JSX.Element => (
    <ol className={styles.list}>
      {{ text, sent }) => (
            className={cn(styles.shared, sent ? styles.sent : styles.received)}

That's it! If this helped press the like button below or let me know on twitter.


Get an email when i write new posts. Learn animation techniques, CSS, design systems and more