Message Bubble Thread
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.
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.
components/support-chat-widget.tsxnpx hoverlab add support-chat-widget
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.
Someone is here — about 3 min to a first reply
Answers for this page
Hi — Priya here. I can see order ORD-88410. What went wrong with it?
Priya · 14:02
The second chair arrived with a cracked base plate.
You · 14:03
Sorry about that. I can send a replacement base plate today, or swap the whole chair — the plate is a ten-minute job with the allen key in the box. Which suits you?
Priya · 14:04
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <SupportChatWidget> — the bubble in the corner, and what it owes you.
*
* The support launcher is the most-shipped and least-designed component on the
* web. The default version pops open unprompted, promises a reply in "a few
* minutes" at 23:00 on a Sunday, and covers the checkout button on a phone.
*
* FOUR THINGS THIS ONE DOES DIFFERENTLY
*
* - **It states real availability, in the visitor's terms.** Either an agent
* is online and the wait is a number, or nobody is and the honest offer is
* a message that gets answered in the morning. "We typically reply in a few
* minutes" outside working hours is the lie that turns one contact into
* three.
* - **It offers self-service before the form.** Three suggested articles
* drawn from the current page. Most contacts are a question with a
* published answer, and deflecting them is worth more than any routing.
* - **It never opens itself.** No proactive invitation, no timer. The
* launcher is a button, and a button is enough.
* - **It is dismissible for the session.** The bubble covers something on
* every mobile layout ever built, and there has to be a way to make it go
* away that is not "leave the site".
*
* WHY THE PANEL IS ANCHORED WITH `end-` AND NOT `right-`
*
* A launcher pinned to the physical right sits over the start of the text in
* Arabic and Hebrew, exactly where the eye begins. Logical properties put it
* in the trailing corner in both directions. The whole widget uses `end-4`
* for that reason and the catalog's RTL check enforces it.
*
* ACCESSIBILITY: the launcher carries an accessible name that includes the
* unread count; the panel is `role="dialog"` named by its heading; the
* transcript is a `role="log"` with `aria-live="polite"`, so a new agent
* message is announced without the composer losing focus; Escape closes the
* panel and returns focus to the launcher, which is the part hand-rolled
* widgets almost always miss.
*/
import * as React from 'react'
import { BookOpen, Clock, MessageCircle, Send, X } from 'lucide-react'
export interface SuggestedArticle {
id: string
title: string
minutes: number
}
export interface WidgetMessage {
id: string
from: 'agent' | 'you'
body: string
at: string
}
export interface SupportChatWidgetProps {
online?: boolean
waitMinutes?: number
articles?: SuggestedArticle[]
messages?: WidgetMessage[]
unread?: number
className?: string
}
const DEFAULT_ARTICLES: SuggestedArticle[] = [
{ id: 'a1', title: 'Why my discount code was refused at checkout', minutes: 2 },
{ id: 'a2', title: 'Changing the delivery address after ordering', minutes: 1 },
{ id: 'a3', title: 'What happens if nobody is in for the delivery', minutes: 3 },
]
const DEFAULT_MESSAGES: WidgetMessage[] = [
{
id: 'm1',
from: 'agent',
body: 'Hi — Priya here. I can see order ORD-88410. What went wrong with it?',
at: '14:02',
},
{
id: 'm2',
from: 'you',
body: 'The second chair arrived with a cracked base plate.',
at: '14:03',
},
{
id: 'm3',
from: 'agent',
body: 'Sorry about that. I can send a replacement base plate today, or swap the whole chair — the plate is a ten-minute job with the allen key in the box. Which suits you?',
at: '14:04',
},
]
export function SupportChatWidget({
online = true,
waitMinutes = 3,
articles = DEFAULT_ARTICLES,
messages = DEFAULT_MESSAGES,
unread = 1,
className = '',
}: SupportChatWidgetProps) {
const uid = React.useId()
const [open, setOpen] = React.useState(true)
const [dismissed, setDismissed] = React.useState(false)
const [draft, setDraft] = React.useState('')
const [thread, setThread] = React.useState(messages)
const launcherRef = React.useRef<HTMLButtonElement>(null)
function close() {
setOpen(false)
// Returning focus is the part hand-rolled widgets miss.
window.requestAnimationFrame(() => launcherRef.current?.focus())
}
function send(event: React.FormEvent) {
event.preventDefault()
if (!draft.trim()) return
setThread((t) => [
...t,
{ id: `m${t.length + 1}`, from: 'you', body: draft.trim(), at: '14:06' },
])
setDraft('')
}
return (
<section
className={`relative min-h-[34rem] overflow-hidden bg-muted/30 p-4 sm:p-6 ${className}`}
onKeyDown={(event) => {
if (event.key === 'Escape' && open) close()
}}
>
{/* Stand-in for the page the widget sits over. */}
<div aria-hidden className="mx-auto max-w-2xl space-y-3">
<div className="h-7 w-48 rounded bg-muted border border-transparent" />
<div className="h-3 w-full rounded bg-muted border border-transparent" />
<div className="h-3 w-5/6 rounded bg-muted border border-transparent" />
<div className="h-3 w-2/3 rounded bg-muted border border-transparent" />
</div>
{/* end-4, not right-4 — see the docblock. */}
<div className="absolute bottom-4 end-4 flex flex-col items-end gap-3">
{open ? (
<div
id={`${uid}-panel`}
role="dialog"
aria-labelledby={`${uid}-title`}
className="flex h-[26rem] w-[min(22rem,calc(100vw-3rem))] flex-col overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-lg"
>
<header className="flex items-start justify-between gap-3 border-b border-border p-4">
<div>
<h2 id={`${uid}-title`} className="text-sm font-semibold">
Support
</h2>
{/* Real availability, not a stock reassurance. */}
<p className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
<span
aria-hidden
className={`h-2 w-2 rounded-full border border-transparent ${
online ? 'bg-emerald-500' : 'bg-muted-foreground'
}`}
/>
{online
? `Someone is here — about ${waitMinutes} min to a first reply`
: 'Nobody is on until 08:00 CET. Leave a message and it is answered first thing.'}
</p>
</div>
<button
type="button"
onClick={close}
aria-label="Close support"
className="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden className="h-4 w-4" />
</button>
</header>
{/* Deflection before the form. */}
<div className="border-b border-border bg-muted/40 p-3">
<p className="flex items-center gap-1.5 text-xs font-semibold">
<BookOpen aria-hidden className="h-3.5 w-3.5 text-primary" />
Answers for this page
</p>
<ul className="mt-2 space-y-1">
{articles.map((article) => (
<li key={article.id}>
<a
href="#"
className="flex items-start justify-between gap-2 rounded-lg px-2 py-1.5 text-xs transition-colors hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="min-w-0">{article.title}</span>
<span className="inline-flex shrink-0 items-center gap-1 text-muted-foreground">
<Clock aria-hidden className="h-3 w-3" />
{article.minutes} min
</span>
</a>
</li>
))}
</ul>
</div>
<div
role="log"
aria-live="polite"
aria-label="Conversation"
className="min-h-0 flex-1 space-y-3 overflow-y-auto p-3"
>
{thread.map((message) => (
<div
key={message.id}
className={`flex ${message.from === 'you' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[85%] rounded-2xl px-3 py-2 text-xs ${
message.from === 'you'
? 'rounded-ee-sm bg-primary text-primary-foreground'
: 'rounded-es-sm bg-muted text-foreground'
}`}
>
<p>{message.body}</p>
<p
className={`mt-1 text-[10px] ${
message.from === 'you'
? 'text-primary-foreground/70'
: 'text-muted-foreground'
}`}
>
{message.from === 'you' ? 'You' : 'Priya'} · {message.at}
</p>
</div>
</div>
))}
</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={online ? 'Type a message' : 'Leave a message'}
className="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-2 text-xs 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>
) : null}
{dismissed ? (
<p className="rounded-full border border-border bg-card px-3 py-1.5 text-xs text-muted-foreground">
Hidden for this session.{' '}
<button
type="button"
onClick={() => setDismissed(false)}
className="font-semibold text-foreground underline underline-offset-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Bring it back
</button>
</p>
) : (
<div className="flex items-center gap-2">
{/* There has to be a way to make it go away. */}
{!open ? (
<button
type="button"
onClick={() => setDismissed(true)}
className="rounded-full border border-border bg-card px-2.5 py-1 text-[11px] font-medium text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Hide
</button>
) : null}
<button
ref={launcherRef}
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
aria-controls={`${uid}-panel`}
className="relative inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<MessageCircle aria-hidden className="h-5 w-5" />
<span className="sr-only">
{open ? 'Close support' : 'Open support'}
{!open && unread > 0 ? `, ${unread} unread` : ''}
</span>
{!open && unread > 0 ? (
<span
aria-hidden
className="absolute -top-0.5 end-0 grid h-4 min-w-4 place-items-center rounded-full bg-destructive px-1 text-[10px] font-bold text-destructive-foreground"
>
{unread}
</span>
) : null}
</button>
</div>
)}
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/support-chat-widget.tsx and import it where you need the section:
import { SupportChatWidget } from '@/components/support-chat-widget'3 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
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 |
|---|---|---|
online | boolean | true |
waitMinutes | number | 3 |
articles | SuggestedArticle[] | DEFAULT_ARTICLES |
messages | WidgetMessage[] | DEFAULT_MESSAGES |
unread | number | 1 |
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.
<!--
Support Chat Widget — 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="relative min-h-[34rem] overflow-hidden bg-muted/30 p-4 sm:p-6 ">
<div aria-hidden="true" class="mx-auto max-w-2xl space-y-3">
<div class="h-7 w-48 rounded bg-muted border border-transparent"></div>
<div class="h-3 w-full rounded bg-muted border border-transparent"></div>
<div class="h-3 w-5/6 rounded bg-muted border border-transparent"></div>
<div class="h-3 w-2/3 rounded bg-muted border border-transparent"></div>
</div>
<div class="absolute bottom-4 end-4 flex flex-col items-end gap-3">
<div id="_R_0_-panel" role="dialog" aria-labelledby="_R_0_-title" class="flex h-[26rem] w-[min(22rem,calc(100vw-3rem))] flex-col overflow-hidden rounded-2xl border border-border bg-card text-card-foreground shadow-lg">
<header class="flex items-start justify-between gap-3 border-b border-border p-4">
<div>
<h2 id="_R_0_-title" class="text-sm font-semibold">Support</h2>
<p class="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground"><span aria-hidden="true" class="h-2 w-2 rounded-full border border-transparent bg-emerald-500"></span>Someone is here — about 3 min to a first reply</p>
</div>
<button type="button" aria-label="Close support" class="rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground 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-x h-4 w-4" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</header>
<div class="border-b border-border bg-muted/40 p-3">
<p class="flex items-center gap-1.5 text-xs font-semibold"><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-book-open h-3.5 w-3.5 text-primary" aria-hidden="true"><path d="M12 7v14"></path><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"></path></svg>Answers for this page</p>
<ul class="mt-2 space-y-1">
<li>
<a href="#" class="flex items-start justify-between gap-2 rounded-lg px-2 py-1.5 text-xs transition-colors hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="min-w-0">Why my discount code was refused at checkout</span>
<span class="inline-flex shrink-0 items-center gap-1 text-muted-foreground"><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-clock h-3 w-3" aria-hidden="true"><path d="M12 6v6l4 2"></path><circle cx="12" cy="12" r="10"></circle></svg>2 min</span>
</a>
</li>
<li>
<a href="#" class="flex items-start justify-between gap-2 rounded-lg px-2 py-1.5 text-xs transition-colors hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="min-w-0">Changing the delivery address after ordering</span>
<span class="inline-flex shrink-0 items-center gap-1 text-muted-foreground"><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-clock h-3 w-3" aria-hidden="true"><path d="M12 6v6l4 2"></path><circle cx="12" cy="12" r="10"></circle></svg>1 min</span>
</a>
</li>
<li>
<a href="#" class="flex items-start justify-between gap-2 rounded-lg px-2 py-1.5 text-xs transition-colors hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<span class="min-w-0">What happens if nobody is in for the delivery</span>
<span class="inline-flex shrink-0 items-center gap-1 text-muted-foreground"><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-clock h-3 w-3" aria-hidden="true"><path d="M12 6v6l4 2"></path><circle cx="12" cy="12" r="10"></circle></svg>3 min</span>
</a>
</li>
</ul>
</div>
<div role="log" aria-live="polite" aria-label="Conversation" class="min-h-0 flex-1 space-y-3 overflow-y-auto p-3">
<div class="flex justify-start">
<div class="max-w-[85%] rounded-2xl px-3 py-2 text-xs rounded-es-sm bg-muted text-foreground">
<p>Hi — Priya here. I can see order ORD-88410. What went wrong with it?</p>
<p class="mt-1 text-[10px] text-muted-foreground">Priya · 14:02</p>
</div>
</div>
<div class="flex justify-end">
<div class="max-w-[85%] rounded-2xl px-3 py-2 text-xs rounded-ee-sm bg-primary text-primary-foreground">
<p>The second chair arrived with a cracked base plate.</p>
<p class="mt-1 text-[10px] text-primary-foreground/70">You · 14:03</p>
</div>
</div>
<div class="flex justify-start">
<div class="max-w-[85%] rounded-2xl px-3 py-2 text-xs rounded-es-sm bg-muted text-foreground">
<p>Sorry about that. I can send a replacement base plate today, or swap the whole chair — the plate is a ten-minute job with the allen key in the box. Which suits you?</p>
<p class="mt-1 text-[10px] text-muted-foreground">Priya · 14:04</p>
</div>
</div>
</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="Type a message" class="min-w-0 flex-1 rounded-lg border border-border bg-background px-3 py-2 text-xs 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>
<div class="flex items-center gap-2">
<button type="button" aria-expanded="true" aria-controls="_R_0_-panel" class="relative inline-flex h-12 w-12 items-center justify-center rounded-full bg-primary text-primary-foreground shadow-lg transition-opacity hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
<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-message-circle h-5 w-5" aria-hidden="true">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"></path>
</svg>
<span class="sr-only">Close support</span>
</button>
</div>
</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.
Want the whole screen instead of this one section? Open a page and copy it entire.
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.
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.
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.