defuss-shadcn / Chat / bubble
BubbleATM
The surface of a chat message. Bubbles size to their content, sit on the received (start) or sent (end) side, stack into groups whose joined corners tighten, grow a tail, carry overlapped emoji reactions, turn into links or buttons, fold long text behind "Show more" - and show when the other side is typing. Avatars, names and timestamps belong to the message row around it. CSS only, after shadcn/ui's Bubble.
On this page (13)
§Chat
Received bubbles sit at the start (secondary), your own at the end (the default primary bubble); a .bubble-reactions pill hangs on the edge - role='img' with an aria-label reads it once.
§Variants
default (your messages), secondary (the other side), muted, tinted, outline (rich content), ghost (an assistant's unframed, full-width reply) and destructive (failed).
§Groups and tails
A .bubble-group stacks one sender's consecutive bubbles 3px apart and tightens the corners where they meet; data-tail on the group gives its last bubble a curved tail.
§Links and buttons
When the whole bubble is an action, make .bubble-content a real <button> or <a> - focusable, with hover and a focus ring. Quick replies in outline bubbles.
§Reactions
Overlapped emoji in a pill on the bubble's edge - bottom end by default; data-side='top' and data-align='start' move it; a .bubble-reactions-count ends it.
§Collapsible content
A long message folds: the preview stays, the rest sits in a <details class='bubble-more'> - its summary toggles Show more / Show less and the height animates (::details-content). Keyboard-operable, no script.
§With a tooltip
A read receipt on demand: make the bubble a button and give it a tooltip (the shipped tooltip component - hover or focus it).
§With a popover
A failed message whose details open in a popover (the shipped popover component) - the destructive bubble is the trigger.
§Media
data-media on the content puts an image or video edge to edge; a .bubble-caption below it keeps the text padding.
§Typing
A .bubble-typing inside a bubble: three bouncing dots while the other side writes (still for reduced motion). role='status' with a label announces it.
§Sizes
data-size sm · (default) · lg - text, padding and corner radius scale together.
§Right to left
Logical throughout: in dir='rtl' received bubbles sit on the right, sent ones on the left, and the group corners, tails and reactions mirror.
§CSS view file
/* -- Bubble component ---------------------------------------------- *//* A chat message bubble: the surface only - avatars, names, timestamps and message actions belong to a message row around it. CSS only. */@layer components { .bubble { --_bg: var(--primary); --_fg: var(--primary-foreground); --_bd: transparent; --_r: 1.125rem; --_rs: 0.3125rem; --_px: 0.875rem; --_py: 0.5rem; --_fs: 0.875rem; position: relative; display: flex; flex-direction: column; align-items: flex-start; width: fit-content; max-width: 80%; min-width: 0; /* -- Alignment: start = the other side (received), end = you (sent) */ &[data-align="end"], .bubble-group[data-align="end"] > & { margin-inline-start: auto; align-items: flex-end; } /* -- Variants -------------------------------------------------- */ &[data-variant="secondary"] { --_bg: var(--secondary); --_fg: var(--secondary-foreground); } &[data-variant="muted"] { --_bg: var(--muted); --_fg: var(--muted-foreground); } &[data-variant="tinted"] { --_bg: color-mix(in oklch, var(--primary) 12%, var(--background)); --_fg: var(--foreground); } &[data-variant="outline"] { --_bg: var(--background); --_fg: var(--foreground); --_bd: var(--border); } &[data-variant="destructive"] { --_bg: color-mix(in oklch, var(--destructive) 12%, var(--background)); --_fg: var(--destructive); --_bd: color-mix(in oklch, var(--destructive) 30%, transparent); } &[data-variant="ghost"] { --_bg: transparent; --_fg: var(--foreground); --_px: 0; max-width: none; width: 100%; } /* -- Sizes ------------------------------------------------------ */ &[data-size="sm"] { --_r: 0.875rem; --_px: 0.625rem; --_py: 0.3125rem; --_fs: 0.8125rem; } &[data-size="lg"] { --_r: 1.375rem; --_px: 1.125rem; --_py: 0.75rem; --_fs: 1rem; } } .bubble-content { box-sizing: border-box; max-width: 100%; padding: var(--_py) var(--_px); border: 1px solid var(--_bd); border-radius: var(--_r); background: var(--_bg); color: var(--_fg); font: inherit; font-size: var(--_fs); line-height: 1.45; text-align: start; text-wrap: pretty; overflow-wrap: anywhere; & > :first-child { margin-block-start: 0; } & > :last-child { margin-block-end: 0; } & p { margin-block: 0 0.5em; } & a:not(.bubble-content) { color: inherit; text-decoration: underline; text-underline-offset: 2px; } & code { font-family: var(--font-mono); font-size: 0.9em; padding: 0.0625rem 0.3125rem; border-radius: 0.3125rem; background: color-mix(in oklch, currentColor 12%, transparent); } & img, & video { display: block; max-width: 100%; height: auto; border-radius: calc(var(--_r) - 0.375rem); } & :is(ul, ol) { margin-block: 0.25em 0.5em; padding-inline-start: 1.25em; } /* a whole bubble as a link or a button (quick replies, options) */ &:is(a, button) { appearance: none; cursor: pointer; text-decoration: none; transition: background-color 150ms ease, border-color 150ms ease; &:hover { background: color-mix(in oklch, var(--_bg) 88%, var(--_fg)); } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } &:disabled, &[aria-disabled="true"] { opacity: 0.5; pointer-events: none; } } } .bubble[data-variant="ghost"] > .bubble-content:is(a, button):hover { background: var(--accent); } /* -- Media bubble: an image / video edge to edge ------------------ */ .bubble-content[data-media] { padding: 0; overflow: hidden; & img, & video { border-radius: 0; } & .bubble-caption { padding: var(--_py) var(--_px); } } /* -- Collapsible content: a preview, the rest in a <details> ------- */ .bubble-more { display: flex; flex-direction: column-reverse; & > summary { list-style: none; width: fit-content; margin-block-start: 0.25rem; font-weight: 500; text-decoration: underline; text-underline-offset: 2px; cursor: pointer; &::-webkit-details-marker { display: none; } &:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: 0.25rem; } } & .bubble-more-open { display: none; } &[open] .bubble-more-open { display: inline; } &[open] .bubble-more-closed { display: none; } &::details-content { interpolate-size: allow-keywords; block-size: 0; overflow: clip; transition: block-size 220ms ease, content-visibility 220ms allow-discrete; } &[open]::details-content { block-size: auto; } } /* -- Reactions: overlapped emoji anchored to the bubble's edge ------- */ .bubble-reactions { position: relative; z-index: 1; display: inline-flex; align-items: center; align-self: flex-end; margin-block-start: -0.5rem; margin-inline: 0.625rem; padding: 0.125rem; border-radius: 9999px; background: var(--background); box-shadow: 0 0 0 1px var(--border), var(--shadow-xs); font-size: 0.75rem; line-height: 1; & > * { display: grid; place-items: center; min-width: 1.375rem; height: 1.375rem; border-radius: 9999px; background: var(--background); } & > * + * { margin-inline-start: -0.3125rem; box-shadow: -2px 0 0 var(--background); } & > .bubble-reactions-count { padding-inline: 0.25rem 0.375rem; font-weight: 500; font-variant-numeric: tabular-nums; color: var(--muted-foreground); box-shadow: none; margin-inline-start: 0; } &[data-align="start"] { align-self: flex-start; } &[data-side="top"] { order: -1; margin-block: 0 -0.5rem; } } [dir="rtl"] .bubble-reactions > * + * { box-shadow: 2px 0 0 var(--background); } /* -- Group: consecutive bubbles of one sender, the joined corners tightened (logical radii, so RTL mirrors) ---------------------- */ .bubble-group { display: flex; flex-direction: column; gap: 0.1875rem; & > .bubble { max-width: 80%; } & > .bubble:has(> .bubble-reactions:not([data-side="top"])):not(:last-child) { margin-block-end: 0.375rem; } /* received (start): the start-side corners between neighbours */ &:not([data-align="end"]) > .bubble:not([data-align="end"]) { &:not(:last-child) > .bubble-content { border-end-start-radius: var(--_rs); } &:not(:first-child) > .bubble-content { border-start-start-radius: var(--_rs); } } /* sent (end) */ &[data-align="end"] > .bubble, & > .bubble[data-align="end"] { &:not(:last-child) > .bubble-content { border-end-end-radius: var(--_rs); } &:not(:first-child) > .bubble-content { border-start-end-radius: var(--_rs); } } } /* -- Tail: data-tail on a bubble (or a group - its last bubble) draws a small curved tail at the bottom outer corner ------------------ */ :is(.bubble[data-tail], .bubble-group[data-tail] > .bubble:last-child):not([data-variant="ghost"], [data-variant="outline"], [data-variant="destructive"]) > .bubble-content { position: relative; border-end-start-radius: var(--_rs); &::before { /* thick against the bubble, tapering to a point at the outer bottom: an SVG path mask, mirrored for the end side and RTL */ content: ""; position: absolute; inset-block-end: -1px; inset-inline-start: -0.75rem; width: 0.875rem; height: 1.25rem; background: var(--_bg); -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 20' preserveAspectRatio='none'%3E%3Cpath d='M14 1C14 11 9 18 0 20H14Z'/%3E%3C/svg%3E") 0 0 / 100% 100% no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 20' preserveAspectRatio='none'%3E%3Cpath d='M14 1C14 11 9 18 0 20H14Z'/%3E%3C/svg%3E") 0 0 / 100% 100% no-repeat; } } :is(.bubble[data-tail][data-align="end"], .bubble-group[data-tail][data-align="end"] > .bubble:last-child, .bubble-group[data-tail] > .bubble[data-align="end"]:last-child):not([data-variant="ghost"], [data-variant="outline"], [data-variant="destructive"]) > .bubble-content { border-end-start-radius: var(--_r); border-end-end-radius: var(--_rs); &::before { inset-inline-start: auto; inset-inline-end: -0.75rem; -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 20' preserveAspectRatio='none'%3E%3Cpath d='M0 1C0 11 5 18 14 20H0Z'/%3E%3C/svg%3E") 0 0 / 100% 100% no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 20' preserveAspectRatio='none'%3E%3Cpath d='M0 1C0 11 5 18 14 20H0Z'/%3E%3C/svg%3E") 0 0 / 100% 100% no-repeat; } } [dir="rtl"] :is(.bubble[data-tail], .bubble-group[data-tail] > .bubble:last-child) > .bubble-content::before { -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 20' preserveAspectRatio='none'%3E%3Cpath d='M0 1C0 11 5 18 14 20H0Z'/%3E%3C/svg%3E") 0 0 / 100% 100% no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 20' preserveAspectRatio='none'%3E%3Cpath d='M0 1C0 11 5 18 14 20H0Z'/%3E%3C/svg%3E") 0 0 / 100% 100% no-repeat; } [dir="rtl"] :is(.bubble[data-tail][data-align="end"], .bubble-group[data-tail][data-align="end"] > .bubble:last-child, .bubble-group[data-tail] > .bubble[data-align="end"]:last-child) > .bubble-content::before { -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 20' preserveAspectRatio='none'%3E%3Cpath d='M14 1C14 11 9 18 0 20H14Z'/%3E%3C/svg%3E") 0 0 / 100% 100% no-repeat; mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 20' preserveAspectRatio='none'%3E%3Cpath d='M14 1C14 11 9 18 0 20H14Z'/%3E%3C/svg%3E") 0 0 / 100% 100% no-repeat; } /* -- Typing: three dots that bounce while the other side writes ---- */ .bubble-typing { display: inline-flex; align-items: center; gap: 0.25rem; height: calc(var(--_fs) * 1.45); & > span { width: 0.4375rem; height: 0.4375rem; border-radius: 9999px; background: currentColor; opacity: 0.45; animation: bubble-typing 1.2s ease-in-out infinite; &:nth-child(2) { animation-delay: 0.15s; } &:nth-child(3) { animation-delay: 0.3s; } } } @keyframes bubble-typing { 0%, 60%, 100% { translate: 0 0; opacity: 0.45; } 30% { translate: 0 -0.25rem; opacity: 1; } }}@media (prefers-reduced-motion: reduce) { @layer components { .bubble-typing > span { animation: none; opacity: 0.7; } .bubble-more::details-content { transition: none; } .bubble-content:is(a, button) { transition: none; } }}@media (prefers-contrast: more) { @layer components { .bubble-content { border-color: var(--foreground); } .bubble[data-variant="ghost"] > .bubble-content { border-color: transparent; } }}@media (forced-colors: active) { @layer components { .bubble-content { border: 1px solid CanvasText; background: Canvas; color: CanvasText; } .bubble[data-variant="default"] > .bubble-content, .bubble:not([data-variant]) > .bubble-content { background: Highlight; color: HighlightText; forced-color-adjust: none; } .bubble-content::before { display: none; } .bubble-reactions { box-shadow: none; border: 1px solid CanvasText; } }}Comments, ideas or improvements? Edit this page's source on GitHub