Attachment Tray
The composer's + tray: a source panel that opens at the software keyboard's own height and pushes the composer up instead of covering it, driven by drag or button.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/attachment-tray.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "AttachmentTray" component — the "+" tray
that lives on a mobile composer and expands into attachment sources (camera,
photo library, files, location). React + lucide-react only: no gesture library,
no animation library, no portal, no modal.
The one idea it is built around: the panel takes over the slot the software
keyboard just gave up. It opens at the height that keyboard was, so the composer
is pushed UP the screen by exactly the height it was already sitting at, and
nothing under the finger jumps. It is therefore a phone component by
construction — on a desktop there is no keyboard slot to inherit and a menu
would do the job.
Contract
- "use client". forwardRef<HTMLDivElement, AttachmentTrayProps> extending
React.HTMLAttributes<HTMLDivElement>; the rest props spread onto the root.
- AttachmentSource = {
key: string // unique; also keys the hidden file input
label: string
icon: React.ReactNode
hint?: string // second line, "stack" variant only
accept?: string // handed to the input; omitted = any file
capture?: "user" | "environment"
multiple?: boolean // default true
onSelect?: () => void // action source: runs instead of a file input
unavailable?: string // present = refused, and this is the reason
}
- Props:
- sources: AttachmentSource[] — required. An empty array refuses to open.
- onPick?: ({ source, files }: { source: AttachmentSource; files: File[] }) =>
void — fires once per successful pick. The component never uploads anything;
what happens to the File objects is the consumer's business.
- variant = "grid" | "row" | "stack" (default "grid").
- open? / defaultOpen = false / onOpenChange? — controlled and uncontrolled
both supported; onOpenChange fires for every requested change, including one
a controlled parent then refuses.
- panelHeight? — fixed px, clamped 56..640. Omitted, the panel matches the last
software keyboard seen this session (fallback 264, or 88 for "row").
- closeOnPick = true, count = 0 (badge on the toggle), label = "Attachments",
panelLabel = "Attachment sources", emptyMessage.
- children — the composer row that sits beside the toggle (input, send button).
- Mirror the state onto the root as data-state="open" | "closed" and
data-variant; tag the toggle, handle, panel and tiles with data-slot so a
consumer can restyle one piece without forking the component.
Behavior — the keyboard's height, remembered
- Subscribe to window.visualViewport (resize + scroll) through
useSyncExternalStore and compute the bottom inset as
innerHeight - visualViewport.height - offsetTop. On iOS the layout viewport
never shrinks (the keyboard overlaps it) and the page can be scrolled under the
keyboard, which offsetTop corrects for; on an Android window that resizes
instead this lands on 0, which is also correct.
- Keep the last reading that was at least ~120px in a module-level variable and
never write it back down to 0 — by the time the tray opens the keyboard is
already gone and the live inset reads 0, so an unremembered height would
collapse the panel. The ~120px floor is what keeps a collapsing URL bar (or a
toolbar) from being mistaken for a keyboard; a later, shorter keyboard (an
emoji pad, a hardware keyboard's suggestion strip) legitimately replaces it.
Module level, not per instance: keyboard height is a property of the device, so
the tray on the next screen opens right the first time. Unsubscribe when the
last tray unmounts.
- Opening moves focus to the first available tile. That is not decoration: it
blurs the composer field, which is what retracts the software keyboard so the
panel can have its slot. Closing hands focus back to the toggle — the panel
goes inert, and focus must never be left to fall onto <body>.
- The other half of that rule: focus landing anywhere inside the children slot
(capture phase, so any field inside it counts) collapses the tray, because
focus in a text field means the keyboard is on its way up. Keyboard XOR tray —
they may never claim the same slot at once.
Behavior — the gesture, and its equal button path
- The toggle IS the drag handle: press it and drag up to open, and there is a
second grab handle at the top of the open panel to drag down. Pointer Events
only (never separate mouse/touch handlers), setPointerCapture taken on the node
the gesture started on so a finger that wanders off a 44px button keeps
driving, and touch-action: none on both drag zones so the browser never fights
it. preventDefault is never called anywhere — touch-action already said it.
- 4px of movement before it counts as a drag, so a tap on the toggle is still a
tap; a drag that did happen sets a one-shot ref that swallows the click
trailing it, or the gesture and the click would cancel each other out.
- Velocity is smoothed (v = 0.3*v + 0.7*sample) so one jittery frame is not a
fling. On release: |v| > 0.45px/ms picks the direction of the fling, otherwise
the panel settles to whichever edge is nearer (>= half of the open height opens).
A pointercancel settles back to the state it started in.
- Past the fully open height the drag is damped (0.3, max 28px) instead of being
hard-stopped: it moves, it never runs away.
- Height is painted straight onto the panel node from a single rAF during the
drag — a 60fps drag must not re-render the composer. Keep ONE writer: an effect
paints the resting height for every commit the finger is not driving, keyed on
a tick bumped at the end of every gesture. That tick is what makes the panel
ease home when a controlled parent refuses to open — and what drops the pending
"focus the first tile" request when it does, so a much later programmatic open
cannot inherit it and steal focus out of a field.
- Keyboard map: Tab reaches the toggle then the tiles; Enter / Space toggle
(it is a real <button>, so that is free); ArrowUp opens and ArrowDown closes;
Escape closes and returns focus to the toggle. Escape is handled on the root,
not on window — a window listener cannot tell which layer is on top and would
also eat the Escape meant for a dialog above the tray.
Behavior — picking, and refusing
- Each file source owns a hidden <input type="file"> carrying its accept,
capture and multiple. Render those inputs OUTSIDE the panel: an inert ancestor
swallows the programmatic click. A tile press clicks its input; an action
source (location, contact card) runs onSelect and opens nothing.
- On change: reset input.value to "" FIRST (picking the same file twice in a row
must fire twice), and return early on an empty list — a cancelled OS picker
must leave the tray exactly as it was. Otherwise call onPick, announce the
count politely, and with closeOnPick collapse and return focus to the toggle.
- A source with `unavailable` keeps its tile and reports aria-disabled — never
the native disabled attribute, which blurs the node to <body> and makes it
unfocusable. Pressing it announces the reason instead of doing nothing; the
stack variant also shows it inline under the label.
- Empty sources: the toggle reports aria-disabled and pressing it refuses with
emptyMessage rather than opening onto an empty panel. The panel still renders
that message, for the case where the last source disappears while it is open.
- One polite sr-only role="status" carries refusals and confirmations, cleared
after ~2.6s so the next identical refusal is announced again. The drag itself
is never announced.
Rendering & styling
- Semantic tokens only, monochrome by default: root border + bg-card +
rounded-2xl, panel border-t + bg-card, tiles border + bg-background with
hover:bg-accent / hover:text-accent-foreground, icon wells bg-muted, captions
text-muted-foreground, grab handle bg-muted-foreground/40, focus-visible:ring-2
ring-ring on everything focusable. The toggle is the one high-priority surface
and it INVERTS when open (bg-foreground text-background) rather than taking a
colour; the count badge inverts against it. Colour is spent only on a real
refusal (text-destructive).
- Root pads bottom with env(safe-area-inset-bottom): the tray is meant to sit on
the screen edge, so both the collapsed row and the open panel clear the home
indicator.
- The composer row is rendered ABOVE the panel inside the same column, which is
the whole trick: growing the panel pushes the composer up rather than covering
it. Every hit area is >= 44px (toggle size-11, chips h-11, list rows min-h-14,
grid tiles min-h-21).
- Variants are three different rows, not colour swaps: "grid" = a four-column
well of icon tiles at keyboard height; "row" = one 88px line of scrolling 44px
chips; "stack" = full-width list rows with icon, label and a second line, for
long labels and sources that must explain themselves.
- Motion: transition-[height] duration-300 ease-out on the panel (removed while
dragging — the finger IS the animation), a 25ms-per-tile fade/rise capped at 7
steps, and the + rotating 45deg into an x. Use the plain `transition` list for
the tiles and the icon: in Tailwind v4 translate / scale / rotate are their own
CSS properties, so transition-transform would silently not cover them. All of
it is class-based with motion-reduce:transition-none, so reduced motion keeps
every function and drops only the decoration. Tiles are painted in while a drag
is in progress too, so a half-open panel is never an empty box.
- Collapsed the panel is 0px tall with overflow hidden, and inert: its buttons
stay in the DOM (that is what makes the height animate) but out of the tab
order and out of the accessibility tree.
Customization levers
- variant is the layout dial: "grid" for 4+ sources, "row" for a composer that
should barely move, "stack" when sources need a sentence each.
- panelHeight overrides the keyboard memory — pass it for a fixed design, leave
it off for the native feel. The fallback (264) and the row height (88) are two
constants at the top of the file.
- Feel lives in three numbers: the 4px drag threshold, the 0.45px/ms fling
cut-off and the 0.3 / 28px rubber band. Raise the fling cut-off to make flings
deliberate; drop the band to 0 for a hard stop.
- Sources are data, not markup: reorder them, gate them per platform, or hand a
source `unavailable` text instead of removing it when a permission is off — a
visible refusal teaches more than a missing tile.
- count is a display-only badge; keep the real attachment list in the consumer
(a file-upload queue component, say) and pass its length.
- Docked full width, pass className="rounded-none border-x-0 border-b-0" — cn()
merges it last. Everything else skins through data-slot / data-state.
- Wire it to a real composer by putting the input and send button in children;
the tray owns only the toggle, the panel and the height.Concepts
- The panel replaces the keyboard, it does not stack on it — opening moves focus to the first tile, which blurs the composer field and retracts the software keyboard; the panel then opens at the height that keyboard was. Focus returning to the composer collapses the tray again, so the two never claim the same slot at once. The composer ends up exactly where it already sat, so nothing under the thumb jumps, and the transcript above gives up the space instead.
- Remembered keyboard height —
innerHeight - visualViewport.height - offsetTopis the only honest reading of the inset, but it reads 0 by the time the tray actually opens, so the last reading worth trusting is kept and never written back down. Anything under ~120px is a collapsing URL bar rather than a keyboard. The memory is module level because it describes the device, not one tray. - Pushed up, not covered — the composer row lives above the panel in the same column, so the panel growing moves the composer up the screen. That is the difference between this and a sheet: a sheet rises over the composer and hides the draft, a tray carries it.
- Every gesture has a button — the toggle is both the drag handle and a real
<button>witharia-expanded. A 4px threshold keeps a tap a tap, a one-shot ref swallows the click that trails a finished drag, and ArrowUp / ArrowDown / Escape reach the same two states with no finger at all. Velocity decides a fling, the nearer edge decides everything else, and a controlled parent that refuses to open gets a panel that eases straight back home. - Inert when collapsed — the tiles stay mounted at zero height (that is what makes the height animate) but
inertkeeps them out of the tab order and out of the accessibility tree, and the hidden file inputs are rendered outside the panel so an inert ancestor cannot swallow their programmatic click. - A refusal says why — a source whose permission is off keeps its tile, reports
aria-disabledrather than taking the native attribute that would blur it to<body>, and answers a press with a spoken reason. The same applies to a tray with nothing in it: it refuses on the toggle instead of opening onto an empty panel. - Bottom edge means safe area — the root pads with
env(safe-area-inset-bottom), so the collapsed row and the open panel both clear the home indicator without the consumer wrapping anything.
Prompt Rail
A one-line rail of conversation starters that scrolls under the thumb and folds itself away — on a flick, on a key, or the moment the thread starts.
Source Sheet
A citation opened as a bottom sheet instead of a popover — the passage in the thumb arc, the cited line lifted clear of the sheet, and a way back to where you were reading.