{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"file-share","type":"registry:block","title":"File share","description":"Drop files, watch them upload, and they fold into one share link with an expiry and an optional password.","author":"Hairline UI","dependencies":["lucide-react"],"registryDependencies":["https://hairlineui.com/r/copy-button.json?as=dependency","https://hairlineui.com/r/input.json?as=dependency","https://hairlineui.com/r/number-roll.json?as=dependency","https://hairlineui.com/r/segmented-control.json?as=dependency","https://hairlineui.com/r/status-button.json?as=dependency","https://hairlineui.com/r/switch.json?as=dependency","https://hairlineui.com/r/text-morph.json?as=dependency"],"files":[{"path":"registry/file-share.tsx","type":"registry:component","content":"// Hairline UI · file-share · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { Lock, RotateCw, Upload, X } from \"lucide-react\";\nimport { CopyButton } from \"./copy-button\";\nimport { Faces } from \"./faces\";\nimport { Input } from \"./input\";\nimport { NumberRoll } from \"./number-roll\";\nimport { SegmentedControl } from \"./segmented-control\";\nimport { StatusButton, type ActionStatus } from \"./status-button\";\nimport { Switch } from \"./switch\";\nimport { TextMorph } from \"./text-morph\";\nimport { INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * FILE SHARE: files that fold into one link\n *\n *   drop      the lit panel is the drop area; dragging files over\n *             it brightens its edge and says how many will land\n *   upload    each file uploads as soon as it's added, its row\n *             filling with a hairline and rolling its percent;\n *             a failed one offers Retry, a removed one is aborted\n *   options   how long the link lives, and an optional password\n *   link      Create link goes Creating → the rows fly up into the\n *             panel, which turns into the link card: the URL with\n *             Copy, what's in it, and the files fanned as a stack\n *\n * Storage is yours: onUpload sends one file (report progress and\n * honour the signal), onCreateLink makes the link from what your\n * uploads returned. Throw an Error and its message shows in place.\n * ───────────────────────────────────────────────────────── */\n\nexport interface FileShareUploadContext {\n  /** Report progress from 0 to 1. */\n  onProgress: (fraction: number) => void;\n  /** Aborted when the file is removed. */\n  signal: AbortSignal;\n}\n\nexport interface FileShareFile {\n  name: string;\n  size: number;\n  type: string;\n  /** Whatever onUpload resolved with for this file, e.g. its storage key. */\n  result: unknown;\n}\n\nexport interface FileShareRequest {\n  files: FileShareFile[];\n  /** Days the link lives. */\n  expiresIn: number;\n  password?: string;\n}\n\nexport interface FileShareHandle {\n  /** Add files from anywhere: a paste handler, a button of your own. */\n  add: (files: File[]) => void;\n}\n\nexport interface FileShareProps {\n  /** Upload one file; resolve with what you'll need to make the link (a key, an id). */\n  onUpload: (file: File, context: FileShareUploadContext) => Promise<unknown>;\n  /** Make the link; resolve with its URL. */\n  onCreateLink: (share: FileShareRequest) => Promise<{ url: string }>;\n  /** Native input syntax, e.g. \"image/*,.pdf\". */\n  accept?: string;\n  /** Bytes per file. */\n  maxSize?: number;\n  maxFiles?: number;\n  /** Choices for how many days the link lives. */\n  expiries?: number[];\n  ref?: React.Ref<FileShareHandle>;\n  className?: string;\n}\n\ntype Item = { id: string; file: File; progress: number; status: \"uploading\" | \"done\" | \"error\"; error?: string; result?: unknown; controller?: AbortController };\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nconst EXPIRIES = [1, 7, 30];\nconst mix = (color: string, amount: number, into = \"transparent\") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;\nconst LIT: React.CSSProperties = {\n  backgroundColor: \"var(--primary)\",\n  backgroundImage: `radial-gradient(120% 90% at 0% 0%, ${mix(\"var(--primary-foreground)\", 22)} 0%, transparent 55%), linear-gradient(175deg, transparent 40%, ${mix(\"black\", 24)} 100%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 36)}, inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 12)}`,\n};\nconst CARD: React.CSSProperties = {\n  backgroundImage: `linear-gradient(180deg, var(--card), ${mix(\"var(--card)\", 95, \"var(--foreground)\")})`,\n  boxShadow: `0 0 0 1px var(--border), inset 0 1px 0 ${mix(\"var(--background)\", 85)}`,\n};\nconst ACTION: React.CSSProperties = {\n  backgroundImage: `linear-gradient(180deg, ${mix(\"var(--primary-foreground)\", 16)}, transparent 60%)`,\n  boxShadow: `inset 0 1px 0 ${mix(\"var(--primary-foreground)\", 32)}, inset 0 0 0 1px ${mix(\"black\", 10)}`,\n};\n\nconst extOf = (name: string) => (name.includes(\".\") ? name.split(\".\").pop()!.slice(0, 4).toUpperCase() : \"FILE\");\nfunction tint(seed: string): React.CSSProperties {\n  let h = 0;\n  for (const c of seed) h = (h * 31 + c.charCodeAt(0)) >>> 0;\n  const chart = `var(--chart-${(h % 5) + 1}, var(--primary))`;\n  return { background: mix(chart, 20, \"var(--card)\"), color: mix(chart, 75, \"var(--foreground)\"), boxShadow: `inset 0 0 0 1px ${mix(chart, 26)}` };\n}\nfunction size(bytes: number) {\n  const units = [\"B\", \"KB\", \"MB\", \"GB\"];\n  let n = bytes;\n  let u = 0;\n  while (n >= 1000 && u < units.length - 1) {\n    n /= 1000;\n    u++;\n  }\n  return `${n >= 10 || u === 0 ? Math.round(n) : n.toFixed(1)} ${units[u]}`;\n}\nlet seq = 0;\n\nexport function FileShare({ onUpload, onCreateLink, accept, maxSize, maxFiles = 20, expiries = EXPIRIES, ref, className = \"\" }: FileShareProps) {\n  const reduced = useInteractionReducedMotion();\n  const baseId = React.useId();\n  const [items, setItems] = React.useState<Item[]>([]);\n  const [over, setOver] = React.useState(0);\n  const [expires, setExpires] = React.useState(expiries[Math.min(1, expiries.length - 1)] ?? 7);\n  const [lock, setLock] = React.useState(false);\n  const [password, setPassword] = React.useState(\"\");\n  const [create, setCreate] = React.useState<{ status: ActionStatus; error?: string }>({ status: \"idle\" });\n  const [link, setLink] = React.useState<{ url: string; files: Item[]; flown?: boolean } | null>(null);\n  // While the rows fly up, the list stops clipping so they can leave it.\n  const [flying, setFlying] = React.useState(false);\n  const [ready, setReady] = React.useState(false);\n  const [said, setSaid] = React.useState(\"\");\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const sectionRef = React.useRef<HTMLElement>(null);\n  const panelRef = React.useRef<HTMLDivElement>(null);\n  const listRef = React.useRef<HTMLUListElement>(null);\n  const depth = React.useRef(0);\n\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() => setReady(true));\n    return () => cancelAnimationFrame(frame);\n  }, []);\n\n  const patch = (id: string, next: Partial<Item>) => setItems((list) => list.map((i) => (i.id === id ? { ...i, ...next } : i)));\n\n  const upload = (item: Item) => {\n    const controller = new AbortController();\n    patch(item.id, { status: \"uploading\", progress: 0, error: undefined, controller });\n    onUpload(item.file, { onProgress: (f) => patch(item.id, { progress: Math.max(0, Math.min(1, f)) }), signal: controller.signal })\n      .then((result) => {\n        if (!controller.signal.aborted) patch(item.id, { status: \"done\", progress: 1, result });\n      })\n      .catch((e) => {\n        if (!controller.signal.aborted) patch(item.id, { status: \"error\", error: e instanceof Error && e.message ? e.message : \"Upload failed\" });\n      });\n  };\n\n  const add = (files: FileList | File[]) => {\n    if (link) return;\n    const room = Math.max(0, maxFiles - items.length);\n    const next: Item[] = [...files].slice(0, room).map((file) => ({ id: `f${++seq}`, file, progress: 0, status: \"uploading\" as const }));\n    for (const item of next) {\n      if (maxSize && item.file.size > maxSize) {\n        item.status = \"error\";\n        item.error = `Over ${size(maxSize)}`;\n      }\n    }\n    setItems((list) => [...list, ...next]);\n    setCreate({ status: \"idle\" });\n    setSaid(`${next.length} ${next.length === 1 ? \"file\" : \"files\"} added.`);\n    next.filter((i) => i.status !== \"error\").forEach(upload);\n  };\n\n  React.useImperativeHandle(ref, () => ({ add: (files: File[]) => add(files) }));\n\n  const remove = (item: Item) => {\n    item.controller?.abort();\n    setItems((list) => list.filter((i) => i.id !== item.id));\n  };\n\n  const doneItems = items.filter((i) => i.status === \"done\");\n  const uploading = items.some((i) => i.status === \"uploading\");\n  const total = items.reduce((n, i) => n + i.file.size, 0);\n  const sent = items.reduce((n, i) => n + i.file.size * (i.status === \"done\" ? 1 : i.progress), 0);\n  const canCreate = doneItems.length > 0 && !uploading && (!lock || password.trim().length > 0);\n  const busy = create.status === \"pending\" || create.status === \"success\";\n\n  const createLink = async () => {\n    if (!canCreate || busy) return;\n    setCreate({ status: \"pending\" });\n    try {\n      const { url } = await onCreateLink({\n        files: doneItems.map((i) => ({ name: i.file.name, size: i.file.size, type: i.file.type, result: i.result })),\n        expiresIn: expires,\n        password: lock && password.trim() ? password.trim() : undefined,\n      });\n      setCreate({ status: \"success\" });\n      setSaid(`Link ready: ${url}`);\n      // Each file's tile leaps into the corner where the link's stack appears and lands as one of its cards,\n      // while the rows fold away; then the panel turns into the link under tiles already in place.\n      const section = sectionRef.current;\n      const panel = panelRef.current?.getBoundingClientRect();\n      const rows = [...(listRef.current?.querySelectorAll<HTMLElement>(\"[data-file]\") ?? [])];\n      const flights: HTMLElement[] = [];\n      if (section && panel && rows.length && !reduced) {\n        setFlying(true);\n        const box = section.getBoundingClientRect();\n        // Screen px to the block's own px, so the tiles land true inside a scaled or zoomed container too.\n        const k = box.width / (section.offsetWidth || box.width) || 1;\n        // The stack's centre on the link face: the panel's top-right corner, inside its padding.\n        const fan = { x: panel.right - 54 * k, y: panel.top + 46 * k };\n        const shown = rows.slice(0, 3);\n        shown.forEach((row, i) => {\n          row.animate([{ opacity: 1, transform: \"none\" }, { opacity: 0, transform: \"translateY(-6px)\" }], { duration: 260, delay: 120 + i * 90, easing: EASE, fill: \"forwards\" });\n          const tile = row.querySelector<HTMLElement>(\"[data-tile]\");\n          if (!tile) return;\n          const r = tile.getBoundingClientRect();\n          const copy = tile.cloneNode(true) as HTMLElement;\n          Object.assign(copy.style, { position: \"absolute\", left: `${(r.left - box.left) / k}px`, top: `${(r.top - box.top) / k}px`, width: `${r.width / k}px`, height: `${r.height / k}px`, margin: \"0\", zIndex: \"30\", pointerEvents: \"none\", willChange: \"transform\" });\n          section.appendChild(copy);\n          flights.push(copy);\n          const turn = (i - (shown.length - 1) / 2) * 10;\n          const dx = (fan.x - (r.left + r.width / 2)) / k + turn * 0.6;\n          const dy = (fan.y - (r.top + r.height / 2)) / k;\n          const grow = 40 / (r.width / k);\n          copy.animate(\n            [\n              { transform: \"translate(0px, 0px) rotate(0deg) scale(1)\" },\n              { transform: `translate(${dx * 0.42}px, ${dy * 0.58 - 28}px) rotate(${turn * 0.5 - 6}deg) scale(${(1 + grow) / 2})`, offset: 0.55 },\n              { transform: `translate(${dx}px, ${dy}px) rotate(${turn}deg) scale(${grow})` },\n            ],\n            { duration: 640, delay: i * 90, easing: \"cubic-bezier(0.45,0,0.2,1)\", fill: \"forwards\" },\n          );\n        });\n      }\n      const landed = reduced || !flights.length ? 300 : 560 + (flights.length - 1) * 90;\n      window.setTimeout(() => {\n        setLink({ url, files: doneItems, flown: flights.length > 0 });\n        setFlying(false);\n        // The real stack is under the flown tiles now; they hand over and go.\n        flights.forEach((f) => (f.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 220, delay: 260, fill: \"forwards\" }).onfinish = () => f.remove()));\n      }, landed);\n    } catch (e) {\n      setCreate({ status: \"error\", error: e instanceof Error && e.message ? e.message : \"Couldn’t make the link. Try again.\" });\n    }\n  };\n\n  const reset = () => {\n    setItems([]);\n    setLink(null);\n    setCreate({ status: \"idle\" });\n    setPassword(\"\");\n    setLock(false);\n  };\n\n  const shared = link?.files ?? [];\n  const sharedSize = shared.reduce((n, i) => n + i.file.size, 0);\n\n  return (\n    <section\n      ref={sectionRef}\n      aria-labelledby={`${baseId}-title`}\n      className={`relative w-full max-w-[420px] rounded-[28px] p-2 text-card-foreground ${className}`}\n      style={CARD}\n      onDragEnter={(e) => {\n        if (link || !e.dataTransfer.types.includes(\"Files\")) return;\n        e.preventDefault();\n        depth.current++;\n        setOver(e.dataTransfer.items.length || 1);\n      }}\n      onDragOver={(e) => {\n        if (link || !e.dataTransfer.types.includes(\"Files\")) return;\n        e.preventDefault();\n        e.dataTransfer.dropEffect = \"copy\";\n      }}\n      onDragLeave={() => {\n        depth.current = Math.max(0, depth.current - 1);\n        if (!depth.current) setOver(0);\n      }}\n      onDrop={(e) => {\n        if (link) return;\n        e.preventDefault();\n        depth.current = 0;\n        setOver(0);\n        add(e.dataTransfer.files);\n      }}\n    >\n      {/* Its natural width where the parent sizes to its content, without forcing it on narrow screens. */}\n      <div aria-hidden=\"true\" data-natural-width className=\"grid h-0\" style={{ gridTemplateColumns: \"minmax(0, 420px)\" }} />\n      <div className=\"@container w-full\">\n        <input ref={inputRef} type=\"file\" multiple accept={accept} className=\"sr-only\" tabIndex={-1} aria-hidden=\"true\" onChange={(e) => {\n          if (e.target.files?.length) add(e.target.files);\n          e.target.value = \"\";\n        }} />\n\n        {/* The one lit surface: the drop area, which becomes the link. */}\n        <div ref={panelRef} className=\"rounded-[22px] p-2 text-primary-foreground\" style={LIT}>\n          <Faces active={link ? 1 : 0} reduced={reduced} ready={ready}>\n            <div\n              className=\"flex min-h-[132px] flex-col items-center justify-center rounded-[16px] px-4 py-5 text-center\"\n              style={{\n                boxShadow: `inset 0 0 0 1px ${over ? \"var(--primary-foreground)\" : mix(\"var(--primary-foreground)\", 30)}`,\n                background: over ? mix(\"var(--primary-foreground)\", 10) : \"transparent\",\n                transform: over && !reduced ? \"scale(0.985)\" : \"none\",\n                transition: reduced ? \"none\" : `box-shadow 200ms, background-color 200ms, transform 320ms ${EASE}`,\n              }}\n            >\n              <span className=\"flex size-10 items-center justify-center rounded-full\" style={{ background: mix(\"var(--primary-foreground)\", 14) }}>\n                <Upload aria-hidden=\"true\" className=\"size-[18px]\" style={{ transform: over && !reduced ? \"translateY(-2px)\" : \"none\", transition: `transform 300ms ${EASE}` }} />\n              </span>\n              <h2 id={`${baseId}-title`} className=\"mt-2.5 text-[15px] font-medium tracking-[-0.01em]\">\n                <TextMorph>{over ? `Release to add ${over === 1 ? \"it\" : over}` : items.length ? \"Add more files\" : \"Drop files to share\"}</TextMorph>\n              </h2>\n              <p className=\"mt-1 text-[12.5px] text-primary-foreground/70\">\n                or{\" \"}\n                <button type=\"button\" onClick={() => inputRef.current?.click()} className={`cursor-pointer rounded-full font-medium text-primary-foreground underline decoration-primary-foreground/40 underline-offset-[3px] hover:decoration-primary-foreground ${INTERACTION_FOCUS}`}>\n                  choose files\n                </button>\n                {maxSize ? ` · up to ${size(maxSize)} each` : \"\"}\n              </p>\n            </div>\n\n            <div className=\"p-2.5\">\n              <div className=\"flex items-start justify-between gap-3\">\n                <div className=\"min-w-0\">\n                  <p className=\"text-[12.5px] text-primary-foreground/70\">Ready to share</p>\n                  <p className=\"mt-0.5 text-[17px] font-medium leading-tight tracking-[-0.02em]\">\n                    {shared.length} {shared.length === 1 ? \"file\" : \"files\"} · {size(sharedSize)}\n                  </p>\n                  <p className=\"mt-1 inline-flex items-center gap-1.5 text-[12.5px] text-primary-foreground/75\">\n                    {lock && <Lock aria-hidden=\"true\" className=\"size-3\" />}\n                    Expires in {expires} {expires === 1 ? \"day\" : \"days\"}\n                    {lock ? \" · password\" : \"\"}\n                  </p>\n                </div>\n                {/* The files, fanned as the stack they folded into. */}\n                <span aria-hidden=\"true\" className=\"relative mr-2 mt-1 h-12 w-14 shrink-0\">\n                  {shared.slice(0, 3).map((it, i, all) => {\n                    const turn = (i - (all.length - 1) / 2) * 10;\n                    return (\n                      // The fan is on the outer layer, the pop on the inner one, so the keyframe never undoes the turn.\n                      <span key={it.id} className=\"absolute left-2 top-0\" style={{ transform: `rotate(${turn}deg) translateX(${turn * 0.6}px)` }}>\n                        <span\n                          className={`flex h-12 w-10 items-end justify-center rounded-[8px] pb-1 font-mono text-[8.5px] font-medium ${link?.flown ? \"\" : \"motion-safe:animate-[ui-pop-in_420ms_cubic-bezier(0.34,1.36,0.64,1)_both]\"}`}\n                          style={{ ...tint(extOf(it.file.name)), animationDelay: `${i * 70}ms`, boxShadow: `0 0 0 1px ${mix(\"var(--primary)\", 60)}` }}\n                        >\n                          {extOf(it.file.name)}\n                        </span>\n                      </span>\n                    );\n                  })}\n                </span>\n              </div>\n              <div className=\"mt-3 flex h-10 items-center gap-2 rounded-full pl-4 pr-1\" style={{ background: mix(\"black\", 14), boxShadow: `inset 0 1px 0 ${mix(\"black\", 12)}, inset 0 0 0 1px ${mix(\"var(--primary-foreground)\", 10)}` }}>\n                <span className=\"min-w-0 flex-1 truncate font-mono text-[12.5px] text-primary-foreground/90\">{link?.url.replace(/^https?:\\/\\//, \"\")}</span>\n                {link && <CopyButton text={link.url} label=\"Copy link\" copiedLabel=\"Copied\" showLabel size=\"sm\" variant=\"secondary\" style={{ background: \"var(--primary-foreground)\", color: \"var(--primary)\" }} className=\"shrink-0 hover:opacity-90\" />}\n              </div>\n              <button type=\"button\" onClick={reset} className={`mt-2 h-8 cursor-pointer rounded-full px-1 text-[12.5px] text-primary-foreground/80 hover:text-primary-foreground ${INTERACTION_FOCUS}`}>\n                Share more files\n              </button>\n            </div>\n          </Faces>\n        </div>\n\n        {/* Flat: the files on their way up, and how the link should work. */}\n        <div className=\"grid grid-cols-1\" style={{ gridTemplateRows: link || !items.length ? \"0fr\" : \"1fr\", opacity: link || !items.length ? 0 : 1, transition: reduced ? \"opacity 160ms\" : `grid-template-rows 460ms ${EASE}, opacity 300ms ${EASE}` }}>\n          <div className={`min-h-0 ${flying ? \"overflow-visible\" : \"overflow-clip\"}`} inert={Boolean(link) || !items.length || undefined}>\n            <div className=\"px-2 pt-2.5\">\n              <div className=\"flex items-baseline justify-between px-1 text-[12px] text-muted-foreground\">\n                <span>\n                  <NumberRoll value={items.length} duration={500} /> {items.length === 1 ? \"file\" : \"files\"} · {size(total)}\n                </span>\n                <span className=\"tabular-nums\">{uploading ? <><NumberRoll value={total ? Math.floor((sent / total) * 100) : 0} duration={400} />% sent</> : doneItems.length ? \"All sent\" : \"\"}</span>\n              </div>\n              <ul ref={listRef} className=\"mt-1.5\">\n                {items.map((it) => (\n                  <li key={it.id} data-file className=\"relative flex h-12 items-center gap-2.5 border-t border-border/70 px-1 first:border-t-0\">\n                    <span aria-hidden=\"true\" data-tile className=\"flex h-8 w-7 shrink-0 items-end justify-center rounded-[6px] pb-0.5 font-mono text-[8px] font-medium\" style={tint(extOf(it.file.name))}>\n                      {extOf(it.file.name)}\n                    </span>\n                    <span className=\"min-w-0 flex-1\">\n                      <span className=\"block truncate text-[13.5px] text-foreground\">{it.file.name}</span>\n                      <span className={`block text-[11.5px] tabular-nums ${it.status === \"error\" ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>\n                        {size(it.file.size)} ·{\" \"}\n                        {it.status === \"error\" ? (\n                          it.error\n                        ) : it.status === \"done\" ? (\n                          <TextMorph>Uploaded</TextMorph>\n                        ) : (\n                          <>\n                            <NumberRoll value={Math.floor(it.progress * 100)} duration={300} />%\n                          </>\n                        )}\n                      </span>\n                    </span>\n                    {it.status === \"error\" && !(maxSize && it.file.size > maxSize) && (\n                      <button type=\"button\" aria-label={`Retry ${it.file.name}`} onClick={() => upload(it)} className={`flex size-7 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>\n                        <RotateCw aria-hidden=\"true\" className=\"size-3.5\" />\n                      </button>\n                    )}\n                    <button type=\"button\" aria-label={`Remove ${it.file.name}`} disabled={busy} onClick={() => remove(it)} className={`-mr-1 flex size-7 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-40 ${INTERACTION_PRESS} ${INTERACTION_FOCUS}`}>\n                      <X aria-hidden=\"true\" className=\"size-3.5\" />\n                    </button>\n                    {/* The hairline that fills as the file goes up. */}\n                    <span aria-hidden=\"true\" className={`absolute inset-x-1 bottom-0 h-px origin-left ${it.status === \"error\" ? \"bg-red-500\" : \"bg-primary\"}`} style={{ opacity: it.status === \"done\" ? 0 : 0.8, transform: `scaleX(${it.status === \"error\" ? 1 : it.progress})`, transition: reduced ? \"opacity 200ms\" : `transform 300ms ${EASE}, opacity 400ms ${EASE} 200ms` }} />\n                  </li>\n                ))}\n              </ul>\n\n              <div className=\"mt-2.5 flex flex-col gap-2.5 border-t border-border pt-3 @[360px]:flex-row @[360px]:items-center @[360px]:justify-between\">\n                <SegmentedControl\n                  label=\"Link expires after\"\n                  size=\"sm\"\n                  value={String(expires)}\n                  onValueChange={(v) => setExpires(Number(v))}\n                  options={expiries.map((d) => ({ value: String(d), label: d === 1 ? \"1 day\" : `${d} days` }))}\n                />\n                <Switch size=\"sm\" label=\"Password\" checked={lock} onCheckedChange={(v) => setLock(v)} />\n              </div>\n              <div className=\"grid grid-cols-1\" style={{ gridTemplateRows: lock ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 360ms ${EASE}` }}>\n                <div className=\"min-h-0 overflow-clip\" inert={!lock || undefined}>\n                  <Input type=\"password\" aria-label=\"Password for the link\" placeholder=\"Password for the link\" autoComplete=\"new-password\" value={password} onValueChange={setPassword} className=\"pt-2.5\" />\n                </div>\n              </div>\n              <StatusButton\n                status={create.status}\n                labels={{ idle: uploading ? \"Uploading…\" : \"Create link\", pending: \"Creating link\", success: \"Link ready\", error: \"Try again\" }}\n                disabled={!canCreate && create.status === \"idle\"}\n                onClick={() => void createLink()}\n                className=\"mt-3 h-11 w-full text-[14.5px] disabled:opacity-50\"\n                style={create.status === \"error\" ? undefined : ACTION}\n              />\n              <p role={create.error ? \"alert\" : undefined} className={`mt-1.5 pb-1 text-center text-[12px] ${create.error ? \"text-red-600 dark:text-red-400\" : \"text-muted-foreground\"}`}>\n                {create.error ?? (lock && !password.trim() ? \"Add a password, or switch it off.\" : \"Anyone with the link can download.\")}\n              </p>\n            </div>\n          </div>\n        </div>\n\n        <span className=\"sr-only\" role=\"status\">\n          {said}\n        </span>\n      </div>\n    </section>\n  );\n}\n\nexport default FileShare;\n"},{"path":"registry/faces.tsx","type":"registry:component","content":"// Hairline UI · file-share · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\n\n/* ─────────────────────────────────────────────────────────\n * FACES: the states of one surface, in the same place\n *\n * The active face is in flow; the others are laid over it, faded\n * and nudged aside (faces before it to the left, faces after it to\n * the right), so switching reads as the surface turning to its\n * next side. The height eases between the faces' measured heights,\n * so nothing around it jumps. Used by the blocks (the wallet's\n * activity and keypad, a pricing card and its checkout, a booking's\n * times, details and confirmation).\n * ───────────────────────────────────────────────────────── */\n\nconst EASE = \"cubic-bezier(0.16,1,0.3,1)\";\nexport const FACES_MORPH = 460;\n\nexport function Faces({\n  active,\n  reduced,\n  ready,\n  className = \"\",\n  children,\n}: {\n  active: number;\n  reduced: boolean;\n  /** False until the first paint, so the faces don't animate into place on mount. */\n  ready: boolean;\n  className?: string;\n  children: React.ReactNode[];\n}) {\n  const refs = React.useRef<(HTMLDivElement | null)[]>([]);\n  const [heights, setHeights] = React.useState<number[] | null>(null);\n  const count = children.length;\n\n  React.useLayoutEffect(() => {\n    const els = refs.current.slice(0, count);\n    const observer = new ResizeObserver(() => {\n      const next = els.map((el) => el?.offsetHeight ?? 0);\n      setHeights((p) => (p && p.length === next.length && p.every((h, i) => h === next[i]) ? p : next));\n    });\n    els.forEach((el) => el && observer.observe(el));\n    return () => observer.disconnect();\n  }, [count]);\n\n  const face = (i: number): React.CSSProperties => {\n    const on = i === active;\n    return {\n      opacity: on ? 1 : 0,\n      transform: on || reduced ? \"none\" : `translateX(${i < active ? -18 : 18}px)`,\n      filter: on || reduced ? \"none\" : \"blur(4px)\",\n      transition: !ready\n        ? \"none\"\n        : reduced\n          ? \"opacity 160ms ease-out\"\n          : on\n            ? `opacity 320ms ${EASE} 90ms, transform ${FACES_MORPH}ms ${EASE}, filter 320ms ${EASE} 90ms`\n            : `opacity 140ms ease-out, transform ${FACES_MORPH}ms ${EASE}, filter 200ms ease-out`,\n    };\n  };\n\n  return (\n    <div className={`relative overflow-clip ${className}`} style={{ height: heights ? heights[active] : undefined, transition: ready && !reduced ? `height ${FACES_MORPH}ms ${EASE}` : \"none\" }}>\n      {children.map((child, i) => (\n        <div\n          key={i}\n          ref={(el) => {\n            refs.current[i] = el;\n          }}\n          inert={active !== i || undefined}\n          aria-hidden={active !== i || undefined}\n          className={active === i ? \"relative\" : \"pointer-events-none absolute inset-x-0 top-0\"}\n          style={face(i)}\n        >\n          {child}\n        </div>\n      ))}\n    </div>\n  );\n}\n"},{"path":"registry/field.tsx","type":"registry:component","content":"// Hairline UI · file-share · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n\"use client\";\n\nimport * as React from \"react\";\nimport { EASE_OUT, useInteractionReducedMotion } from \"./ui-interaction-motion\";\n\n/* ─────────────────────────────────────────────────────────\n * FIELD: the column every text control sits in\n *\n *   label    above, with an optional aside on the right (a count)\n *   hint     under the control while all is well\n *   error    takes the hint's place: the hint folds away, the\n *            error rises in out of a blur, the control shakes once\n *\n * Shared by input, textarea, password, search and select so the\n * whole family has one surface, one focus ring and one error.\n * ───────────────────────────────────────────────────────── */\n\nexport interface FieldProps {\n  id: string;\n  label?: React.ReactNode;\n  /** Right side of the label row, e.g. a character count. */\n  aside?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  required?: boolean;\n  className?: string;\n  children: React.ReactNode;\n}\n\nexport type FieldSize = \"sm\" | \"md\";\n\n/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */\nexport function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) {\n  const ring = invalid\n    ? \"shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_55%,transparent)] focus-within:shadow-[inset_0_0_0_1px_var(--color-red-500),0_0_0_3px_color-mix(in_oklab,var(--color-red-500)_14%,transparent)]\"\n    : \"shadow-[inset_0_0_0_1px_var(--border)] [&:not(:focus-within)]:hover:shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--foreground)_24%,var(--border))] focus-within:shadow-[inset_0_0_0_1px_var(--ring),0_0_0_3px_color-mix(in_oklab,var(--ring)_16%,transparent)]\";\n  return `relative flex w-full items-center bg-background text-foreground transition-[box-shadow,background-color,opacity] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] ${round ? \"rounded-full\" : \"rounded-[14px]\"} ${ring} ${\n    disabled ? \"pointer-events-none opacity-50\" : \"\"\n  }`;\n}\n\n/** ids for the control's label, hint and error. */\nexport function useFieldIds(id?: string) {\n  const auto = React.useId();\n  const base = id ?? auto;\n  return { id: base, hint: `${base}-hint`, error: `${base}-error` };\n}\n\n/** One short shake whenever `on` turns true (an error arrives). */\nexport function useShake<T extends HTMLElement>(on: boolean) {\n  const ref = React.useRef<T>(null);\n  const reduced = useInteractionReducedMotion();\n  React.useEffect(() => {\n    if (!on || reduced) return;\n    ref.current?.animate(\n      [{ transform: \"none\" }, { transform: \"translateX(-4px)\" }, { transform: \"translateX(4px)\" }, { transform: \"translateX(-2px)\" }, { transform: \"none\" }],\n      { duration: 380, easing: \"ease-out\" },\n    );\n  }, [on, reduced]);\n  return ref;\n}\n\n/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */\nexport function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) {\n  const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;\n  Object.getOwnPropertyDescriptor(proto, \"value\")?.set?.call(el, value);\n  el.dispatchEvent(new Event(\"input\", { bubbles: true }));\n}\n\n/** A line under the control that folds open and closed; keeps its last words while closing. */\nfunction Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) {\n  const [kept, setKept] = React.useState(children);\n  if (show && children !== kept) setKept(children);\n  return (\n    <div\n      className=\"grid\"\n      style={{ gridTemplateRows: show ? \"1fr\" : \"0fr\", transition: reduced ? \"none\" : `grid-template-rows 320ms ${EASE_OUT}` }}\n    >\n      <div className=\"min-h-0 overflow-hidden\">\n        <p\n          id={id}\n          className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`}\n          style={{\n            opacity: show ? 1 : 0,\n            filter: show ? \"blur(0px)\" : \"blur(4px)\",\n            transform: show ? \"none\" : \"translateY(-4px)\",\n            transition: reduced ? \"none\" : show ? `opacity 280ms ${EASE_OUT} 60ms, filter 280ms ${EASE_OUT} 60ms, transform 380ms ${EASE_OUT}` : `opacity 140ms ${EASE_OUT}, filter 140ms ${EASE_OUT}, transform 200ms ${EASE_OUT}`,\n          }}\n        >\n          {show ? children : kept}\n        </p>\n      </div>\n    </div>\n  );\n}\n\nexport function Field({ id, label, aside, hint, error, required, className = \"\", children }: FieldProps) {\n  const reduced = useInteractionReducedMotion();\n  const ids = { hint: `${id}-hint`, error: `${id}-error` };\n  return (\n    <div className={`flex w-full flex-col ${className}`}>\n      {(label || aside) && (\n        <div className=\"flex items-baseline justify-between gap-3 px-3 pb-1.5\">\n          {label && (\n            <label htmlFor={id} className=\"text-[13px] font-medium text-foreground\">\n              {label}\n              {required && (\n                <span aria-hidden=\"true\" className=\"ml-0.5 text-muted-foreground\">\n                  *\n                </span>\n              )}\n            </label>\n          )}\n          {aside && <span className=\"text-[12px] tabular-nums text-muted-foreground\">{aside}</span>}\n        </div>\n      )}\n      {children}\n      <Line show={!error && !!hint} id={ids.hint} tone=\"text-muted-foreground\" reduced={reduced}>\n        {hint}\n      </Line>\n      <Line show={!!error} id={ids.error} tone=\"text-red-600 dark:text-red-400\" reduced={reduced}>\n        {error}\n      </Line>\n    </div>\n  );\n}\n\nexport default Field;\n"},{"path":"registry/ui-interaction-motion.ts","type":"registry:component","content":"// Hairline UI · file-share · MIT License\n// Copyright (c) 2026 Hairline UI · https://hairlineui.com/license#components\n\n'use client';\n\nimport * as React from 'react';\n\nexport const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';\nexport const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';\n/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */\nexport const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';\n/**\n * The press every button primitive shares: a 3% give that eases in and settles back.\n * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list\n * or the press snaps.\n */\nexport const INTERACTION_PRESS =\n  'transition-[background-color,color,box-shadow,opacity,scale] duration-200 ease-[cubic-bezier(0.16,1,0.3,1)] active:scale-[0.97] motion-reduce:transition-none motion-reduce:active:scale-100';\nexport const INTERACTION_FOCUS ='focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background';\n\nconst query = '(prefers-reduced-motion: reduce)';\nconst subscribe = (notify: () => void) => {\n  const media = window.matchMedia(query);\n  media.addEventListener('change', notify);\n  return () => media.removeEventListener('change', notify);\n};\nexport const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);\n\nexport function useInteractionActivity<T extends Element>() {\n  const ref = React.useRef<T>(null);\n  const [visible, setVisible] = React.useState(false);\n  const [hidden, setHidden] = React.useState(false);\n  React.useEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));\n    observer.observe(el);\n    const visibility = () => setHidden(document.hidden);\n    visibility(); document.addEventListener('visibilitychange', visibility);\n    return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); };\n  }, []);\n  return { ref, active: visible && !hidden };\n}\n\nexport function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties {\n  return { opacity: on ? 1 : 0, transform: on ? 'none' : 'scale(0.6)', filter: on ? 'blur(0px)' : 'blur(3px)', transition: reduced ? 'none' : `opacity 220ms ${EASE_OUT}, transform 380ms ${EASE_OUT}, filter 220ms ${EASE_OUT}` };\n}\n\n/** Sample recent movement rather than averaging a long drag and losing its release velocity. */\nexport type PointerSample = { x: number; time: number };\nexport function samplePointer(samples: PointerSample[], x: number, time: number) {\n  samples.push({ x, time });\n  while (samples.length > 2 && time - samples[0].time > 100) samples.shift();\n}\nexport function releaseVelocity(samples: PointerSample[], now: number) {\n  if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0;\n  const first = samples[0], last = samples[samples.length - 1];\n  return (last.x - first.x) / Math.max(1, last.time - first.time);\n}\n\n/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */\nexport function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number {\n  const d = dimension || 320;\n  const abs = Math.abs(overshoot);\n  return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;\n}\n\n/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */\nexport function projectInertia(distance: number, velocity: number, time = 0.12): number {\n  return distance + velocity * 1000 * time;\n}\n\n/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */\nexport function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number {\n  const maxAllowed = Math.abs(distanceToTarget) * factor;\n  return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);\n}\n\n/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */\nexport function springFrames(\n  from: number,\n  to: number,\n  velocity = 0,\n  duration = 480,\n  config: { omega?: number; damping?: number; steps?: number } = {},\n) {\n  const { omega = 16, damping = 0.74, steps = 36 } = config;\n  const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping));\n  const displacement = from - to;\n  const coefficient = (velocity * 1000 + damping * omega * displacement) / damped;\n  return Array.from({ length: steps + 1 }, (_, i) => {\n    const t = (i / steps) * (duration / 1000);\n    const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t));\n    return { value: i === steps ? to : value, offset: i / steps };\n  });\n}\n\n"}],"css":{"@keyframes ui-col-in":{"from":{"max-width":"0","opacity":"0"},"to":{"max-width":"1em","opacity":"1"}},"@keyframes ui-col-out":{"from":{"max-width":"1em","opacity":"1"},"to":{"max-width":"0","opacity":"0"}},"@keyframes ui-pop-in":{"from":{"opacity":"0","transform":"translateY(-4px) scale(0.96)"},"to":{"opacity":"1","transform":"none"}}},"docs":"Docs and live preview: https://hairlineui.com/ui/file-share. MIT licensed: https://hairlineui.com/license#components","meta":{"license":"MIT"}}