AI

Composer Attachments

The strip of things already attached to the next message — compact chips on the composer's top edge or rich preview cards under its bottom edge, each printing only the statistics its own kind actually has.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, Code, Folder, Globe, Paperclip, X, type LucideIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { FileIcon, fileExtensionOf, type FileIconSize } from "@/registry/ui/file-icon"
import { cn } from "@/lib/utils"
import type {
  ComposerAttachmentItem,
  ComposerAttachmentsData,
  ComposerAttachmentsPlacement,
  ComposerAttachmentsVariant,
} from "./composer-attachments.contract"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/composer-attachments.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "ComposerAttachments" component (zod +
lucide-react). It is the strip of things ALREADY attached to the next message,
shown beside a prompt input: files, images, folders, spreadsheets, links and
code snippets. It is not the input, not a file picker, not an upload queue —
selection, transport and state all stay with the composer around it.

Contract
- A zod schema in a sibling contract file is the single source of truth, and the
  item type is a DISCRIMINATED UNION on `kind`, not one bag of optional fields:
    file        { id, name, ext?, lines? }
    image       { id, name, ext?, src?, width?, height? }
    folder      { id, name, files?, docs? }
    spreadsheet { id, name, ext?, rows?, columns? }
    link        { id, name, url }            // url is required: it is what makes it a link
    snippet     { id, name, source, startLine?, endLine? }   // source is required
  The union is the point: a folder counts files and documents, a sheet counts
  rows and columns, a text file counts lines, and no kind can borrow another's
  unit. Every statistic is OPTIONAL and a missing one prints nothing — there is
  no default of 0, because "43 files" and "we were not told how many" are
  different facts and only one of them may appear on screen.
- The wrapper schema adds { status: "loading" | "empty" | "error" | "ready",
  items, variant?: "chips" | "cards", placement?: "above" | "below", label?,
  maxVisible? } plus a refinement that ids are unique (a duplicate id makes two
  entries share a React key, and removing one then unmounts the other). The
  refinement guards its own input with Array.isArray / typeof before touching
  `items[0].id`, because zod runs every refinement — an unguarded dereference on
  a ragged payload throws out of safeParse instead of returning success: false.
- Props extend HTMLAttributes<HTMLDivElement> minus the contract keys, and add:
  removable = true, onRemove?(id), onOpen?(item), onRetry?, emptyState?,
  skeletonCount? (clamped 1-8). forwardRef to the root, cn() merges className,
  rest spreads onto the root.
- Affordances are handler-gated. No onOpen: nothing renders as clickable — no
  button element, no cursor-pointer, no hover underline. No onRemove (or
  removable=false): no remove control AND no key handling, so a read-only strip
  is completely out of the tab order.

Behavior
- ONE list, TWO densities. `chips` is a single row of pills (mark + name + ✕)
  that scrolls sideways; `cards` is a wrapping row of fixed-size previews:
  image cards give the thumbnail the whole body and put the name in the caption
  bar, everything else puts name plus up to two statistic lines in the body and
  a type badge ("PDF", "XLSX", "Folder", "Link", "Snippet") in the footer.
  Same data, same callbacks, same semantics — only the density changes.
- `placement` moves nothing but corners and padding: "above" squares the bottom
  edge and drops its border so the strip sits on the input's top edge; "below"
  does the mirror image. It never changes what is rendered.
- Overflow has two answers. Past maxVisible the tail folds behind a "+N more"
  button that expands in place, where N is items.length minus what is shown —
  a figure, never a decoration. Without maxVisible the chips row simply scrolls,
  with a fade at each edge that has content beyond it: a scroll listener plus a
  ResizeObserver on both the viewport and the row (the row's width changes when
  an entry leaves), all torn down on unmount and rebuilt with the node they
  watch.
- Removal is keyboard-first. Delete or Backspace on the focused entry removes
  it; focus then moves to the NEXT entry, or the previous one if it was last, or
  to the strip itself when nothing is left — never onto <body>. An always-mounted
  role="status" region says what went and how many remain. Both only fire once
  the entry is really gone from `items`: until then the consumer may still be
  refusing the removal, and moving focus (or claiming a count) would be a lie.
- Thumbnails degrade, never break: an image whose src fails removes itself and
  uncovers the type mark painted behind it, and a cached image that finished
  before hydration is probed through the DOM (complete && naturalWidth === 0)
  because its error event never arrives. The URL comes from the caller, so it is
  never revoked here — revoking a URL you did not create blanks it everywhere
  else it is used. (A component that takes a File and creates the object URL
  itself must revoke it; this one takes a validated payload, where a File cannot
  survive the boundary.)
