File shareNew
Drop files, watch them upload, and they fold into one share link with an expiry and an optional password.
Used forSend filesDeliverablesSupport uploadsInternal drops
Install with the shadcn CLI
$ npxnpx shadcn@latest add https://hairlineui.com/r/file-share.jsonTemplates
Need the whole page?
43 Next.js landing pages, each designed from scratch. Try any of them live; one is $39, all of them $99, and 5 are free.
Where it’s used
Send files
A WeTransfer-style page for clients to send or receive large files.
Deliverables
An agency or freelancer handing over a project’s files with an expiry.
Support uploads
Customers sharing logs or recordings with a password.
Internal drops
A quick way to share builds or exports inside a team.
What it handles for you
A share page is usually a drop zone, an upload queue, a settings form and a link dialog, each with its own state. This block carries the files from drop to link on one surface, against your own storage.
- 01
Upload on drop
Each file starts as it’s added, with progress from onUpload’s callback and an abort signal for removal.
- 02
Failures you can retry
A failed upload keeps its row with the reason and a Retry; files over maxSize say so and never upload.
- 03
Totals as they go
The file count and the share sent roll while everything uploads.
- 04
Expiry and password
A segmented choice of days and a password switch that opens its field; both reach onCreateLink.
- 05
Files into a link
Create link sends the rows flying into the panel, which becomes the link card with Copy.
- 06
Add from anywhere
A ref with add(files) lets a paste handler or your own button add files.
Overview
Most share screens are an upload field, a list with spinners and a link in a modal at the end. Here the lit panel is the drop area: drag files over it and its edge brightens and says how many will land. Each file uploads the moment it’s added, its row filling with a hairline as the percent rolls; a dropped connection offers Retry and removing a file aborts its upload. Choose how long the link lives and switch on a password. Create link goes Creating link → the rows lift and fly up into the panel, which turns into the link card: the URL with Copy, what’s in it, when it expires, and the files fanned as the stack they folded into. Storage is yours: onUpload sends one file with progress and an abort signal, onCreateLink makes the link.
States
- empty
- The lit panel with a hairline drop area: an upload glyph, “Drop files to share” and choose files.
- drag
- Files over the block brighten the area’s edge, settle it to 0.985× and morph the line to “Release to add 3”.
- uploading
- Rows with a type tile, name, size and a rolling percent over a hairline that fills; the header rolls “3 files · 55 MB” and the share sent.
- retry
- A failed upload shows the reason in red with a Retry button; removing a file aborts it.
- options
- Expires after 1, 7 or 30 days, and a Password switch whose field folds open; Create link waits for a password once it’s on.
- link
- Create link → Creating link → the rows arc up into the panel (560ms, 70ms apart) and the panel turns to the link: count and size, expiry, the URL with Copy, and the files fanned as tiles.
Usage
import { FileShare } from "@/components/file-share";<FileShare onUpload={(file, { onProgress, signal }) => uploadTo(storage, file, onProgress, signal)} onCreateLink={({ files, expiresIn, password }) => api.createShare(files, expiresIn, password)}/>Depends on lucide-react, installed for you by the CLI along with the keyframes it needs.
Works with
Installs with it
- Copy buttonCopies on the press: the icon blurs into a self-drawing check, the label morphs to Copied, and a failed copy shakes and says so.
- InputA one-line field with a label, hint and error that trade places, a clear button that folds in, and one shake when an error arrives.
- Number rollNumbers that change like an odometer: every digit rolls in the direction the number moved.
- Segmented controlOne of a few options side by side: the thumb is thrown to the new segment while its width eases to the new label.
- Status buttonSave, Saving, Saved: the spinner opens in, the check draws itself, errors shake, and the label morphs between them.
- SwitchA switch with weight: the thumb stretches on press, is thrown across with a slight overshoot, and spins while an async save settles.
- Text morphText that changes the way it should: shared letters slide over, new ones rise out of a blur, the width eases.
With presigned uploads
Ask your server for a presigned URL per file and PUT it with XMLHttpRequest, which reports progress; then make the link from the keys. Works with S3, R2, GCS or anything that signs URLs.
"use client";import { FileShare } from "@/components/file-share";function put(url: string, file: File, onProgress: (f: number) => void, signal: AbortSignal) { return new Promise<void>((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open("PUT", url); xhr.upload.onprogress = (e) => e.lengthComputable && onProgress(e.loaded / e.total); xhr.onload = () => (xhr.status < 300 ? resolve() : reject(new Error("Upload failed"))); xhr.onerror = () => reject(new Error("Connection dropped")); signal.addEventListener("abort", () => xhr.abort()); xhr.send(file); });}export function ShareFiles() { return ( <FileShare maxSize={2_000_000_000} onUpload={async (file, { onProgress, signal }) => { const { url, key } = await (await fetch("/api/uploads", { method: "POST", body: JSON.stringify({ name: file.name, type: file.type }) })).json(); await put(url, file, onProgress, signal); return key; }} onCreateLink={async ({ files, expiresIn, password }) => { const res = await fetch("/api/shares", { method: "POST", body: JSON.stringify({ keys: files.map((f) => f.result), expiresIn, password }) }); if (!res.ok) throw new Error("Couldn’t make the link."); return res.json(); // { url } }} /> );}Props
| Prop | Type | Description |
|---|---|---|
| onUpload | (file, { onProgress, signal }) => Promise<unknown> | Upload one file; call onProgress(0–1), honour signal, resolve with what you need later (a key). Throw to offer Retry. |
| onCreateLink | ({ files, expiresIn, password? }) => Promise<{ url }> | files carry { name, size, type, result }; expiresIn is in days. |
| accept / maxSize / maxFiles | string / number / numberdefault — / — / 20 | File types, bytes per file and how many files. |
| expiries | number[]default [1, 7, 30] | Days the link can live, as a segmented choice. |
| ref | Ref<FileShareHandle> | { add(files) } to add files from a paste handler or your own button. |
Accessibility and motion
- Choose files is a real button that opens the file picker; dropping works anywhere on the block.
- Each row names its file and status; added files and the ready link are announced from a status region, and errors are text beside the file.
- Removing a file aborts its upload through the signal; nothing is sent twice.
- The flight is the rows themselves animated up before they fold away; nothing is cloned and focus stays put.
- Its light comes from your theme: the panel mixes --primary-foreground into --primary, file tiles take tints from --chart-1 to --chart-5. No drop shadows, glow or blur.
- Built from Segmented control, Switch, Input, Copy button, Number roll, Status button and Text morph, which the CLI installs with it. No animation library; with reduced motion nothing flies and the panel changes in place.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.
The CLI also installs Copy button, Input, Number roll, Segmented control, Status button, Switch, Text morph.
Source
"use client";import * as React from "react";import { Lock, RotateCw, Upload, X } from "lucide-react";import { CopyButton } from "./copy-button";import { Faces } from "./faces";import { Input } from "./input";import { NumberRoll } from "./number-roll";import { SegmentedControl } from "./segmented-control";import { StatusButton, type ActionStatus } from "./status-button";import { Switch } from "./switch";import { TextMorph } from "./text-morph";import { INTERACTION_FOCUS, INTERACTION_PRESS, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * FILE SHARE: files that fold into one link * * drop the lit panel is the drop area; dragging files over * it brightens its edge and says how many will land * upload each file uploads as soon as it's added, its row * filling with a hairline and rolling its percent; * a failed one offers Retry, a removed one is aborted * options how long the link lives, and an optional password * link Create link goes Creating → the rows fly up into the * panel, which turns into the link card: the URL with * Copy, what's in it, and the files fanned as a stack * * Storage is yours: onUpload sends one file (report progress and * honour the signal), onCreateLink makes the link from what your * uploads returned. Throw an Error and its message shows in place. * ───────────────────────────────────────────────────────── */export interface FileShareUploadContext { /** Report progress from 0 to 1. */ onProgress: (fraction: number) => void; /** Aborted when the file is removed. */ signal: AbortSignal;}export interface FileShareFile { name: string; size: number; type: string; /** Whatever onUpload resolved with for this file, e.g. its storage key. */ result: unknown;}export interface FileShareRequest { files: FileShareFile[]; /** Days the link lives. */ expiresIn: number; password?: string;}export interface FileShareHandle { /** Add files from anywhere: a paste handler, a button of your own. */ add: (files: File[]) => void;}export interface FileShareProps { /** Upload one file; resolve with what you'll need to make the link (a key, an id). */ onUpload: (file: File, context: FileShareUploadContext) => Promise<unknown>; /** Make the link; resolve with its URL. */ onCreateLink: (share: FileShareRequest) => Promise<{ url: string }>; /** Native input syntax, e.g. "image/*,.pdf". */ accept?: string; /** Bytes per file. */ maxSize?: number; maxFiles?: number; /** Choices for how many days the link lives. */ expiries?: number[]; ref?: React.Ref<FileShareHandle>; className?: string;}type Item = { id: string; file: File; progress: number; status: "uploading" | "done" | "error"; error?: string; result?: unknown; controller?: AbortController };const EASE = "cubic-bezier(0.16,1,0.3,1)";const EXPIRIES = [1, 7, 30];const mix = (color: string, amount: number, into = "transparent") => `color-mix(in oklab, ${color} ${amount}%, ${into})`;const LIT: React.CSSProperties = { backgroundColor: "var(--primary)", 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%)`, boxShadow: `inset 0 1px 0 ${mix("var(--primary-foreground)", 36)}, inset 0 0 0 1px ${mix("var(--primary-foreground)", 12)}`,};const CARD: React.CSSProperties = { backgroundImage: `linear-gradient(180deg, var(--card), ${mix("var(--card)", 95, "var(--foreground)")})`, boxShadow: `0 0 0 1px var(--border), inset 0 1px 0 ${mix("var(--background)", 85)}`,};const ACTION: React.CSSProperties = { backgroundImage: `linear-gradient(180deg, ${mix("var(--primary-foreground)", 16)}, transparent 60%)`, boxShadow: `inset 0 1px 0 ${mix("var(--primary-foreground)", 32)}, inset 0 0 0 1px ${mix("black", 10)}`,};const extOf = (name: string) => (name.includes(".") ? name.split(".").pop()!.slice(0, 4).toUpperCase() : "FILE");function tint(seed: string): React.CSSProperties { let h = 0; for (const c of seed) h = (h * 31 + c.charCodeAt(0)) >>> 0; const chart = `var(--chart-${(h % 5) + 1}, var(--primary))`; return { background: mix(chart, 20, "var(--card)"), color: mix(chart, 75, "var(--foreground)"), boxShadow: `inset 0 0 0 1px ${mix(chart, 26)}` };}function size(bytes: number) { const units = ["B", "KB", "MB", "GB"]; let n = bytes; let u = 0; while (n >= 1000 && u < units.length - 1) { n /= 1000; u++; } return `${n >= 10 || u === 0 ? Math.round(n) : n.toFixed(1)} ${units[u]}`;}let seq = 0;export function FileShare({ onUpload, onCreateLink, accept, maxSize, maxFiles = 20, expiries = EXPIRIES, ref, className = "" }: FileShareProps) { const reduced = useInteractionReducedMotion(); const baseId = React.useId(); const [items, setItems] = React.useState<Item[]>([]); const [over, setOver] = React.useState(0); const [expires, setExpires] = React.useState(expiries[Math.min(1, expiries.length - 1)] ?? 7); const [lock, setLock] = React.useState(false); const [password, setPassword] = React.useState(""); const [create, setCreate] = React.useState<{ status: ActionStatus; error?: string }>({ status: "idle" }); const [link, setLink] = React.useState<{ url: string; files: Item[]; flown?: boolean } | null>(null); // While the rows fly up, the list stops clipping so they can leave it. const [flying, setFlying] = React.useState(false); const [ready, setReady] = React.useState(false); const [said, setSaid] = React.useState(""); const inputRef = React.useRef<HTMLInputElement>(null); const sectionRef = React.useRef<HTMLElement>(null); const panelRef = React.useRef<HTMLDivElement>(null); const listRef = React.useRef<HTMLUListElement>(null); const depth = React.useRef(0); React.useEffect(() => { const frame = requestAnimationFrame(() => setReady(true)); return () => cancelAnimationFrame(frame); }, []); const patch = (id: string, next: Partial<Item>) => setItems((list) => list.map((i) => (i.id === id ? { ...i, ...next } : i))); const upload = (item: Item) => { const controller = new AbortController(); patch(item.id, { status: "uploading", progress: 0, error: undefined, controller }); onUpload(item.file, { onProgress: (f) => patch(item.id, { progress: Math.max(0, Math.min(1, f)) }), signal: controller.signal }) .then((result) => { if (!controller.signal.aborted) patch(item.id, { status: "done", progress: 1, result }); }) .catch((e) => { if (!controller.signal.aborted) patch(item.id, { status: "error", error: e instanceof Error && e.message ? e.message : "Upload failed" }); }); }; const add = (files: FileList | File[]) => { if (link) return; const room = Math.max(0, maxFiles - items.length); const next: Item[] = [...files].slice(0, room).map((file) => ({ id: `f${++seq}`, file, progress: 0, status: "uploading" as const })); for (const item of next) { if (maxSize && item.file.size > maxSize) { item.status = "error"; item.error = `Over ${size(maxSize)}`; } } setItems((list) => [...list, ...next]); setCreate({ status: "idle" }); setSaid(`${next.length} ${next.length === 1 ? "file" : "files"} added.`); next.filter((i) => i.status !== "error").forEach(upload); }; React.useImperativeHandle(ref, () => ({ add: (files: File[]) => add(files) })); const remove = (item: Item) => { item.controller?.abort(); setItems((list) => list.filter((i) => i.id !== item.id)); }; const doneItems = items.filter((i) => i.status === "done"); const uploading = items.some((i) => i.status === "uploading"); const total = items.reduce((n, i) => n + i.file.size, 0); const sent = items.reduce((n, i) => n + i.file.size * (i.status === "done" ? 1 : i.progress), 0); const canCreate = doneItems.length > 0 && !uploading && (!lock || password.trim().length > 0); const busy = create.status === "pending" || create.status === "success"; const createLink = async () => { if (!canCreate || busy) return; setCreate({ status: "pending" }); try { const { url } = await onCreateLink({ files: doneItems.map((i) => ({ name: i.file.name, size: i.file.size, type: i.file.type, result: i.result })), expiresIn: expires, password: lock && password.trim() ? password.trim() : undefined, }); setCreate({ status: "success" }); setSaid(`Link ready: ${url}`); // Each file's tile leaps into the corner where the link's stack appears and lands as one of its cards, // while the rows fold away; then the panel turns into the link under tiles already in place. const section = sectionRef.current; const panel = panelRef.current?.getBoundingClientRect(); const rows = [...(listRef.current?.querySelectorAll<HTMLElement>("[data-file]") ?? [])]; const flights: HTMLElement[] = []; if (section && panel && rows.length && !reduced) { setFlying(true); const box = section.getBoundingClientRect(); // Screen px to the block's own px, so the tiles land true inside a scaled or zoomed container too. const k = box.width / (section.offsetWidth || box.width) || 1; // The stack's centre on the link face: the panel's top-right corner, inside its padding. const fan = { x: panel.right - 54 * k, y: panel.top + 46 * k }; const shown = rows.slice(0, 3); shown.forEach((row, i) => { row.animate([{ opacity: 1, transform: "none" }, { opacity: 0, transform: "translateY(-6px)" }], { duration: 260, delay: 120 + i * 90, easing: EASE, fill: "forwards" }); const tile = row.querySelector<HTMLElement>("[data-tile]"); if (!tile) return; const r = tile.getBoundingClientRect(); const copy = tile.cloneNode(true) as HTMLElement; 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" }); section.appendChild(copy); flights.push(copy); const turn = (i - (shown.length - 1) / 2) * 10; const dx = (fan.x - (r.left + r.width / 2)) / k + turn * 0.6; const dy = (fan.y - (r.top + r.height / 2)) / k; const grow = 40 / (r.width / k); copy.animate( [ { transform: "translate(0px, 0px) rotate(0deg) scale(1)" }, { transform: `translate(${dx * 0.42}px, ${dy * 0.58 - 28}px) rotate(${turn * 0.5 - 6}deg) scale(${(1 + grow) / 2})`, offset: 0.55 }, { transform: `translate(${dx}px, ${dy}px) rotate(${turn}deg) scale(${grow})` }, ], { duration: 640, delay: i * 90, easing: "cubic-bezier(0.45,0,0.2,1)", fill: "forwards" }, ); }); } const landed = reduced || !flights.length ? 300 : 560 + (flights.length - 1) * 90; window.setTimeout(() => { setLink({ url, files: doneItems, flown: flights.length > 0 }); setFlying(false); // The real stack is under the flown tiles now; they hand over and go. flights.forEach((f) => (f.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 220, delay: 260, fill: "forwards" }).onfinish = () => f.remove())); }, landed); } catch (e) { setCreate({ status: "error", error: e instanceof Error && e.message ? e.message : "Couldn’t make the link. Try again." }); } }; const reset = () => { setItems([]); setLink(null); setCreate({ status: "idle" }); setPassword(""); setLock(false); }; const shared = link?.files ?? []; const sharedSize = shared.reduce((n, i) => n + i.file.size, 0); return ( <section ref={sectionRef} aria-labelledby={`${baseId}-title`} className={`relative w-full max-w-[420px] rounded-[28px] p-2 text-card-foreground ${className}`} style={CARD} onDragEnter={(e) => { if (link || !e.dataTransfer.types.includes("Files")) return; e.preventDefault(); depth.current++; setOver(e.dataTransfer.items.length || 1); }} onDragOver={(e) => { if (link || !e.dataTransfer.types.includes("Files")) return; e.preventDefault(); e.dataTransfer.dropEffect = "copy"; }} onDragLeave={() => { depth.current = Math.max(0, depth.current - 1); if (!depth.current) setOver(0); }} onDrop={(e) => { if (link) return; e.preventDefault(); depth.current = 0; setOver(0); add(e.dataTransfer.files); }} > {/* Its natural width where the parent sizes to its content, without forcing it on narrow screens. */} <div aria-hidden="true" data-natural-width className="grid h-0" style={{ gridTemplateColumns: "minmax(0, 420px)" }} /> <div className="@container w-full"> <input ref={inputRef} type="file" multiple accept={accept} className="sr-only" tabIndex={-1} aria-hidden="true" onChange={(e) => { if (e.target.files?.length) add(e.target.files); e.target.value = ""; }} /> {/* The one lit surface: the drop area, which becomes the link. */} <div ref={panelRef} className="rounded-[22px] p-2 text-primary-foreground" style={LIT}> <Faces active={link ? 1 : 0} reduced={reduced} ready={ready}> <div className="flex min-h-[132px] flex-col items-center justify-center rounded-[16px] px-4 py-5 text-center" style={{ boxShadow: `inset 0 0 0 1px ${over ? "var(--primary-foreground)" : mix("var(--primary-foreground)", 30)}`, background: over ? mix("var(--primary-foreground)", 10) : "transparent", transform: over && !reduced ? "scale(0.985)" : "none", transition: reduced ? "none" : `box-shadow 200ms, background-color 200ms, transform 320ms ${EASE}`, }} > <span className="flex size-10 items-center justify-center rounded-full" style={{ background: mix("var(--primary-foreground)", 14) }}> <Upload aria-hidden="true" className="size-[18px]" style={{ transform: over && !reduced ? "translateY(-2px)" : "none", transition: `transform 300ms ${EASE}` }} /> </span> <h2 id={`${baseId}-title`} className="mt-2.5 text-[15px] font-medium tracking-[-0.01em]"> <TextMorph>{over ? `Release to add ${over === 1 ? "it" : over}` : items.length ? "Add more files" : "Drop files to share"}</TextMorph> </h2> <p className="mt-1 text-[12.5px] text-primary-foreground/70"> or{" "} <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}`}> choose files </button> {maxSize ? ` · up to ${size(maxSize)} each` : ""} </p> </div> <div className="p-2.5"> <div className="flex items-start justify-between gap-3"> <div className="min-w-0"> <p className="text-[12.5px] text-primary-foreground/70">Ready to share</p> <p className="mt-0.5 text-[17px] font-medium leading-tight tracking-[-0.02em]"> {shared.length} {shared.length === 1 ? "file" : "files"} · {size(sharedSize)} </p> <p className="mt-1 inline-flex items-center gap-1.5 text-[12.5px] text-primary-foreground/75"> {lock && <Lock aria-hidden="true" className="size-3" />} Expires in {expires} {expires === 1 ? "day" : "days"} {lock ? " · password" : ""} </p> </div> {/* The files, fanned as the stack they folded into. */} <span aria-hidden="true" className="relative mr-2 mt-1 h-12 w-14 shrink-0"> {shared.slice(0, 3).map((it, i, all) => { const turn = (i - (all.length - 1) / 2) * 10; return ( // The fan is on the outer layer, the pop on the inner one, so the keyframe never undoes the turn. <span key={it.id} className="absolute left-2 top-0" style={{ transform: `rotate(${turn}deg) translateX(${turn * 0.6}px)` }}> <span 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]"}`} style={{ ...tint(extOf(it.file.name)), animationDelay: `${i * 70}ms`, boxShadow: `0 0 0 1px ${mix("var(--primary)", 60)}` }} > {extOf(it.file.name)} </span> </span> ); })} </span> </div> <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)}` }}> <span className="min-w-0 flex-1 truncate font-mono text-[12.5px] text-primary-foreground/90">{link?.url.replace(/^https?:\/\//, "")}</span> {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" />} </div> <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}`}> Share more files </button> </div> </Faces> </div> {/* Flat: the files on their way up, and how the link should work. */} <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}` }}> <div className={`min-h-0 ${flying ? "overflow-visible" : "overflow-clip"}`} inert={Boolean(link) || !items.length || undefined}> <div className="px-2 pt-2.5"> <div className="flex items-baseline justify-between px-1 text-[12px] text-muted-foreground"> <span> <NumberRoll value={items.length} duration={500} /> {items.length === 1 ? "file" : "files"} · {size(total)} </span> <span className="tabular-nums">{uploading ? <><NumberRoll value={total ? Math.floor((sent / total) * 100) : 0} duration={400} />% sent</> : doneItems.length ? "All sent" : ""}</span> </div> <ul ref={listRef} className="mt-1.5"> {items.map((it) => ( <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"> <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))}> {extOf(it.file.name)} </span> <span className="min-w-0 flex-1"> <span className="block truncate text-[13.5px] text-foreground">{it.file.name}</span> <span className={`block text-[11.5px] tabular-nums ${it.status === "error" ? "text-red-600 dark:text-red-400" : "text-muted-foreground"}`}> {size(it.file.size)} ·{" "} {it.status === "error" ? ( it.error ) : it.status === "done" ? ( <TextMorph>Uploaded</TextMorph> ) : ( <> <NumberRoll value={Math.floor(it.progress * 100)} duration={300} />% </> )} </span> </span> {it.status === "error" && !(maxSize && it.file.size > maxSize) && ( <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}`}> <RotateCw aria-hidden="true" className="size-3.5" /> </button> )} <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}`}> <X aria-hidden="true" className="size-3.5" /> </button> {/* The hairline that fills as the file goes up. */} <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` }} /> </li> ))} </ul> <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"> <SegmentedControl label="Link expires after" size="sm" value={String(expires)} onValueChange={(v) => setExpires(Number(v))} options={expiries.map((d) => ({ value: String(d), label: d === 1 ? "1 day" : `${d} days` }))} /> <Switch size="sm" label="Password" checked={lock} onCheckedChange={(v) => setLock(v)} /> </div> <div className="grid grid-cols-1" style={{ gridTemplateRows: lock ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 360ms ${EASE}` }}> <div className="min-h-0 overflow-clip" inert={!lock || undefined}> <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" /> </div> </div> <StatusButton status={create.status} labels={{ idle: uploading ? "Uploading…" : "Create link", pending: "Creating link", success: "Link ready", error: "Try again" }} disabled={!canCreate && create.status === "idle"} onClick={() => void createLink()} className="mt-3 h-11 w-full text-[14.5px] disabled:opacity-50" style={create.status === "error" ? undefined : ACTION} /> <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"}`}> {create.error ?? (lock && !password.trim() ? "Add a password, or switch it off." : "Anyone with the link can download.")} </p> </div> </div> </div> <span className="sr-only" role="status"> {said} </span> </div> </section> );}export default FileShare;"use client";import * as React from "react";/* ───────────────────────────────────────────────────────── * FACES: the states of one surface, in the same place * * The active face is in flow; the others are laid over it, faded * and nudged aside (faces before it to the left, faces after it to * the right), so switching reads as the surface turning to its * next side. The height eases between the faces' measured heights, * so nothing around it jumps. Used by the blocks (the wallet's * activity and keypad, a pricing card and its checkout, a booking's * times, details and confirmation). * ───────────────────────────────────────────────────────── */const EASE = "cubic-bezier(0.16,1,0.3,1)";export const FACES_MORPH = 460;export function Faces({ active, reduced, ready, className = "", children,}: { active: number; reduced: boolean; /** False until the first paint, so the faces don't animate into place on mount. */ ready: boolean; className?: string; children: React.ReactNode[];}) { const refs = React.useRef<(HTMLDivElement | null)[]>([]); const [heights, setHeights] = React.useState<number[] | null>(null); const count = children.length; React.useLayoutEffect(() => { const els = refs.current.slice(0, count); const observer = new ResizeObserver(() => { const next = els.map((el) => el?.offsetHeight ?? 0); setHeights((p) => (p && p.length === next.length && p.every((h, i) => h === next[i]) ? p : next)); }); els.forEach((el) => el && observer.observe(el)); return () => observer.disconnect(); }, [count]); const face = (i: number): React.CSSProperties => { const on = i === active; return { opacity: on ? 1 : 0, transform: on || reduced ? "none" : `translateX(${i < active ? -18 : 18}px)`, filter: on || reduced ? "none" : "blur(4px)", transition: !ready ? "none" : reduced ? "opacity 160ms ease-out" : on ? `opacity 320ms ${EASE} 90ms, transform ${FACES_MORPH}ms ${EASE}, filter 320ms ${EASE} 90ms` : `opacity 140ms ease-out, transform ${FACES_MORPH}ms ${EASE}, filter 200ms ease-out`, }; }; return ( <div className={`relative overflow-clip ${className}`} style={{ height: heights ? heights[active] : undefined, transition: ready && !reduced ? `height ${FACES_MORPH}ms ${EASE}` : "none" }}> {children.map((child, i) => ( <div key={i} ref={(el) => { refs.current[i] = el; }} inert={active !== i || undefined} aria-hidden={active !== i || undefined} className={active === i ? "relative" : "pointer-events-none absolute inset-x-0 top-0"} style={face(i)} > {child} </div> ))} </div> );}"use client";import * as React from "react";import { EASE_OUT, useInteractionReducedMotion } from "./ui-interaction-motion";/* ───────────────────────────────────────────────────────── * FIELD: the column every text control sits in * * label above, with an optional aside on the right (a count) * hint under the control while all is well * error takes the hint's place: the hint folds away, the * error rises in out of a blur, the control shakes once * * Shared by input, textarea, password, search and select so the * whole family has one surface, one focus ring and one error. * ───────────────────────────────────────────────────────── */export interface FieldProps { id: string; label?: React.ReactNode; /** Right side of the label row, e.g. a character count. */ aside?: React.ReactNode; hint?: React.ReactNode; error?: React.ReactNode; required?: boolean; className?: string; children: React.ReactNode;}export type FieldSize = "sm" | "md";/** The surface of a text control: a hairline that darkens on hover and turns to the ring on focus. */export function fieldSurface({ invalid, disabled, round = true }: { invalid?: boolean; disabled?: boolean; round?: boolean }) { const ring = invalid ? "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)]" : "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)]"; 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} ${ disabled ? "pointer-events-none opacity-50" : "" }`;}/** ids for the control's label, hint and error. */export function useFieldIds(id?: string) { const auto = React.useId(); const base = id ?? auto; return { id: base, hint: `${base}-hint`, error: `${base}-error` };}/** One short shake whenever `on` turns true (an error arrives). */export function useShake<T extends HTMLElement>(on: boolean) { const ref = React.useRef<T>(null); const reduced = useInteractionReducedMotion(); React.useEffect(() => { if (!on || reduced) return; ref.current?.animate( [{ transform: "none" }, { transform: "translateX(-4px)" }, { transform: "translateX(4px)" }, { transform: "translateX(-2px)" }, { transform: "none" }], { duration: 380, easing: "ease-out" }, ); }, [on, reduced]); return ref;}/** Sets a native input's value so React's onChange still fires (clear buttons, pickers). */export function setNativeValue(el: HTMLInputElement | HTMLTextAreaElement, value: string) { const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; Object.getOwnPropertyDescriptor(proto, "value")?.set?.call(el, value); el.dispatchEvent(new Event("input", { bubbles: true }));}/** A line under the control that folds open and closed; keeps its last words while closing. */function Line({ show, children, id, tone, reduced }: { show: boolean; children: React.ReactNode; id: string; tone: string; reduced: boolean }) { const [kept, setKept] = React.useState(children); if (show && children !== kept) setKept(children); return ( <div className="grid" style={{ gridTemplateRows: show ? "1fr" : "0fr", transition: reduced ? "none" : `grid-template-rows 320ms ${EASE_OUT}` }} > <div className="min-h-0 overflow-hidden"> <p id={id} className={`pt-1.5 pl-3 text-[12.5px] leading-snug ${tone}`} style={{ opacity: show ? 1 : 0, filter: show ? "blur(0px)" : "blur(4px)", transform: show ? "none" : "translateY(-4px)", 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}`, }} > {show ? children : kept} </p> </div> </div> );}export function Field({ id, label, aside, hint, error, required, className = "", children }: FieldProps) { const reduced = useInteractionReducedMotion(); const ids = { hint: `${id}-hint`, error: `${id}-error` }; return ( <div className={`flex w-full flex-col ${className}`}> {(label || aside) && ( <div className="flex items-baseline justify-between gap-3 px-3 pb-1.5"> {label && ( <label htmlFor={id} className="text-[13px] font-medium text-foreground"> {label} {required && ( <span aria-hidden="true" className="ml-0.5 text-muted-foreground"> * </span> )} </label> )} {aside && <span className="text-[12px] tabular-nums text-muted-foreground">{aside}</span>} </div> )} {children} <Line show={!error && !!hint} id={ids.hint} tone="text-muted-foreground" reduced={reduced}> {hint} </Line> <Line show={!!error} id={ids.error} tone="text-red-600 dark:text-red-400" reduced={reduced}> {error} </Line> </div> );}export default Field;'use client';import * as React from 'react';export const EASE_OUT = 'cubic-bezier(0.16, 1, 0.3, 1)';export const EASE_SETTLE = 'cubic-bezier(0.22, 1.18, 0.36, 1)';/** Thrown things (indicators, thumbs): a slight overshoot on transform only. */export const EASE_THROW = 'cubic-bezier(0.34, 1.36, 0.64, 1)';/** * The press every button primitive shares: a 3% give that eases in and settles back. * Tailwind v4's scale-* writes the `scale` property, so `scale` has to be in the list * or the press snaps. */export const INTERACTION_PRESS = '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';export 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';const query = '(prefers-reduced-motion: reduce)';const subscribe = (notify: () => void) => { const media = window.matchMedia(query); media.addEventListener('change', notify); return () => media.removeEventListener('change', notify);};export const useInteractionReducedMotion = () => React.useSyncExternalStore(subscribe, () => window.matchMedia(query).matches, () => false);export function useInteractionActivity<T extends Element>() { const ref = React.useRef<T>(null); const [visible, setVisible] = React.useState(false); const [hidden, setHidden] = React.useState(false); React.useEffect(() => { const el = ref.current; if (!el) return; const observer = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting)); observer.observe(el); const visibility = () => setHidden(document.hidden); visibility(); document.addEventListener('visibilitychange', visibility); return () => { observer.disconnect(); document.removeEventListener('visibilitychange', visibility); }; }, []); return { ref, active: visible && !hidden };}export function interactionIcon(on: boolean, reduced: boolean): React.CSSProperties { 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}` };}/** Sample recent movement rather than averaging a long drag and losing its release velocity. */export type PointerSample = { x: number; time: number };export function samplePointer(samples: PointerSample[], x: number, time: number) { samples.push({ x, time }); while (samples.length > 2 && time - samples[0].time > 100) samples.shift();}export function releaseVelocity(samples: PointerSample[], now: number) { if (samples.length < 2 || now - samples[samples.length - 1].time > 100) return 0; const first = samples[0], last = samples[samples.length - 1]; return (last.x - first.x) / Math.max(1, last.time - first.time);}/** Smooth Apple/rational rubber-band curve: continuous exponential-like resistance without linear kinks. */export function rubberBand(overshoot: number, dimension = 320, constant = 0.55): number { const d = dimension || 320; const abs = Math.abs(overshoot); return Math.sign(overshoot) * (1 - 1 / ((abs * constant) / d + 1)) * d;}/** Projects resting travel from release velocity (velocity in px/ms; time in seconds). */export function projectInertia(distance: number, velocity: number, time = 0.12): number { return distance + velocity * 1000 * time;}/** Smooths incoming release velocity toward a boundary so springs don't slam or clip into walls. */export function softenVelocity(velocity: number, distanceToTarget: number, factor = 10): number { const maxAllowed = Math.abs(distanceToTarget) * factor; return Math.sign(velocity) * Math.min(Math.abs(velocity), maxAllowed);}/** A damped spring sampled by WAAPI: tuned for organic lightness with a hint of life (3% breath). */export function springFrames( from: number, to: number, velocity = 0, duration = 480, config: { omega?: number; damping?: number; steps?: number } = {},) { const { omega = 16, damping = 0.74, steps = 36 } = config; const damped = omega * Math.sqrt(Math.max(0.001, 1 - damping * damping)); const displacement = from - to; const coefficient = (velocity * 1000 + damping * omega * displacement) / damped; return Array.from({ length: steps + 1 }, (_, i) => { const t = (i / steps) * (duration / 1000); const value = to + Math.exp(-damping * omega * t) * (displacement * Math.cos(damped * t) + coefficient * Math.sin(damped * t)); return { value: i === steps ? to : value, offset: i / steps }; });}Registry item: /r/file-share.json


