Support Chat Widget
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.
components/message-attachment-bubble.tsxnpx hoverlab add message-attachment-bubble
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.
09:31
unboxing-clip.mp4
25 MB of 40 MB
now
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <MessageAttachmentBubble> — every kind of thing that is not text.
*
* The companion to `message-bubble-thread`, split out because attachments are
* a genuinely separate problem and get treated as an afterthought: one grey
* rectangle with a paperclip and a filename, whatever was sent.
*
* FIVE KINDS, AND EACH ONE NEEDS SOMETHING DIFFERENT
*
* image a thumbnail with real dimensions reserved, and alt text
* document type, size and page count, because "report.pdf" is not enough
* link a title and host, not a bare URL that wraps over four lines
* location a name and a distance, not raw coordinates
* upload a progress bar and a cancel, before it is any of the above
*
* Collapsing those into one card is what makes a chat feel unfinished. Each
* shape below is a few lines and answers the question its own kind raises.
*
* THE IN-FLIGHT STATE IS THE ONE PEOPLE SKIP
*
* An upload occupies a bubble before it is a file, and it needs a determinate
* progress bar, a size, and a cancel that actually removes the bubble. Showing
* a spinner instead means a 40 MB video on a train looks identical to a stuck
* request.
*
* IMAGES RESERVE THEIR BOX. `aspect-ratio` on the frame from the known
* dimensions, so the thread does not jump when the thumbnail decodes. A chat
* that reflows as images load is a chat you cannot read while it loads.
*
* SIZES ARE FORMATTED FROM BYTES ONCE. Not "1048576 bytes", not "1.048576 MB".
* One helper, binary units, one decimal.
*
* ACCESSIBILITY: every attachment is a link or button with an accessible name
* that includes the kind and the size — "Invoice INV-2026-0841, PDF, 240 KB,
* 3 pages" — because "download" ×5 is what a screen reader otherwise hears.
* The upload progress is a real `role="progressbar"` with its values set.
*/
import * as React from 'react'
import { Download, FileText, Link2, MapPin, X } from 'lucide-react'
export type AttachmentKind = 'image' | 'document' | 'link' | 'location' | 'upload'
export interface Attachment {
id: string
kind: AttachmentKind
mine?: boolean
at: string
/** image */
alt?: string
width?: number
height?: number
/** document + upload */
name?: string
bytes?: number
format?: string
pages?: number
/** upload */
percent?: number
/** link */
title?: string
host?: string
excerpt?: string
/** location */
place?: string
distance?: string
}
export interface MessageAttachmentBubbleProps {
attachments?: Attachment[]
className?: string
}
const DEFAULT_ATTACHMENTS: Attachment[] = [
{
id: 'a1',
kind: 'image',
at: '09:31',
alt: 'The cracked base plate on the second chair, photographed from above',
width: 1600,
height: 1200,
},
{
id: 'a2',
kind: 'document',
mine: true,
at: '09:33',
name: 'INV-2026-0841',
format: 'PDF',
bytes: 245_760,
pages: 3,
},
{
id: 'a3',
kind: 'link',
at: '09:35',
title: 'Replacing a Meridian base plate — 8 minutes, one allen key',
host: 'support.northwind.example',
excerpt:
'The plate is held by four M6 bolts under the seat pan. No need to remove the gas lift.',
},
{
id: 'a4',
kind: 'location',
mine: true,
at: '09:38',
place: 'Rotterdam DC — goods in, gate 4',
distance: '2.1 km from the ring road exit',
},
{
id: 'a5',
kind: 'upload',
mine: true,
at: 'now',
name: 'unboxing-clip.mp4',
format: 'MP4',
bytes: 41_943_040,
percent: 62,
},
]
/** Binary units, one decimal, one place in the codebase. */
function size(bytes: number) {
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit += 1
}
return `${value.toFixed(value < 10 && unit > 0 ? 1 : 0)} ${units[unit]}`
}
export function MessageAttachmentBubble({
attachments = DEFAULT_ATTACHMENTS,
className = '',
}: MessageAttachmentBubbleProps) {
const [items, setItems] = React.useState(attachments)
function cancel(id: string) {
setItems((list) => list.filter((item) => item.id !== id))
}
return (
<section className={`bg-background px-4 py-10 sm:px-6 ${className}`}>
<div className="mx-auto max-w-2xl space-y-3 rounded-2xl border border-border bg-card p-4">
<h2 className="text-sm font-semibold">Attachments</h2>
{items.map((item) => (
<div
key={item.id}
className={`flex ${item.mine ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] overflow-hidden rounded-2xl text-sm ${
item.mine
? 'rounded-ee-sm bg-primary/10 text-foreground'
: 'rounded-es-sm bg-muted text-foreground'
}`}
>
{item.kind === 'image' ? (
<figure>
{/* The box is reserved from the known dimensions, so the
thread does not jump as the thumbnail decodes. */}
<div
style={{ aspectRatio: `${item.width ?? 4} / ${item.height ?? 3}` }}
className="grid w-64 max-w-full place-items-center bg-foreground/10 text-xs text-muted-foreground"
>
<span className="px-4 text-center">{item.alt}</span>
</div>
<figcaption className="flex items-center justify-between gap-2 px-3 py-2 text-[11px] text-muted-foreground">
<span>
{item.width} × {item.height}
</span>
<a
href="#"
className="inline-flex items-center gap-1 rounded font-medium underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<Download aria-hidden className="h-3 w-3" />
Save
<span className="sr-only"> the photograph: {item.alt}</span>
</a>
</figcaption>
</figure>
) : null}
{item.kind === 'document' ? (
<a
href="#"
className="flex items-center gap-3 p-3 transition-colors hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span
aria-hidden
className="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-card text-primary"
>
<FileText className="h-5 w-5" />
</span>
<span className="min-w-0">
<span className="block truncate font-medium">{item.name}</span>
<span className="block text-xs text-muted-foreground">
{item.format} · {size(item.bytes ?? 0)} · {item.pages} pages
</span>
</span>
<span className="sr-only">
Download {item.name}, {item.format}, {size(item.bytes ?? 0)},{' '}
{item.pages} pages
</span>
</a>
) : null}
{item.kind === 'link' ? (
<a
href="#"
className="block p-3 transition-colors hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
{/* A title and a host, not a URL that wraps over four lines. */}
<span className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<Link2 aria-hidden className="h-3 w-3" />
{item.host}
</span>
<span className="mt-1 block font-medium">{item.title}</span>
<span className="mt-1 block text-xs text-muted-foreground">
{item.excerpt}
</span>
</a>
) : null}
{item.kind === 'location' ? (
<a
href="#"
className="block transition-colors hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span
aria-hidden
className="grid h-24 w-64 max-w-full place-items-center bg-foreground/10 text-muted-foreground"
>
<MapPin className="h-6 w-6" />
</span>
<span className="block p-3">
<span className="block font-medium">{item.place}</span>
<span className="block text-xs text-muted-foreground">
{item.distance}
</span>
</span>
</a>
) : null}
{item.kind === 'upload' ? (
<div className="w-64 max-w-full p-3">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="truncate font-medium">{item.name}</p>
<p className="text-xs text-muted-foreground">
{size(((item.bytes ?? 0) * (item.percent ?? 0)) / 100)} of{' '}
{size(item.bytes ?? 0)}
</p>
</div>
<button
type="button"
onClick={() => cancel(item.id)}
aria-label={`Cancel upload of ${item.name}`}
className="rounded p-1 text-muted-foreground transition-colors hover:bg-card hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden className="h-3.5 w-3.5" />
</button>
</div>
{/* Determinate: a spinner cannot tell a slow upload from a
stuck one. */}
<div
role="progressbar"
aria-valuenow={item.percent ?? 0}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Uploading ${item.name}`}
className="mt-2 h-1.5 overflow-hidden rounded-full bg-card border border-transparent"
>
<div
style={{ width: `${item.percent ?? 0}%` }}
className="h-full rounded-full bg-primary transition-[width] border border-transparent"
/>
</div>
</div>
) : null}
<p className="px-3 pb-2 text-end text-[10px] text-muted-foreground">
{item.at}
</p>
</div>
</div>
))}
{items.length === 0 ? (
<p className="rounded-xl border border-dashed border-border p-6 text-center text-xs text-muted-foreground">
Every attachment was cancelled. Cancelling removes the bubble — a
cancel that leaves a dead card behind is the version people complain
about.
</p>
) : null}
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/message-attachment-bubble.tsx and import it where you need the section:
import { MessageAttachmentBubble } from '@/components/message-attachment-bubble'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 |
|---|---|---|
attachments | Attachment[] | DEFAULT_ATTACHMENTS |
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.
<!--
Attachment Bubbles — 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 max-w-2xl space-y-3 rounded-2xl border border-border bg-card p-4">
<h2 class="text-sm font-semibold">Attachments</h2>
<div class="flex justify-start">
<div class="max-w-[80%] overflow-hidden rounded-2xl text-sm rounded-es-sm bg-muted text-foreground">
<figure>
<div style="aspect-ratio:1600 / 1200" class="grid w-64 max-w-full place-items-center bg-foreground/10 text-xs text-muted-foreground">
<span class="px-4 text-center">The cracked base plate on the second chair, photographed from above</span>
</div>
<figcaption class="flex items-center justify-between gap-2 px-3 py-2 text-[11px] text-muted-foreground">
<span>1600 × 1200</span>
<a href="#" class="inline-flex items-center gap-1 rounded font-medium underline-offset-2 hover:underline 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-download h-3 w-3" aria-hidden="true"><path d="M12 15V3"></path><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><path d="m7 10 5 5 5-5"></path></svg>Save<span class="sr-only"> the photograph: The cracked base plate on the second chair, photographed from above</span></a>
</figcaption>
</figure>
<p class="px-3 pb-2 text-end text-[10px] text-muted-foreground">09:31</p>
</div>
</div>
<div class="flex justify-end">
<div class="max-w-[80%] overflow-hidden rounded-2xl text-sm rounded-ee-sm bg-primary/10 text-foreground">
<a href="#" class="flex items-center gap-3 p-3 transition-colors hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
<span aria-hidden="true" class="grid h-10 w-10 shrink-0 place-items-center rounded-lg bg-card text-primary">
<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-file-text h-5 w-5" aria-hidden="true">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path>
<path d="M14 2v4a2 2 0 0 0 2 2h4"></path>
<path d="M10 9H8"></path>
<path d="M16 13H8"></path>
<path d="M16 17H8"></path>
</svg>
</span>
<span class="min-w-0">
<span class="block truncate font-medium">INV-2026-0841</span>
<span class="block text-xs text-muted-foreground">PDF · 240 KB · 3 pages</span>
</span>
<span class="sr-only">Download INV-2026-0841, PDF, 240 KB, 3 pages</span>
</a>
<p class="px-3 pb-2 text-end text-[10px] text-muted-foreground">09:33</p>
</div>
</div>
<div class="flex justify-start">
<div class="max-w-[80%] overflow-hidden rounded-2xl text-sm rounded-es-sm bg-muted text-foreground">
<a href="#" class="block p-3 transition-colors hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
<span class="flex items-center gap-1.5 text-[11px] 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-link2 lucide-link-2 h-3 w-3" aria-hidden="true"><path d="M9 17H7A5 5 0 0 1 7 7h2"></path><path d="M15 7h2a5 5 0 1 1 0 10h-2"></path><line x1="8" x2="16" y1="12" y2="12"></line></svg>support.northwind.example</span>
<span class="mt-1 block font-medium">Replacing a Meridian base plate — 8 minutes, one allen key</span>
<span class="mt-1 block text-xs text-muted-foreground">The plate is held by four M6 bolts under the seat pan. No need to remove the gas lift.</span>
</a>
<p class="px-3 pb-2 text-end text-[10px] text-muted-foreground">09:35</p>
</div>
</div>
<div class="flex justify-end">
<div class="max-w-[80%] overflow-hidden rounded-2xl text-sm rounded-ee-sm bg-primary/10 text-foreground">
<a href="#" class="block transition-colors hover:bg-foreground/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring">
<span aria-hidden="true" class="grid h-24 w-64 max-w-full place-items-center bg-foreground/10 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-map-pin h-6 w-6" aria-hidden="true">
<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"></path>
<circle cx="12" cy="10" r="3"></circle>
</svg>
</span>
<span class="block p-3">
<span class="block font-medium">Rotterdam DC — goods in, gate 4</span>
<span class="block text-xs text-muted-foreground">2.1 km from the ring road exit</span>
</span>
</a>
<p class="px-3 pb-2 text-end text-[10px] text-muted-foreground">09:38</p>
</div>
</div>
<div class="flex justify-end">
<div class="max-w-[80%] overflow-hidden rounded-2xl text-sm rounded-ee-sm bg-primary/10 text-foreground">
<div class="w-64 max-w-full p-3">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="truncate font-medium">unboxing-clip.mp4</p>
<p class="text-xs text-muted-foreground">25 MB of 40 MB</p>
</div>
<button type="button" aria-label="Cancel upload of unboxing-clip.mp4" class="rounded p-1 text-muted-foreground transition-colors hover:bg-card 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-3.5 w-3.5" aria-hidden="true">
<path d="M18 6 6 18"></path>
<path d="m6 6 12 12"></path>
</svg>
</button>
</div>
<div role="progressbar" aria-valuenow="62" aria-valuemin="0" aria-valuemax="100" aria-label="Uploading unboxing-clip.mp4" class="mt-2 h-1.5 overflow-hidden rounded-full bg-card border border-transparent">
<div style="width:62%" class="h-full rounded-full bg-primary transition-[width] border border-transparent"></div>
</div>
</div>
<p class="px-3 pb-2 text-end text-[10px] text-muted-foreground">now</p>
</div>
</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.
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.
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 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.