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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/composer-attachments.jsonPrompt
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 densities —
chipsandcardsare 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
kindprecisely 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 filesis 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
maxVisiblethe tail folds behind a+N morebutton 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.
Translation Panel
A document beside its machine translation, aligned segment by segment — hover or focus a pair and both halves light up, with per-segment translated / waiting / failed states, a swappable language header, a formality control and progress over the whole job.
Mobile App Bar
A phone screen header whose large in-flow title scrolls away and crossfades into a 44px bar, with the bar's surface bleeding under the safe-area top inset.