Skip to content
Product block

Attachment Bubbles

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.

305 lineslucide-reactAdded 13 Sept 2026
  • chat
  • attachment
  • upload
  • file
  • messaging

What's included

  • components/message-attachment-bubble.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add message-attachment-bubble

Or over MCP, from your editor's agent — no account needed.

License

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.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/message-attachment-bubble.tsx
'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>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/message-attachment-bubble.tsx and import it where you need the section:

import { MessageAttachmentBubble } from '@/components/message-attachment-bubble'

Props

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.

PropTypeDefault
attachmentsAttachment[]DEFAULT_ATTACHMENTS
classNamestring''

Not using React?

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-attachment-bubble.html
<!--
  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>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

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.

See the prompt

8 more blocks in Communication

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 Communication

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Communication blocks

View category
Open the full page for this block

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.

Communication307 lines1 dep
Open the full page for this block

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.

Communication325 lines1 dep
Open the full page for this block

Inbox Thread List

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.

Communication333 lines1 dep
Open the full page for this block

Inbox Reading Pane

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.

Communication311 lines1 dep
Open the full page for this block

Voice Message Player

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.

Communication271 lines1 dep
Open the full page for this block

Video Call Layout

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.

Communication286 lines1 dep
Open the full page for this block

Call Device Settings

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.

Communication315 lines1 dep
Open the full page for this block

Support Ticket Thread

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.

Communication340 lines1 dep