fileUpload
A Inputs block/component from Aceternity UI — clean-room reimplemented for Domphy (see methodology). Call fileUpload() with no arguments for a working demo, or edit the code below live.
Implementation notes
Full functional port: large rounded bordered drop-zone (role=button, keyboard-activatable) with a faint repeating-linear-gradient grid pattern masked by a radial-gradient fade toward the edges; click-to-browse via a hidden native file input, drag-and-drop via a dragenter/dragleave depth counter (avoids flicker from child-boundary crossings) that also drives a scale-up + accent-outline active state through motion(); dropped/selected files render as staggered-entrance rows (filename, formatted size, MIME type, type-aware icon) with maxFiles/maxSize/accept filtering and controlled-or-uncontrolled file list support; a two-layer faintly rotated 'ghost' outline stack behind the drop-zone's front face nudges further apart on hover per the spec's tactile-stack note. One judgment call not pinned down by the spec: only two icon variants (generic document vs. image) are used for file-type glyphs rather than a large per-MIME-type icon set, since the spec only asked for 'a file-type icon' without enumerating types, a reasonable, non-overengineered default rather than a fidelity gap.
Status: ported · Reference: Aceternity UI original
// Aceternity UI "File Upload" — clean-room reimplementation from the public
// behavior/visual spec only (no upstream source viewed or copied). A large
// bordered drop-zone with a faint background grid that accepts drag-and-drop
// or click-to-browse file selection, and animates a stack of added files in.
//
// The background grid reuses this package's own `retroGrid.ts` idiom (two
// layered `repeating-linear-gradient`s standing in for tiled grid lines,
// resolved through `themeColor()` — not a literal color), with a
// `radial-gradient` `mask-image` fading it out toward the container's edges
// per the spec's own "soft mask" note. Drag state uses a manual enter/leave
// depth counter (rather than trusting a single `dragleave`, which also fires
// when the pointer crosses a child element's boundary) so the active/hover
// state doesn't flicker while dragging across the zone's own content. Newly
// added rows enter via `motion()` with a per-index delay for the spec's
// "cascades in one row at a time" stagger. The "ghost stack" hinting more
// files can be added is two static, faintly rotated outline rectangles
// behind the drop-zone's own front face, nudged further apart on hover via a
// `motion()` `State<MotionKeyframe>` re-animate (this package's
// `layoutTextFlip.ts` badge-width idiom, applied to `rotate`/`x` instead).
import type { DomphyElement, ElementNode, Listener, State, StyleObject } from "@domphy/core";
import { toState } from "@domphy/core";
import { empty, heading, motion, paragraph, small } from "@domphy/ui";
import type { MotionKeyframe } from "@domphy/ui";
import { themeColor, themeDensity, themeSpacing } from "@domphy/theme";
export interface FileUploadProps {
/** Fires with the full current file list whenever a selection changes. */
onChange?: (files: File[]) => void;
/** Alias for {@link FileUploadProps.onChange}, matching the spec's own naming. */
onFilesSelected?: (files: File[]) => void;
/** Allows selecting/dropping more than one file at once. Defaults to `true`. */
multiple?: boolean;
/** Native `accept` filter, e.g. `"image/*"` or `".pdf,.docx"`. */
accept?: string;
/** Maximum number of files kept — extra files (beyond this count) are dropped. */
maxFiles?: number;
/** Maximum size per file, in bytes — oversized files are silently excluded. */
maxSize?: number;
/** Externally-managed file list to render instead of the component's own internal state. */
files?: File[];
/** Extra class name merged onto the outer wrapper's native `class` attribute. */
className?: string;
/** Passthrough style merged onto the drop-zone box. */
style?: StyleObject;
}
const GRID_CELL_PX = 28;
const ROW_STAGGER_MS = 60;
// Visually-hidden but screen-reader-visible label text, matching
// `canvasText.ts`'s own `SR_ONLY_STYLE` idiom.
const SR_ONLY_STYLE = {
position: "absolute",
width: "1px",
height: "1px",
padding: "0",
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
border: "0",
} as const;
let fileUploadInstanceCounter = 0;
function formatFileSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value >= 10 ? Math.round(value) : Math.round(value * 10) / 10} ${units[unitIndex]}`;
}
/** Loose `accept` matcher covering MIME wildcards (`image/*`), exact MIME types, and file extensions (`.pdf`). */
function matchesAccept(file: File, accept: string | undefined): boolean {
if (!accept) return true;
const patterns = accept
.split(",")
.map((entry) => entry.trim())
.filter(Boolean);
if (patterns.length === 0) return true;
return patterns.some((pattern) => {
if (pattern.startsWith(".")) return file.name.toLowerCase().endsWith(pattern.toLowerCase());
if (pattern.endsWith("/*")) return file.type.startsWith(pattern.slice(0, -1));
return file.type === pattern;
});
}
/** Cloud-with-upward-arrow glyph, hand-composed — not traced from any icon library. */
function uploadCloudGlyph(): DomphyElement<"span"> {
return {
span: [
{
svg: [
{
path: null,
d: "M7 18a4 4 0 0 1-.6-7.96A5 5 0 0 1 16.5 8.05 4.5 4.5 0 0 1 16 17H7Z",
fill: "none",
stroke: "currentColor",
strokeWidth: "1.5",
strokeLinejoin: "round",
} as DomphyElement,
{
path: null,
d: "M12 11v7M9 14l3-3 3 3",
fill: "none",
stroke: "currentColor",
strokeWidth: "1.5",
strokeLinecap: "round",
strokeLinejoin: "round",
} as DomphyElement,
],
viewBox: "0 0 24 24",
fill: "none",
role: "img",
ariaHidden: "true",
style: { width: "100%", height: "100%" },
} as DomphyElement<"svg">,
],
style: { display: "inline-flex", width: themeSpacing(12), height: themeSpacing(12) } as StyleObject,
};
}
/** Generic document glyph (folded corner), hand-composed — not traced from any icon library. */
function genericFileGlyph(): DomphyElement<"svg"> {
return {
svg: [
{ path: null, d: "M6 2h9l5 5v15H6Z", fill: "none", stroke: "currentColor", strokeWidth: "1.3" } as DomphyElement,
{ path: null, d: "M15 2v5h5", fill: "none", stroke: "currentColor", strokeWidth: "1.3" } as DomphyElement,
],
viewBox: "0 0 24 24",
fill: "none",
role: "img",
ariaHidden: "true",
style: { width: "100%", height: "100%" },
} as DomphyElement<"svg">;
}
/** Generic image glyph (frame + mountain silhouette), hand-composed — not traced from any icon library. */
function imageFileGlyph(): DomphyElement<"svg"> {
return {
svg: [
{ rect: null, x: "3", y: "4", width: "18", height: "16", rx: "2", fill: "none", stroke: "currentColor", strokeWidth: "1.3" } as DomphyElement,
{ circle: null, cx: "8.5", cy: "9.5", r: "1.4", fill: "currentColor" } as DomphyElement,
{
path: null,
d: "M4 17l5-5 4 4 3-3 4 4",
fill: "none",
stroke: "currentColor",
strokeWidth: "1.3",
strokeLinecap: "round",
strokeLinejoin: "round",
} as DomphyElement,
],
viewBox: "0 0 24 24",
fill: "none",
role: "img",
ariaHidden: "true",
style: { width: "100%", height: "100%" },
} as DomphyElement<"svg">;
}
/** Two layered `repeating-linear-gradient`s (horizontal + vertical hairlines) standing in for a tiled grid-line background image. */
function buildGridBackgroundImage(lineColor: string): string {
// Zero-length gradient stops are written unitless ("0", not "0px") — CSS
// allows a bare zero for any `<length>`, and stylelint's own
// `length-zero-no-unit` rule (part of this package's doctor Layer 4 audit)
// flags the unit otherwise.
const vertical = `repeating-linear-gradient(90deg, ${lineColor} 0, ${lineColor} 1px, transparent 1px, transparent ${GRID_CELL_PX}px)`;
const horizontal = `repeating-linear-gradient(0deg, ${lineColor} 0, ${lineColor} 1px, transparent 1px, transparent ${GRID_CELL_PX}px)`;
return `${vertical}, ${horizontal}`;
}
interface FileEntry {
key: string;
file: File;
}
function fileRow(entry: FileEntry, index: number): DomphyElement<"div"> {
const isImage = entry.file.type.startsWith("image/");
return {
div: [
{
span: [isImage ? imageFileGlyph() : genericFileGlyph()],
style: {
flexShrink: 0,
width: themeSpacing(6),
height: themeSpacing(6),
color: (listener: Listener) => themeColor(listener, "shift-8", "neutral"),
} as StyleObject,
},
{
div: [
{ p: entry.file.name, $: [paragraph({ color: "neutral" })], style: { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } as StyleObject },
{ small: `${formatFileSize(entry.file.size)}${entry.file.type ? ` · ${entry.file.type}` : ""}`, $: [small({ color: "neutral" })] },
],
style: { display: "flex", flexDirection: "column", gap: themeSpacing(0.5), minWidth: 0, flex: "1 1 auto" } as StyleObject,
},
],
_key: entry.key,
style: {
display: "flex",
alignItems: "center",
gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
borderRadius: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
backgroundColor: (listener: Listener) => themeColor(listener, "shift-1", "neutral"),
color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3", "neutral")}`,
outlineOffset: "-1px",
} as StyleObject,
$: [
motion({
initial: { opacity: 0, y: "0.75em" },
animate: { opacity: 1, y: 0 },
transition: { duration: 260, delay: index * ROW_STAGGER_MS, easing: "ease-out" },
}),
],
};
}
/** One faint rotated outline rectangle standing behind the drop-zone's front face, part of the "more files can be added" ghost stack. */
function ghostCard(depthIndex: number, nudge: State<MotionKeyframe>): DomphyElement<"div"> {
return {
div: null,
ariaHidden: "true",
// Purely decorative outline shape with no text of its own — never reads
// its own `color`, so the missing-color contract doesn't apply.
// `_doctorDisable` is a doctor-only annotation not present in core's
// strict `PartialElement` type — build through an untyped literal, then
// assert (mirrors `pixelatedCanvas.ts`).
_doctorDisable: "missing-color",
style: {
position: "absolute",
inset: 0,
borderRadius: (listener: Listener) => themeSpacing(themeDensity(listener) * 6),
outline: (listener: Listener) => `1px solid ${themeColor(listener, "shift-3", "neutral")}`,
outlineOffset: "-1px",
opacity: 0.4 - depthIndex * 0.12,
zIndex: -depthIndex,
} as StyleObject,
$: [motion({ animate: nudge, transition: { duration: 220, easing: "ease-out" } })],
} as unknown as DomphyElement<"div">;
}
/**
* A large bordered drop-zone with a faint background grid that accepts
* drag-and-drop or click-to-browse file selection, animating added files in
* as a cascading list. Call with no arguments for a working empty demo.
*/
function fileUpload(props: FileUploadProps = {}): DomphyElement<"div"> {
const instanceId = ++fileUploadInstanceCounter;
const inputId = `domphy-file-upload-${instanceId}`;
const multiple = props.multiple ?? true;
const accept = props.accept;
const maxFiles = props.maxFiles;
const maxSize = props.maxSize;
const onChange = props.onChange ?? props.onFilesSelected;
const isControlled = props.files !== undefined;
const files = toState<File[]>(props.files ?? []);
const isDraggingOver = toState(false);
const ghostNudgeFar = toState<MotionKeyframe>({ y: "0px", rotate: "-6deg" });
const ghostNudgeNear = toState<MotionKeyframe>({ y: "0px", rotate: "-3deg" });
let fileInputElement: HTMLInputElement | null = null;
let dropZoneDomElement: HTMLElement | null = null;
let dragDepth = 0;
function commitFiles(nextFiles: File[]): void {
if (!isControlled) files.set(nextFiles);
onChange?.(nextFiles);
}
function addFiles(candidateFiles: File[]): void {
const accepted = candidateFiles.filter((file) => {
if (!matchesAccept(file, accept)) return false;
if (typeof maxSize === "number" && file.size > maxSize) return false;
return true;
});
if (accepted.length === 0) return;
const existing = files.get();
const merged = multiple ? [...existing, ...accepted] : accepted.slice(0, 1);
const limited = typeof maxFiles === "number" ? merged.slice(0, maxFiles) : merged;
commitFiles(limited);
}
function openFilePicker(): void {
fileInputElement?.click();
}
const srOnlyLabel: DomphyElement<"label"> = {
label: "Upload files",
for: inputId,
style: SR_ONLY_STYLE as StyleObject,
};
const hiddenFileInput: DomphyElement<"input"> = {
input: null,
type: "file",
id: inputId,
multiple,
accept,
ariaHidden: "true",
tabindex: -1,
onChange: (event: Event) => {
const selected = Array.from((event.target as HTMLInputElement).files ?? []);
addFiles(selected);
(event.target as HTMLInputElement).value = "";
},
_onMount: (node: ElementNode) => {
fileInputElement = node.domElement as HTMLInputElement;
},
style: {
position: "absolute",
width: 0,
height: 0,
opacity: 0,
overflow: "hidden",
pointerEvents: "none",
} as StyleObject,
};
const gridPatternLayer = {
div: null,
ariaHidden: "true",
// `_doctorDisable` is a doctor-only annotation not present in core's
// strict `PartialElement` type — build through an untyped literal, then
// assert (mirrors `pixelatedCanvas.ts`).
_doctorDisable: "missing-color",
style: {
position: "absolute",
inset: 0,
pointerEvents: "none",
backgroundImage: (listener: Listener) => buildGridBackgroundImage(themeColor(listener, "shift-3", "neutral")),
backgroundSize: `${GRID_CELL_PX}px ${GRID_CELL_PX}px`,
maskImage: "radial-gradient(ellipse at center, black 40%, transparent 85%)",
WebkitMaskImage: "radial-gradient(ellipse at center, black 40%, transparent 85%)",
} as StyleObject,
} as unknown as DomphyElement<"div">;
const centeredContent: DomphyElement<"div"> = {
div: [
uploadCloudGlyph(),
{ h3: "Drag & drop files here", $: [heading()] },
{ small: "or click anywhere in this box to browse", $: [small({ color: "neutral" })] },
],
style: { position: "relative", zIndex: 1 } as StyleObject,
$: [empty()],
};
const fileListContainer: DomphyElement<"div"> = {
div: (listener: Listener) => files.get(listener).map((file, index) => fileRow({ key: `${file.name}-${file.size}-${file.lastModified}-${index}`, file }, index)),
style: {
position: "relative",
zIndex: 1,
display: "flex",
flexDirection: "column",
gap: (listener: Listener) => themeSpacing(themeDensity(listener) * 2),
width: "100%",
maxWidth: themeSpacing(140),
marginTop: themeSpacing(4),
paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 6),
paddingBottom: (listener: Listener) => themeSpacing(themeDensity(listener) * 6),
} as StyleObject,
};
// Reactive drag-hover scale nudge — a dedicated `State<MotionKeyframe>`
// written alongside `isDraggingOver` in the drag handlers below, and
// consumed by the `motion()` patch on `dropZone` itself.
const dropZoneScale = toState<MotionKeyframe>({ scale: 1 });
const dropZone: DomphyElement<"div"> = {
div: [srOnlyLabel, hiddenFileInput, gridPatternLayer, centeredContent, fileListContainer],
role: "button",
tabindex: 0,
ariaLabel: "Upload files",
onClick: openFilePicker,
onKeyDown: (event: KeyboardEvent) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openFilePicker();
}
},
_onMount: (node: ElementNode) => {
dropZoneDomElement = node.domElement as HTMLElement;
if (typeof window === "undefined") return;
const handleDragEnter = (event: DragEvent) => {
event.preventDefault();
dragDepth += 1;
isDraggingOver.set(true);
dropZoneScale.set({ scale: 1.02 });
};
const handleDragOver = (event: DragEvent) => {
event.preventDefault();
};
const handleDragLeave = (event: DragEvent) => {
event.preventDefault();
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) {
isDraggingOver.set(false);
dropZoneScale.set({ scale: 1 });
}
};
const handleDrop = (event: DragEvent) => {
event.preventDefault();
dragDepth = 0;
isDraggingOver.set(false);
dropZoneScale.set({ scale: 1 });
const dropped = Array.from(event.dataTransfer?.files ?? []);
addFiles(dropped);
};
dropZoneDomElement.addEventListener("dragenter", handleDragEnter);
dropZoneDomElement.addEventListener("dragover", handleDragOver);
dropZoneDomElement.addEventListener("dragleave", handleDragLeave);
dropZoneDomElement.addEventListener("drop", handleDrop);
node.addHook("Remove", () => {
dropZoneDomElement?.removeEventListener("dragenter", handleDragEnter);
dropZoneDomElement?.removeEventListener("dragover", handleDragOver);
dropZoneDomElement?.removeEventListener("dragleave", handleDragLeave);
dropZoneDomElement?.removeEventListener("drop", handleDrop);
});
},
style: {
position: "relative",
zIndex: 1,
overflow: "hidden",
cursor: "pointer",
display: "flex",
flexDirection: "column",
alignItems: "center",
borderRadius: (listener: Listener) => themeSpacing(themeDensity(listener) * 6),
outline: (listener: Listener) =>
`1px dashed ${themeColor(listener, isDraggingOver.get(listener) ? "shift-8" : "shift-4", isDraggingOver.get(listener) ? "primary" : "neutral")}`,
outlineOffset: "-1px",
backgroundColor: (listener: Listener) => themeColor(listener, "inherit", "neutral"),
color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
transition: "outline-color 180ms ease",
...(props.style ?? {}),
} as StyleObject,
$: [motion({ animate: dropZoneScale, transition: { duration: 200, easing: "ease-out" } })],
};
return {
div: [ghostCard(2, ghostNudgeFar), ghostCard(1, ghostNudgeNear), dropZone],
class: props.className,
onPointerEnter: () => {
ghostNudgeFar.set({ y: "-6px", rotate: "-9deg" });
ghostNudgeNear.set({ y: "-4px", rotate: "-5deg" });
},
onPointerLeave: () => {
ghostNudgeFar.set({ y: "0px", rotate: "-6deg" });
ghostNudgeNear.set({ y: "0px", rotate: "-3deg" });
},
style: {
position: "relative",
width: "100%",
paddingBlock: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
paddingInline: (listener: Listener) => themeSpacing(themeDensity(listener) * 3),
} as StyleObject,
};
}
export { fileUpload };