Mobile

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Plus } from "lucide-react"
import { cn } from "@/lib/utils"

/** Movement (px) before a press on the toggle / handle becomes a drag. Under it, a tap is still a tap. */
const DRAG_START_PX = 4
/** Fling speed (px/ms). Above it the direction decides the outcome; below it the nearer edge wins. */
const FLING_VELOCITY = 0.45
/** Damping and hard ceiling for a drag pulled past the fully open height. */
const RUBBER_BAND = 0.3
const RUBBER_BAND_MAX_PX = 28
/** Bounds for the automatic (keyboard-derived) panel height. */

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/attachment-tray.json

Prompt

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 heightinnerHeight - visualViewport.height - offsetTop is 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> with aria-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 inert keeps 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-disabled rather 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.

On This Page