- Four first-class branches on `status`: loading (placeholders at the real chip
  or card geometry, so nothing jumps when the list lands), empty (says what CAN
  be attached, replaceable through emptyState), error (says the list failed to
  load — which is not the same as "your files are gone" — with retry only if
  onRetry was passed), ready.

Rendering & styling
- Semantic tokens only: bg-background root with a border, bg-card chips and
  cards, bg-muted thumbnail wells and skeletons, text-muted-foreground for
  statistics and badges, text-destructive for the error line, ring-ring for
  focus. Type tints come from var(--chart-1..5) and var(--foreground) by way of
  the file-icon mark — no hex / rgb / oklch anywhere, so a repalette is free.
- Reuse rather than rebuild: extension-to-glyph goes through your existing
  file-icon component (it already owns the extension table, the MIME fallback
  and the family tints); folders, links and snippets are not files, so they hand
  it a glyph plus a tint instead. The remove / expand / retry controls are your
  shadcn Button (ghost icon-xs, outline sm) — only the chip and card shells are
  hand-rolled, because no primitive has that geometry.
- Semantics: a real ul + li with role="list" restated (list-style: none drops
  list semantics in Safari). A LIST, not a toolbar — these are things the user
  attached, not a bank of commands — so there is no roving tabindex: each entry
  is an ordinary tab stop and its remove button is the next one. Every remove
  button is named ("Remove contract-agreement-brief.pdf", never a bare ✕), each
  removable entry carries aria-keyshortcuts="Delete" and is described by one
  shared sr-only hint, and the "+N more" button gets a spoken label because
  "+3 more" is a fine thing to see and a poor thing to hear.
- Cards use the stretched-hit-area trick (an after:absolute overlay on the name
  button) so the whole card opens without ever nesting a button inside a button;
  the remove control sits above it on z-10.
- Long names truncate in chips and clamp to two lines with wrap-anywhere in
  cards; the full name plus its statistics stay reachable through title.
  Transitions carry motion-reduce:transition-none, the skeleton pulse carries
  motion-reduce:animate-none.

Customization levers
- Kinds: the union is the extension point. Add "calendar" or "board" with its
  own statistics, then teach the two switch statements (secondary line, type
  label) and the icon map about it — nothing else needs to know.
- Density: chip height (h-8) and card size (w-40 h-32) are one constant each.
  Cards with no statistics can drop to a caption-only tile by shortening the
  body; a denser chip row is a smaller mark size and tighter padding.
- Overflow policy: maxVisible collapses, no maxVisible scrolls. Swap the "+N
  more" expander for a popover listing the rest if the tail matters more than
  the row height.
- Removal ceremony: this strip removes on one press because a composer
  attachment is cheap to re-add. If yours is not, wrap onRemove in your own
  confirm step or undo bar rather than adding a second path here.
- Statistics: metaOf() is one function per kind. Show bytes instead of lines,
  or a relative time, by returning a different string — an absent field must
  keep returning an empty array so the line disappears instead of reading zero.
- Placement: the two corner recipes are a single record. A floating strip that
  is not glued to an input wants all four corners rounded and its own shadow.

Concepts

  • Same list, two densitieschips and cards are one component with one contract, not two components that happen to share data. The chip row is what a busy composer can afford; the card row is what a wide one can show. Switching is a prop, so a responsive host can change its mind without re-modelling anything.
  • Per-kind statistics, never invented — the item type is discriminated on kind precisely so a folder can report files and documents, a sheet rows and columns, a text file lines. Every one of those fields is optional, and a missing one draws no line at all: 0 files is a claim, and this component refuses to make claims it was not given.
  • Removal is a keyboard operation first — Delete or Backspace on the focused entry, focus moving forward to the next entry (backward if it was the last, to the strip itself if nothing remains), and a role="status" line naming what went and how many are left. The pointer path is the same operation with a named button attached.
  • Focus follows proof, not intent — the strip does not move focus when the remove button is pressed; it moves focus when the entry has actually disappeared from items. A consumer that refuses or defers the removal therefore never leaves the user's focus stranded on an element that is still on screen.
  • Overflow is a count, not a fade — past maxVisible the tail folds behind a +N more button whose N is measured from the list, and unfolding is in place. The edge fades on the scrolling row are the other half: they appear only on a side that really has content beyond it, and they never pretend to be buttons.
  • Affordance gating — no onOpen, no clickable rendering anywhere: no button element, no pointer cursor, no hover underline. A strip that looks pressable and does nothing costs more trust than a plain one saves in polish.

On This Page