Web InnoventixFreeCode

Chat Attachments

Original · free

messages with image and file attachments

byWeb InnoventixReact + Tailwind
chatattachments
Open

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
chat-attachments.tsx
"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

motion

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 quote

Similar components

Browse all →