Chat Attachments
Original · freemessages with image and file attachments
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/chat-attachments.json"use client";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type FileKind = "pdf" | "zip" | "sheet" | "audio" | "code";
type ImageAttachment = {
kind: "image";
id: string;
src: string;
alt: string;
caption: string;
width: number;
height: number;
};
type FileAttachment = {
kind: "file";
id: string;
name: string;
size: string;
fileType: FileKind;
progress: number;
};
type Attachment = ImageAttachment | FileAttachment;
type Message = {
id: string;
author: string;
initials: string;
role: "them" | "me";
time: string;
body: string;
attachments: Attachment[];
};
const MESSAGES: Message[] = [
{
id: "m1",
author: "Priya Raghavan",
initials: "PR",
role: "them",
time: "09:41",
body: "Shot list from the Kalyan warehouse walkthrough. The loading-bay frame is the one I'd lead the case study with — the light held up better than we expected at 7am.",
attachments: [
{
kind: "image",
id: "a1",
src: "/img/gallery/g07.webp",
alt: "Wide shot of a warehouse loading bay in early morning light",
caption: "loading-bay-07.webp",
width: 1200,
height: 800,
},
{
kind: "image",
id: "a2",
src: "/img/gallery/g12.webp",
alt: "Close detail of stacked pallets against a corrugated wall",
caption: "pallets-detail-12.webp",
width: 1200,
height: 800,
},
{
kind: "image",
id: "a3",
src: "/img/gallery/g21.webp",
alt: "Overhead view of the warehouse floor with marked walkways",
caption: "floor-overhead-21.webp",
width: 1200,
height: 800,
},
],
},
{
id: "m2",
author: "You",
initials: "SA",
role: "me",
time: "09:48",
body: "Agreed on the loading bay. I've attached the signed release and the raw audio from the floor manager interview — timestamps 4:12 to 6:30 are the usable quotes.",
attachments: [
{
kind: "file",
id: "a4",
name: "kalyan-image-release-signed.pdf",
size: "412 KB",
fileType: "pdf",
progress: 100,
},
{
kind: "file",
id: "a5",
name: "floor-manager-interview.wav",
size: "68.4 MB",
fileType: "audio",
progress: 100,
},
],
},
{
id: "m3",
author: "Dev Malhotra",
initials: "DM",
role: "them",
time: "10:03",
body: "Pulled the throughput numbers before and after the retrofit into one sheet. Column J is the only figure legal wants us to publish — everything else is directional.",
attachments: [
{
kind: "file",
id: "a6",
name: "throughput-q1-q3-2026.xlsx",
size: "1.8 MB",
fileType: "sheet",
progress: 100,
},
{
kind: "file",
id: "a7",
name: "chart-export-sources.zip",
size: "24.1 MB",
fileType: "zip",
progress: 62,
},
],
},
{
id: "m4",
author: "You",
initials: "SA",
role: "me",
time: "10:11",
body: "Dropping the annotated hero crop plus the component that renders the stat strip, so the build team doesn't have to guess the spacing.",
attachments: [
{
kind: "image",
id: "a8",
src: "/img/gallery/g30.webp",
alt: "Annotated crop of the hero photograph with alignment guides",
caption: "hero-crop-annotated.webp",
width: 1200,
height: 800,
},
{
kind: "file",
id: "a9",
name: "StatStrip.tsx",
size: "6 KB",
fileType: "code",
progress: 100,
},
],
},
];
const IMAGE_ATTACHMENTS: ImageAttachment[] = MESSAGES.flatMap((m) =>
m.attachments.filter((a): a is ImageAttachment => a.kind === "image"),
);
function FileGlyph({ type }: { type: FileKind }) {
const common = {
width: 18,
height: 18,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
"aria-hidden": true,
};
if (type === "pdf") {
return (
<svg {...common}>
<path d="M14 2H6.5A1.5 1.5 0 0 0 5 3.5v17A1.5 1.5 0 0 0 6.5 22h11a1.5 1.5 0 0 0 1.5-1.5V7z" />
<path d="M14 2v5h5" />
<path d="M8.5 17v-4h1.2a1.2 1.2 0 0 1 0 2.4H8.5" />
<path d="M13 17v-4h1a2 2 0 0 1 0 4z" />
</svg>
);
}
if (type === "zip") {
return (
<svg {...common}>
<path d="M14 2H6.5A1.5 1.5 0 0 0 5 3.5v17A1.5 1.5 0 0 0 6.5 22h11a1.5 1.5 0 0 0 1.5-1.5V7z" />
<path d="M14 2v5h5" />
<path d="M10 4h2M10 7h2M10 10h2M10 13h2" />
<rect x="9.5" y="15.5" width="3" height="4" rx="1" />
</svg>
);
}
if (type === "sheet") {
return (
<svg {...common}>
<rect x="3" y="4" width="18" height="16" rx="2" />
<path d="M3 9.5h18M3 15h18M9.5 4v16M15 4v16" />
</svg>
);
}
if (type === "audio") {
return (
<svg {...common}>
<path d="M4 13v-2M8 16V8M12 19V5M16 16V8M20 13v-2" />
</svg>
);
}
return (
<svg {...common}>
<path d="m9 17-5-5 5-5M15 7l5 5-5 5" />
</svg>
);
}
const FILE_TONE: Record<FileKind, string> = {
pdf: "bg-rose-100 text-rose-700 dark:bg-rose-500/15 dark:text-rose-300",
zip: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300",
sheet: "bg-emerald-100 text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300",
audio: "bg-violet-100 text-violet-700 dark:bg-violet-500/15 dark:text-violet-300",
code: "bg-sky-100 text-sky-700 dark:bg-sky-500/15 dark:text-sky-300",
};
const RING =
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 dark:focus-visible:ring-indigo-400";
export default function ChatAttachments() {
const reduced = useReducedMotion();
const labelId = useId();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [saved, setSaved] = useState<Record<string, boolean>>({});
const closeRef = useRef<HTMLButtonElement | null>(null);
const openerRef = useRef<HTMLElement | null>(null);
const dialogRef = useRef<HTMLDivElement | null>(null);
const activeImage = useMemo(
() => (lightboxIndex === null ? null : IMAGE_ATTACHMENTS[lightboxIndex] ?? null),
[lightboxIndex],
);
const openLightbox = useCallback((id: string, trigger: HTMLElement) => {
const index = IMAGE_ATTACHMENTS.findIndex((img) => img.id === id);
if (index < 0) return;
openerRef.current = trigger;
setLightboxIndex(index);
}, []);
const closeLightbox = useCallback(() => {
setLightboxIndex(null);
const opener = openerRef.current;
if (opener) window.requestAnimationFrame(() => opener.focus());
}, []);
const step = useCallback((delta: number) => {
setLightboxIndex((prev) => {
if (prev === null) return prev;
const next = (prev + delta + IMAGE_ATTACHMENTS.length) % IMAGE_ATTACHMENTS.length;
return next;
});
}, []);
const isOpen = lightboxIndex !== null;
useEffect(() => {
if (!isOpen) return;
closeRef.current?.focus();
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
closeLightbox();
return;
}
if (event.key === "ArrowRight") {
event.preventDefault();
step(1);
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
step(-1);
return;
}
if (event.key !== "Tab") return;
const root = dialogRef.current;
if (!root) return;
const focusables = root.querySelectorAll<HTMLElement>(
'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])',
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKey);
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = previousOverflow;
};
}, [isOpen, closeLightbox, step]);
const toggleSaved = useCallback((id: string) => {
setSaved((prev) => ({ ...prev, [id]: !prev[id] }));
}, []);
return (
<section
className="relative w-full bg-slate-50 px-4 py-16 text-slate-900 sm:px-6 sm:py-24 dark:bg-slate-950 dark:text-slate-100"
aria-labelledby={labelId}
>
<style>{`
@keyframes camsg-rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes camsg-stripe {
from { background-position: 0 0; }
to { background-position: 28px 0; }
}
.camsg-rise { animation: camsg-rise 460ms cubic-bezier(0.22, 1, 0.36, 1) both; }
.camsg-stripe {
background-image: linear-gradient(
115deg,
rgba(255,255,255,0.55) 25%, transparent 25%,
transparent 50%, rgba(255,255,255,0.55) 50%,
rgba(255,255,255,0.55) 75%, transparent 75%
);
background-size: 28px 28px;
animation: camsg-stripe 900ms linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.camsg-rise, .camsg-stripe { animation: none !important; }
}
`}</style>
<div className="mx-auto w-full max-w-3xl">
<header className="mb-10 border-b border-slate-200 pb-8 dark:border-slate-800">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
Kalyan retrofit · case study
</p>
<h2
id={labelId}
className="mt-3 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white"
>
Shared files thread
</h2>
<p className="mt-3 max-w-xl text-sm leading-relaxed text-slate-600 dark:text-slate-400">
Four people, nine attachments, one deadline. Select any photo to open it full size — arrow keys move
between shots, Escape closes.
</p>
</header>
<ol className="space-y-8" role="list">
{MESSAGES.map((message, index) => {
const mine = message.role === "me";
return (
<li
key={message.id}
className={reduced ? undefined : "camsg-rise"}
style={reduced ? undefined : { animationDelay: `${index * 90}ms` }}
>
<article
className={`flex gap-3 sm:gap-4 ${mine ? "flex-row-reverse" : "flex-row"}`}
aria-label={`Message from ${message.author} at ${message.time}`}
>
<div
aria-hidden
className={`mt-1 grid h-9 w-9 shrink-0 place-items-center rounded-full text-[11px] font-semibold tracking-wide ${
mine
? "bg-indigo-600 text-white dark:bg-indigo-500"
: "bg-slate-200 text-slate-700 dark:bg-slate-800 dark:text-slate-200"
}`}
>
{message.initials}
</div>
<div className={`min-w-0 flex-1 ${mine ? "items-end text-right" : "items-start"}`}>
<div
className={`mb-1.5 flex items-baseline gap-2 text-xs ${
mine ? "flex-row-reverse" : "flex-row"
}`}
>
<span className="font-semibold text-slate-800 dark:text-slate-200">{message.author}</span>
<time className="tabular-nums text-slate-500 dark:text-slate-500">{message.time}</time>
</div>
<div
className={`inline-block w-full max-w-[38rem] rounded-2xl border px-4 py-3.5 text-left ${
mine
? "border-indigo-200 bg-indigo-50 dark:border-indigo-500/25 dark:bg-indigo-500/10"
: "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900"
}`}
>
<p className="text-sm leading-relaxed text-slate-700 dark:text-slate-300">{message.body}</p>
<AttachmentGroup
attachments={message.attachments}
onOpenImage={openLightbox}
saved={saved}
onToggleSaved={toggleSaved}
/>
</div>
</div>
</article>
</li>
);
})}
</ol>
</div>
<AnimatePresence>
{activeImage ? (
<motion.div
key="camsg-lightbox"
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/85 p-4 backdrop-blur-sm sm:p-8"
initial={reduced ? { opacity: 1 } : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={reduced ? { opacity: 1 } : { opacity: 0 }}
transition={{ duration: reduced ? 0 : 0.18 }}
onClick={(event) => {
if (event.target === event.currentTarget) closeLightbox();
}}
>
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={`${activeImage.caption}. Image ${(lightboxIndex ?? 0) + 1} of ${IMAGE_ATTACHMENTS.length}`}
className="relative w-full max-w-3xl"
>
<motion.div
initial={reduced ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.97 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduced ? { opacity: 1, scale: 1 } : { opacity: 0, scale: 0.97 }}
transition={{ duration: reduced ? 0 : 0.2, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden rounded-2xl border border-slate-700 bg-slate-900 shadow-2xl"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={activeImage.src}
alt={activeImage.alt}
width={activeImage.width}
height={activeImage.height}
loading="lazy"
draggable={false}
className="max-h-[70vh] w-full bg-slate-800 object-contain"
/>
<div className="flex items-center justify-between gap-4 border-t border-slate-700 px-4 py-3">
<p className="min-w-0 truncate text-xs text-slate-300">{activeImage.alt}</p>
<p className="shrink-0 text-xs tabular-nums text-slate-400">
{(lightboxIndex ?? 0) + 1} / {IMAGE_ATTACHMENTS.length}
</p>
</div>
</motion.div>
<div className="mt-4 flex items-center justify-center gap-2">
<button
type="button"
onClick={() => step(-1)}
className={`inline-flex h-10 items-center gap-1.5 rounded-full border border-slate-700 bg-slate-900 px-4 text-sm font-medium text-slate-200 transition hover:bg-slate-800 ${RING} focus-visible:ring-offset-slate-950`}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="m15 18-6-6 6-6" />
</svg>
Previous
</button>
<button
type="button"
onClick={() => step(1)}
className={`inline-flex h-10 items-center gap-1.5 rounded-full border border-slate-700 bg-slate-900 px-4 text-sm font-medium text-slate-200 transition hover:bg-slate-800 ${RING} focus-visible:ring-offset-slate-950`}
>
Next
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="m9 18 6-6-6-6" />
</svg>
</button>
<button
ref={closeRef}
type="button"
onClick={closeLightbox}
className={`inline-flex h-10 items-center gap-1.5 rounded-full bg-white px-4 text-sm font-semibold text-slate-900 transition hover:bg-slate-200 ${RING} focus-visible:ring-offset-slate-950`}
>
Close
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M18 6 6 18M6 6l12 12" />
</svg>
</button>
</div>
</div>
</motion.div>
) : null}
</AnimatePresence>
</section>
);
}
function AttachmentGroup({
attachments,
onOpenImage,
saved,
onToggleSaved,
}: {
attachments: Attachment[];
onOpenImage: (id: string, trigger: HTMLElement) => void;
saved: Record<string, boolean>;
onToggleSaved: (id: string) => void;
}) {
const images = attachments.filter((a): a is ImageAttachment => a.kind === "image");
const files = attachments.filter((a): a is FileAttachment => a.kind === "file");
return (
<div className="mt-3.5 space-y-3">
{images.length > 0 ? (
<ul
className={`grid gap-2 ${images.length === 1 ? "grid-cols-1" : "grid-cols-2 sm:grid-cols-3"}`}
aria-label={`${images.length} image ${images.length === 1 ? "attachment" : "attachments"}`}
>
{images.map((image) => (
<li key={image.id} className={images.length === 1 ? "max-w-sm" : undefined}>
<button
type="button"
onClick={(event) => onOpenImage(image.id, event.currentTarget)}
className={`group relative block w-full overflow-hidden rounded-xl border border-slate-200 bg-slate-100 transition dark:border-slate-700 dark:bg-slate-800 ${RING} focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-900`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={image.src}
alt={image.alt}
width={image.width}
height={image.height}
loading="lazy"
draggable={false}
className="aspect-[4/3] w-full object-cover transition duration-300 group-hover:scale-[1.03]"
/>
<span className="pointer-events-none absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-slate-950/80 to-transparent px-2 pb-1.5 pt-6 text-[11px] font-medium text-white opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100">
<span className="truncate">{image.caption}</span>
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="shrink-0"
>
<path d="M15 3h6v6M10 14 21 3M21 14v7H3V3h7" />
</svg>
</span>
</button>
</li>
))}
</ul>
) : null}
{files.length > 0 ? (
<ul
className="space-y-2"
aria-label={`${files.length} file ${files.length === 1 ? "attachment" : "attachments"}`}
>
{files.map((file) => (
<li key={file.id}>
<FileRow file={file} isSaved={Boolean(saved[file.id])} onToggleSaved={onToggleSaved} />
</li>
))}
</ul>
) : null}
</div>
);
}
function FileRow({
file,
isSaved,
onToggleSaved,
}: {
file: FileAttachment;
isSaved: boolean;
onToggleSaved: (id: string) => void;
}) {
const reduced = useReducedMotion();
const uploading = file.progress < 100;
return (
<div className="flex items-center gap-3 rounded-xl border border-slate-200 bg-slate-50 p-2.5 dark:border-slate-700 dark:bg-slate-800/60">
<span className={`grid h-9 w-9 shrink-0 place-items-center rounded-lg ${FILE_TONE[file.fileType]}`}>
<FileGlyph type={file.fileType} />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-[13px] font-medium text-slate-800 dark:text-slate-100">{file.name}</p>
{uploading ? (
<div className="mt-1.5">
<div
role="progressbar"
aria-valuenow={file.progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label={`Uploading ${file.name}`}
className="h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-700"
>
<div
className={`h-full rounded-full bg-indigo-600 dark:bg-indigo-500 ${reduced ? "" : "camsg-stripe"}`}
style={{ width: `${file.progress}%` }}
/>
</div>
<p className="mt-1 text-[11px] tabular-nums text-slate-500 dark:text-slate-400">
Uploading · {file.progress}% of {file.size}
</p>
</div>
) : (
<p className="mt-0.5 text-[11px] uppercase tracking-wide tabular-nums text-slate-500 dark:text-slate-400">
{file.fileType} · {file.size}
</p>
)}
</div>
<button
type="button"
aria-pressed={isSaved}
onClick={() => onToggleSaved(file.id)}
disabled={uploading}
className={`inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg border px-2.5 text-[12px] font-medium transition disabled:cursor-not-allowed disabled:opacity-40 ${RING} focus-visible:ring-offset-slate-50 dark:focus-visible:ring-offset-slate-900 ${
isSaved
? "border-emerald-300 bg-emerald-100 text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/15 dark:text-emerald-300"
: "border-slate-300 bg-white text-slate-700 hover:bg-slate-100 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
}`}
>
{isSaved ? (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M20 6 9 17l-5-5" />
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3" />
</svg>
)}
{isSaved ? "Saved" : "Save"}
</button>
</div>
);
}Dependencies
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Chat Bubbles
Originalmessage bubble styles both sides

Chat Conversation
Originalfull conversation thread with avatars

Chat Input
Originalchat composer with attach and send

Chat Support Widget
Originalfloating support chat widget

Chat Typing
Originaltyping indicator and delivery states

Chat Group
Originalgroup chat with member names

Chat List
Originalconversations list with unread counts

Chat AI
OriginalAI assistant chat interface

Chat Header
Originalchat window header with actions

Chat Reactions
Originalmessage reactions and replies

Chat Read Receipts
Originalread receipts and timestamps

Spotlight Hero
OriginalA centred hero with a soft radial spotlight, badge and dual call-to-action.

