Priya Raman, 16:41
The Rotterdam bay is booked out until Tuesday. Do you want me to split the delivery?
Bubbles with the parts that get skipped: per-message delivery state, grouping by author and time, day separators, and a retry on the failed bubble itself.
components/message-bubble-thread.tsxnpx hoverlab add message-bubble-thread
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Northwind logistics · online
Priya Raman, 16:41
The Rotterdam bay is booked out until Tuesday. Do you want me to split the delivery?
Priya Raman, 16:41
Half now, half when the bay frees up. No extra charge either way.
You, 17:02
Split it. The chairs are the urgent half.
Read
Priya Raman, 09:14
You: Split it. The chairs are the urgent half.
Chairs are on the 07:00 run Monday. I moved the desks to Wednesday.
You, 09:20
Perfect. Can you put the reference on the paperwork this time?
Delivered
You, 09:21
ORD-88410, in case it helps.
Not sent
Could not send.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <MessageBubbleThread> — the bubbles, done properly once.
*
* `chat-thread-panel` in this catalog is an *assistant* thread: one human, one
* model, no delivery state, no identity to establish. This is the human-to-
* human version, and the differences are not cosmetic.
*
* WHAT A HUMAN THREAD NEEDS THAT AN ASSISTANT THREAD DOES NOT
*
* - **Delivery state per message.** Sending, sent, delivered, read, failed.
* A message that silently failed is the worst outcome in any messaging
* product, and the failed state needs a retry on the bubble itself rather
* than a toast that scrolls away.
* - **Grouping by author and time.** Consecutive messages from one person
* within a few minutes share an avatar and a timestamp. Repeating them per
* bubble is what makes a hand-built thread look wrong in a way people
* cannot name.
* - **Day separators.** Absolute dates between days, relative times within
* them. A thread of bare "09:14" stamps is unreadable after a week.
* - **A reply quote.** Threads fork; the quoted parent is how a reply to a
* message from four screens ago makes sense at all.
*
* THE CORNER RADII CARRY THE DIRECTION
*
* `rounded-ee-sm` on your own bubbles and `rounded-es-sm` on theirs — logical
* corners, so the tail sits on the correct side in Arabic and Hebrew without a
* second rule. A thread built with `rounded-br-sm` has both tails on the same
* side in RTL, which reads as everyone talking to themselves.
*
* ACCESSIBILITY: the thread is a `role="log"` with `aria-live="polite"`, so a
* new message is announced without dragging focus out of the composer; each
* bubble is an `<article>` with a visually hidden author-and-time line, since
* an avatar and an alignment are not information to a screen reader; delivery
* state is a word, not a tick glyph alone.
*/
import * as React from 'react'
import { Check, CheckCheck, Clock, RotateCcw, Send, TriangleAlert } from 'lucide-react'
export type Delivery = 'sending' | 'sent' | 'delivered' | 'read' | 'failed'
export interface ThreadMessage {
id: string
author: string
initials: string
mine?: boolean
body: string
at: string
/** Absolute day label. A change from the previous message inserts a rule. */
day: string
delivery?: Delivery
/** Quoted parent, for a reply that would otherwise make no sense. */
replyTo?: { author: string; body: string }
}
export interface MessageBubbleThreadProps {
messages?: ThreadMessage[]
className?: string
}
const DEFAULT_MESSAGES: ThreadMessage[] = [
{
id: '1',
author: 'Priya Raman',
initials: 'PR',
day: 'Thursday 11 September',
at: '16:41',
body: 'The Rotterdam bay is booked out until Tuesday. Do you want me to split the delivery?',
},
{
id: '2',
author: 'Priya Raman',
initials: 'PR',
day: 'Thursday 11 September',
at: '16:41',
body: 'Half now, half when the bay frees up. No extra charge either way.',
},
{
id: '3',
author: 'You',
initials: 'YU',
mine: true,
day: 'Thursday 11 September',
at: '17:02',
body: 'Split it. The chairs are the urgent half.',
delivery: 'read',
},
{
id: '4',
author: 'Priya Raman',
initials: 'PR',
day: 'Friday 12 September',
at: '09:14',
body: 'Chairs are on the 07:00 run Monday. I moved the desks to Wednesday.',
replyTo: { author: 'You', body: 'Split it. The chairs are the urgent half.' },
},
{
id: '5',
author: 'You',
initials: 'YU',
mine: true,
day: 'Friday 12 September',
at: '09:20',
body: 'Perfect. Can you put the reference on the paperwork this time?',
delivery: 'delivered',
},
{
id: '6',
author: 'You',
initials: 'YU',
mine: true,
day: 'Friday 12 September',
at: '09:21',
body: 'ORD-88410, in case it helps.',
delivery: 'failed',
},
]
const DELIVERY_LABEL: Record<Delivery, string> = {
sending: 'Sending',
sent: 'Sent',
delivered: 'Delivered',
read: 'Read',
failed: 'Not sent',
}
function DeliveryMark({ state }: { state: Delivery }) {
const Icon =
state === 'sending'
? Clock
: state === 'failed'
? TriangleAlert
: state === 'sent'
? Check
: CheckCheck
return (
<span
className={`inline-flex items-center gap-1 text-[10px] ${
state === 'failed'
? 'text-destructive'
: state === 'read'
? 'text-primary-foreground/90'
: 'text-primary-foreground/70'
}`}
>
<Icon aria-hidden className="h-3 w-3" />
{/* A word, not a tick glyph alone. */}
{DELIVERY_LABEL[state]}
</span>
)
}
export function MessageBubbleThread({
messages = DEFAULT_MESSAGES,
className = '',
}: MessageBubbleThreadProps) {
const uid = React.useId()
const [thread, setThread] = React.useState(messages)
const [draft, setDraft] = React.useState('')
function retry(id: string) {
setThread((t) =>
t.map((m) => (m.id === id ? { ...m, delivery: 'sending' as Delivery } : m)),
)
window.setTimeout(() => {
setThread((t) => t.map((m) => (m.id === id ? { ...m, delivery: 'sent' as Delivery } : m)))
}, 800)
}
function send(event: React.FormEvent) {
event.preventDefault()
if (!draft.trim()) return
setThread((t) => [
...t,
{
id: `n${t.length + 1}`,
author: 'You',
initials: 'YU',
mine: true,
day: 'Friday 12 September',
at: '09:24',
body: draft.trim(),
delivery: 'sent',
},
])
setDraft('')
}
return (
<section className={`bg-background px-4 py-10 sm:px-6 ${className}`}>
<div className="mx-auto flex h-[34rem] max-w-2xl flex-col overflow-hidden rounded-2xl border border-border bg-card">
<header className="flex items-center gap-3 border-b border-border p-4">
<span
aria-hidden
className="grid h-9 w-9 place-items-center rounded-full bg-primary/10 text-xs font-bold text-primary"
>
PR
</span>
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold">Priya Raman</h2>
<p className="text-xs text-muted-foreground">Northwind logistics · online</p>
</div>
</header>
<div
role="log"
aria-live="polite"
aria-label="Messages"
className="min-h-0 flex-1 space-y-1 overflow-y-auto p-4"
>
{thread.map((message, index) => {
const previous = thread[index - 1]
const newDay = previous?.day !== message.day
// Grouped by author within a day — the detail that makes a
// hand-built thread look wrong when it is missing.
const grouped = !newDay && previous?.author === message.author
return (
<React.Fragment key={message.id}>
{newDay ? (
<div className="flex items-center gap-3 py-3">
<span aria-hidden className="h-px flex-1 bg-border" />
<span className="text-[11px] font-medium text-muted-foreground">
{message.day}
</span>
<span aria-hidden className="h-px flex-1 bg-border" />
</div>
) : null}
<article
className={`flex gap-2 ${message.mine ? 'justify-end' : 'justify-start'} ${
grouped ? '' : 'pt-2'
}`}
>
{!message.mine ? (
<span
aria-hidden
className={`mt-auto grid h-7 w-7 shrink-0 place-items-center rounded-full text-[10px] font-bold ${
grouped ? 'invisible' : 'bg-muted text-muted-foreground'
}`}
>
{message.initials}
</span>
) : null}
<div className={`max-w-[78%] ${message.mine ? 'items-end' : ''}`}>
{/* An avatar and an alignment are not information. */}
<p className="sr-only">
{message.author}, {message.at}
</p>
{message.replyTo ? (
<blockquote className="mb-1 rounded-lg border-s-2 border-primary/50 bg-muted/60 px-2 py-1 text-[11px] text-muted-foreground">
<span className="font-semibold">{message.replyTo.author}:</span>{' '}
{message.replyTo.body}
</blockquote>
) : null}
<div
className={`rounded-2xl px-3 py-2 text-sm ${
message.mine
? 'rounded-ee-sm bg-primary text-primary-foreground'
: 'rounded-es-sm bg-muted text-foreground'
}`}
>
<p>{message.body}</p>
<p
className={`mt-1 flex items-center gap-2 text-[10px] ${
message.mine
? 'justify-end text-primary-foreground/70'
: 'text-muted-foreground'
}`}
>
<span aria-hidden>{message.at}</span>
{message.delivery ? <DeliveryMark state={message.delivery} /> : null}
</p>
</div>
{/* Retry on the bubble, not in a toast that scrolls away. */}
{message.delivery === 'failed' ? (
<p className="mt-1 flex items-center justify-end gap-1.5 text-[11px] text-destructive">
Could not send.
<button
type="button"
onClick={() => retry(message.id)}
className="inline-flex items-center gap-1 rounded font-semibold underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCcw aria-hidden className="h-3 w-3" />
Retry
</button>
</p>
) : null}
</div>
</article>
</React.Fragment>
)
})}
</div>
<form onSubmit={send} className="flex items-center gap-2 border-t border-border p-3">
<label htmlFor={`${uid}-draft`} className="sr-only">
Write a message
</label>
<input
id={`${uid}-draft`}
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Message Priya"
className="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring"
/>
<button
type="submit"
disabled={!draft.trim()}
aria-label="Send message"
className="rounded-lg bg-primary p-2 text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Send aria-hidden className="h-4 w-4 rtl:rotate-180" />
</button>
</form>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/message-bubble-thread.tsx and import it where you need the section:
import { MessageBubbleThread } from '@/components/message-bubble-thread'Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
messages | ThreadMessage[] | DEFAULT_MESSAGES |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Message Bubble Thread — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section class="bg-background px-4 py-10 sm:px-6 ">
<div class="mx-auto flex h-[34rem] max-w-2xl flex-col overflow-hidden rounded-2xl border border-border bg-card">
<header class="flex items-center gap-3 border-b border-border p-4">
<span aria-hidden="true" class="grid h-9 w-9 place-items-center rounded-full bg-primary/10 text-xs font-bold text-primary">PR</span>
<div class="min-w-0">
<h2 class="truncate text-sm font-semibold">Priya Raman</h2>
<p class="text-xs text-muted-foreground">Northwind logistics · online</p>
</div>
</header>
<div role="log" aria-live="polite" aria-label="Messages" class="min-h-0 flex-1 space-y-1 overflow-y-auto p-4">
<div class="flex items-center gap-3 py-3">
<span aria-hidden="true" class="h-px flex-1 bg-border"></span>
<span class="text-[11px] font-medium text-muted-foreground">Thursday 11 September</span>
<span aria-hidden="true" class="h-px flex-1 bg-border"></span>
</div>
<article class="flex gap-2 justify-start pt-2">
<span aria-hidden="true" class="mt-auto grid h-7 w-7 shrink-0 place-items-center rounded-full text-[10px] font-bold bg-muted text-muted-foreground">PR</span>
<div class="max-w-[78%] ">
<p class="sr-only">Priya Raman, 16:41</p>
<div class="rounded-2xl px-3 py-2 text-sm rounded-es-sm bg-muted text-foreground">
<p>The Rotterdam bay is booked out until Tuesday. Do you want me to split the delivery?</p>
<p class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
<span aria-hidden="true">16:41</span>
</p>
</div>
</div>
</article>
<article class="flex gap-2 justify-start ">
<span aria-hidden="true" class="mt-auto grid h-7 w-7 shrink-0 place-items-center rounded-full text-[10px] font-bold invisible">PR</span>
<div class="max-w-[78%] ">
<p class="sr-only">Priya Raman, 16:41</p>
<div class="rounded-2xl px-3 py-2 text-sm rounded-es-sm bg-muted text-foreground">
<p>Half now, half when the bay frees up. No extra charge either way.</p>
<p class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
<span aria-hidden="true">16:41</span>
</p>
</div>
</div>
</article>
<article class="flex gap-2 justify-end pt-2">
<div class="max-w-[78%] items-end">
<p class="sr-only">You, 17:02</p>
<div class="rounded-2xl px-3 py-2 text-sm rounded-ee-sm bg-primary text-primary-foreground">
<p>Split it. The chairs are the urgent half.</p>
<p class="mt-1 flex items-center gap-2 text-[10px] justify-end text-primary-foreground/70">
<span aria-hidden="true">17:02</span>
<span class="inline-flex items-center gap-1 text-[10px] text-primary-foreground/90"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-check h-3 w-3" aria-hidden="true"><path d="M18 6 7 17l-5-5"></path><path d="m22 10-7.5 7.5L13 16"></path></svg>Read</span>
</p>
</div>
</div>
</article>
<div class="flex items-center gap-3 py-3">
<span aria-hidden="true" class="h-px flex-1 bg-border"></span>
<span class="text-[11px] font-medium text-muted-foreground">Friday 12 September</span>
<span aria-hidden="true" class="h-px flex-1 bg-border"></span>
</div>
<article class="flex gap-2 justify-start pt-2">
<span aria-hidden="true" class="mt-auto grid h-7 w-7 shrink-0 place-items-center rounded-full text-[10px] font-bold bg-muted text-muted-foreground">PR</span>
<div class="max-w-[78%] ">
<p class="sr-only">Priya Raman, 09:14</p>
<blockquote class="mb-1 rounded-lg border-s-2 border-primary/50 bg-muted/60 px-2 py-1 text-[11px] text-muted-foreground"><span class="font-semibold">You:</span> Split it. The chairs are the urgent half.</blockquote>
<div class="rounded-2xl px-3 py-2 text-sm rounded-es-sm bg-muted text-foreground">
<p>Chairs are on the 07:00 run Monday. I moved the desks to Wednesday.</p>
<p class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground">
<span aria-hidden="true">09:14</span>
</p>
</div>
</div>
</article>
<article class="flex gap-2 justify-end pt-2">
<div class="max-w-[78%] items-end">
<p class="sr-only">You, 09:20</p>
<div class="rounded-2xl px-3 py-2 text-sm rounded-ee-sm bg-primary text-primary-foreground">
<p>Perfect. Can you put the reference on the paperwork this time?</p>
<p class="mt-1 flex items-center gap-2 text-[10px] justify-end text-primary-foreground/70">
<span aria-hidden="true">09:20</span>
<span class="inline-flex items-center gap-1 text-[10px] text-primary-foreground/70"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-check-check h-3 w-3" aria-hidden="true"><path d="M18 6 7 17l-5-5"></path><path d="m22 10-7.5 7.5L13 16"></path></svg>Delivered</span>
</p>
</div>
</div>
</article>
<article class="flex gap-2 justify-end ">
<div class="max-w-[78%] items-end">
<p class="sr-only">You, 09:21</p>
<div class="rounded-2xl px-3 py-2 text-sm rounded-ee-sm bg-primary text-primary-foreground">
<p>ORD-88410, in case it helps.</p>
<p class="mt-1 flex items-center gap-2 text-[10px] justify-end text-primary-foreground/70">
<span aria-hidden="true">09:21</span>
<span class="inline-flex items-center gap-1 text-[10px] text-destructive"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert h-3 w-3" aria-hidden="true"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path></svg>Not sent</span>
</p>
</div>
<p class="mt-1 flex items-center justify-end gap-1.5 text-[11px] text-destructive">Could not send.<button type="button" class="inline-flex items-center gap-1 rounded font-semibold underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw h-3 w-3" aria-hidden="true"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path><path d="M3 3v5h5"></path></svg>Retry</button></p>
</div>
</article>
</div>
<form class="flex items-center gap-2 border-t border-border p-3">
<label for="_R_0_-draft" class="sr-only">Write a message</label>
<input id="_R_0_-draft" placeholder="Message Priya" class="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none transition-colors focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring" value="" />
<button type="submit" disabled="" aria-label="Send message" class="rounded-lg bg-primary p-2 text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-send h-4 w-4 rtl:rotate-180" aria-hidden="true">
<path d="M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z"></path>
<path d="m21.854 2.147-10.94 10.939"></path>
</svg>
</button>
</form>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse CommunicationCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
The corner launcher, with real availability instead of a stock reassurance, article deflection before the form, no self-opening, and a way to make it go away.
Five kinds of thing that is not text - image, document, link, location and an upload still in flight - each answering the question its own kind raises.
The list half of a two-pane inbox, with unread and selected as visibly different states and bulk selection as a mode rather than a hover checkbox.
The reading pane with the composer always present, quoted history collapsed behind a count, and an internal-note mode whose send button names the recipient.
A voice note you can navigate: a waveform that is a real seek control, a speed button, a transcript, and a played state that survives finishing.
The call grid with the state that makes it usable - active speaker, per-tile connection quality, hide self-view, and the you-are-muted nudge.
The green room, where every picker has a test beside it - an input meter, a test tone, a camera preview - and blocked permission is a state, not an error.
The customer side of a ticket, where a named owner and a deadline replace Status: Open - including the waiting-on-you state that stops the argument about response times.