A file upload input rendered as a labelled dropzone surface with a centered icon, title, helper description, and a styled trigger affordance. The whole surface is clickable to open the native file picker and accepts drag-and-drop. Drag state and selected files are owned by the shared core package, so Astro and Svelte adapters consume the same headless logic.
The full flow: a dropzone, an “import from a link” row, and an UploadList linked to the dropzone with for="<input id>". Pass an upload(file, { onProgress, signal }) adapter and the engine runs the queue — progress, time left, retry on failure, abort on remove, three uploads at a time (concurrency). The link row is plain Input + Button composition: fetch the remote file however your app does (usually server-side, because of CORS) and hand the result to engine.addEntries().
---
// GENERATED from packages/demos/input-upload/dialog.demo.tsx — edit the source, not this file.
import InputUpload from "@pindoba/astro-input-upload";
import { UploadList } from "@pindoba/astro-input-upload";
import Card from "@pindoba/astro-card";
import Button from "@pindoba/astro-button";
import { InputRoot, InputField } from "@pindoba/astro-input";
import Affix from "@pindoba/astro-affix";
import { css } from "@pindoba/styled-system/css";
import { flex, stack } from "@pindoba/styled-system/patterns";
import type { UploadEntry } from "@pindoba/core-input-upload";
const sectionLabel = css({ textStyle: "label.sm", color: "panel.text" });
const entries = [] as UploadEntry[];
const url = "";
const uploading = entries.some(
(e) => e.status === "queued" || e.status === "uploading",
);
const stored = entries.filter((e) => e.status === "success");
---
<div
class={css({ maxWidth: "48rem", width: "100%" })}
data-ir-root="input-upload/dialog"
>
<Card
header={{
heading: "Project files",
subheading: "Share briefs, footage and assets with the team",
}}
footer={{}}
data-ir="1"
>
<div slot="footer-trailing" class={flex({ gap: "sm" })}>
<Button emphasis="secondary" data-ir="6">
Clear
</Button>
<Button disabled={uploading || stored.length === 0} data-ir="7">
Save to project
<Fragment set:html="<!--ir:8-->" />
{stored.length ? ` (${stored.length})` : ""}
<Fragment set:html="<!--/ir-->" />
</Button>
</div>
<div class={stack({ gap: "lg", direction: "column" })}>
<InputUpload
id="astro-demo-dialog-upload"
multiple
maxSize={256 * 1024 * 1024}
title="Drop files here"
description="Any type, up to 256 MB each"
trigger="Choose files"
data-ir="2"
/>
<div class={stack({ gap: "xs", direction: "column" })}>
<span class={sectionLabel}>Import from a link</span>
<InputRoot>
<InputField
type="url"
placeholder="https://"
aria-label="Link to a file"
data-ir="3"
/>
<Affix side="end">
<Button size="sm" disabled={!url.trim()} data-ir="4">
Import
</Button>
</Affix>
</InputRoot>
</div>
<!--ir:5-->
{entries.length > 0 && (
<div class={stack({ gap: "xs", direction: "column" })}>
<span class={sectionLabel}>In this project</span>
<UploadList for="astro-demo-dialog-upload" />
</div>
)}
<!--/ir-->
</div>
</Card>
</div>
<script>
import { hydrate } from "./_client/dialog.ts";
document
.querySelectorAll('[data-ir-root="input-upload/dialog"]')
.forEach((root) => hydrate([root]));
</script>UploadList layout="grid" renders square thumbnail tiles. Put a layout="tile" dropzone in its append slot to get the gallery’s “add” tile. columns pins a column count; without it tiles auto-fill. Stored files are seeded with initialEntries and preview from their url.
---
// GENERATED from packages/demos/input-upload/photo-grid.demo.tsx — edit the source, not this file.
import InputUpload from "@pindoba/astro-input-upload";
import { UploadList } from "@pindoba/astro-input-upload";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const initialEntries = [
{
name: "mountains.jpg",
type: "image/jpeg",
size: 2_400_000,
url: "https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=480&q=60",
},
{
name: "lake.jpg",
type: "image/jpeg",
size: 1_800_000,
url: "https://images.unsplash.com/photo-1501785888041-af3ef285b470?w=480&q=60",
},
];
---
<div
class={stack({ gap: "xl", direction: "column", width: "100%" })}
data-ir-root="input-upload/photo-grid"
>
<div class={stack({ gap: "xs", direction: "column" })}>
<span class={css({ textStyle: "label.sm" })}>Gallery</span>
<UploadList for="astro-demo-photo-grid" layout="grid">
<InputUpload
slot="append"
id="astro-demo-photo-grid"
layout="tile"
accept="image/*"
multiple
maxFiles={8}
initialEntries={initialEntries}
title="Add photos"
data-ir="1"
/>
</UploadList>
</div>
<div class={stack({ gap: "xs", direction: "column" })}>
<span class={css({ textStyle: "label.sm" })}>
Fixed columns, compact density
</span>
<UploadList
for="astro-demo-photo-grid-compact"
layout="grid"
density="compact"
columns={6}
>
<InputUpload
slot="append"
id="astro-demo-photo-grid-compact"
layout="tile"
size="xs"
accept="image/*"
multiple
initialEntries={initialEntries}
title="Add"
data-ir="2"
/>
</UploadList>
</div>
</div>
<script>
import { hydrate } from "./_client/photo-grid.ts";
document
.querySelectorAll('[data-ir-root="input-upload/photo-grid"]')
.forEach((root) => hydrate([root]));
</script>layout="avatar" is a single-image picker: it shows the current image (picked or stored), a progress ring while it uploads, and a remove button. A new pick replaces the old one. shape is circle (default) or rounded.
---
// GENERATED from packages/demos/input-upload/avatar.demo.tsx — edit the source, not this file.
import InputUpload from "@pindoba/astro-input-upload";
import { css } from "@pindoba/styled-system/css";
import { flex, stack } from "@pindoba/styled-system/patterns";
const sizes = ["xs", "sm", "md", "lg"] as const;
const existing = [
{
name: "portrait.jpg",
type: "image/jpeg",
size: 320_000,
url: "https://images.unsplash.com/photo-1494790108377-be9c29b29330?w=320&q=60",
},
];
const label = css({ textStyle: "label.sm", color: "panel.text.muted" });
---
<div
class={stack({ gap: "xl", direction: "column" })}
data-ir-root="input-upload/avatar"
>
<div class={stack({ gap: "xs", direction: "column" })}>
<span class={label}>Circle (default) — every size</span>
<div class={flex({ gap: "lg", align: "end", wrap: "wrap" })}>
<!--ir:1-->
{sizes.map((size) => (
<InputUpload
layout="avatar"
size={size}
accept="image/*"
title="Add photo"
data-ir="2"
data-ir-key={size}
/>
))}
<!--/ir-->
</div>
</div>
<div class={stack({ gap: "xs", direction: "column" })}>
<span class={label}>Rounded, with an existing photo</span>
<div class={flex({ gap: "lg", align: "end", wrap: "wrap" })}>
<InputUpload
layout="avatar"
shape="rounded"
size="lg"
accept="image/*"
initialEntries={existing}
title="Add cover"
data-ir="3"
/>
<InputUpload
layout="avatar"
size="lg"
accept="image/*"
initialEntries={existing}
title="Add photo"
data-ir="4"
/>
</div>
</div>
</div>
<script>
import { hydrate } from "./_client/avatar.ts";
document
.querySelectorAll('[data-ir-root="input-upload/avatar"]')
.forEach((root) => hydrate([root]));
</script>layout="inline" turns the dropzone into a single control row, as tall as an Input of the same size. UploadList density="compact" puts each file’s name and status on one line, with a small ring while it uploads.
---
// GENERATED from packages/demos/input-upload/compact.demo.tsx — edit the source, not this file.
import InputUpload from "@pindoba/astro-input-upload";
import { UploadList } from "@pindoba/astro-input-upload";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const sizes = ["xs", "sm", "md", "lg"] as const;
const label = css({ textStyle: "label.sm", color: "panel.text.muted" });
---
<div
class={stack({
gap: "xl",
direction: "column",
width: "100%",
maxWidth: "56rem",
})}
data-ir-root="input-upload/compact"
>
<div class={stack({ gap: "xs", direction: "column" })}>
<span class={label}>Inline dropzone + compact rows</span>
<InputUpload
id="astro-demo-compact"
layout="inline"
size="sm"
multiple
title="Drop files or"
description="PDF, images up to 10 MB"
trigger="Browse"
data-ir="1"
/>
<UploadList for="astro-demo-compact" density="compact" size="sm" />
</div>
<div class={stack({ gap: "xs", direction: "column" })}>
<span class={label}>Inline sizes (row heights match Input)</span>
<!--ir:2-->
{sizes.map((size) => (
<InputUpload
layout="inline"
size={size}
title="Attach a file"
trigger="Browse"
data-ir="3"
data-ir-key={size}
/>
))}
<!--/ir-->
</div>
</div>
<script>
import { hydrate } from "./_client/compact.ts";
document
.querySelectorAll('[data-ir-root="input-upload/compact"]')
.forEach((root) => hydrate([root]));
</script>dropTarget makes another element — or the whole page with "document" — accept dropped files into the dropzone’s engine. Only drags that carry files react. UploadDropOverlay covers the target while files are dragged over it (placement="fixed" for the page, "absolute" for a positioned container).
Drag files anywhere over this panel.
---
// GENERATED from packages/demos/input-upload/drop-anywhere.demo.tsx — edit the source, not this file.
import InputUpload from "@pindoba/astro-input-upload";
import { UploadList, UploadDropOverlay } from "@pindoba/astro-input-upload";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
---
<div
class={css({
position: "relative",
width: "100%",
minHeight: "28rem",
padding: "lg",
borderRadius: "lg",
borderWidth: "1px",
borderColor: "neutral.border.muted",
background: "neutral.surface.hill",
})}
data-ir-root="input-upload/drop-anywhere"
data-ir="1"
>
<div class={stack({ gap: "md", direction: "column" })}>
<p class={css({ margin: 0, color: "neutral.text.muted" })}>
Drag files anywhere over this panel.
</p>
<InputUpload
id="astro-demo-drop-anywhere"
layout="inline"
multiple
title="Or pick files"
trigger="Browse"
data-ir="2"
/>
<UploadList for="astro-demo-drop-anywhere" density="compact" />
</div>
<UploadDropOverlay
for="astro-demo-drop-anywhere"
placement="absolute"
labels={{ description: "They'll upload right away" }}
/>
</div>
<script>
import { hydrate } from "./_client/drop-anywhere.ts";
document
.querySelectorAll('[data-ir-root="input-upload/drop-anywhere"]')
.forEach((root) => hydrate([root]));
</script>UploadList is controlled: hand it entries and handle onRemove / onRetry yourself when an SDK (S3 multipart, tus, …) already tracks upload state.
---
// GENERATED from packages/demos/input-upload/controlled.demo.tsx — edit the source, not this file.
import { UploadList } from "@pindoba/astro-input-upload";
import { css } from "@pindoba/styled-system/css";
import type { UploadEntry } from "@pindoba/core-input-upload";
const base = { loaded: 0, timeLeft: null };
const entries = [
{
...base,
id: "a",
name: "quarterly-plan.pdf",
type: "application/pdf",
size: 21_180_000,
status: "uploading",
progress: 32,
timeLeft: 5,
},
{
...base,
id: "b",
name: "standup-recording.mov",
type: "video/quicktime",
size: 228_200_000,
status: "uploading",
progress: 67,
timeLeft: 24,
},
{
...base,
id: "c",
name: "brand-guidelines.docx",
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
size: 1_250_000,
status: "success",
progress: 100,
},
{
...base,
id: "d",
name: "hero-banner.png",
type: "image/png",
size: 4_800_000,
status: "error",
progress: 40,
error: "Connection lost",
file: new File([], "hero-banner.png", { type: "image/png" }),
},
] as UploadEntry[];
---
<div
class={css({ width: "100%", maxWidth: "48rem" })}
data-ir-root="input-upload/controlled"
>
<UploadList entries={entries} data-ir="1" />
</div>
<script>
import { hydrate } from "./_client/controlled.ts";
document
.querySelectorAll('[data-ir-root="input-upload/controlled"]')
.forEach((root) => hydrate([root]));
</script>Apply semantic tone colors to indicate validation state or context (for example, a red dropzone after rejecting an oversized file).
---
// GENERATED from packages/demos/input-upload/tone.demo.tsx — edit the source, not this file.
import InputUpload from "@pindoba/astro-input-upload";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", direction: "column" })}>
<InputUpload tone="neutral" description="JPEG, PNG, PDF up to 50 MB" />
<InputUpload tone="primary" description="JPEG, PNG, PDF up to 50 MB" />
<InputUpload tone="success" description="JPEG, PNG, PDF up to 50 MB" />
<InputUpload tone="danger" description="JPEG, PNG, PDF up to 50 MB" />
<InputUpload tone="warning" description="JPEG, PNG, PDF up to 50 MB" />
</div>Four sizes scale the dropzone’s min-height, padding, icon, and typography together.
---
// GENERATED from packages/demos/input-upload/size.demo.tsx — edit the source, not this file.
import InputUpload from "@pindoba/astro-input-upload";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", direction: "column" })}>
<InputUpload size="xs" description="Extra small" />
<InputUpload size="sm" description="Small" />
<InputUpload size="md" description="Medium" />
<InputUpload size="lg" description="Large" />
</div>Override any slot — icon, title, description, or trigger — to customize the dropzone content. Pass strings via the matching props (title, description, trigger) for simple text overrides, or use named slots / snippets for rich content.
---
// GENERATED from packages/demos/input-upload/slots.demo.tsx — edit the source, not this file.
import InputUpload from "@pindoba/astro-input-upload";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3>Custom icon</h3>
<InputUpload accept="image/*" description="JPEG or PNG, up to 10 MB">
<svg
slot="icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2"></rect>
<circle cx="9" cy="9" r="2"></circle>
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"></path>
</svg>
</InputUpload>
</div>
<div>
<h3>Rich title and description</h3>
<InputUpload accept=".pdf,.doc,.docx" multiple>
<Fragment slot="title">
Drop <em>contracts</em> here
</Fragment>
<Fragment slot="description">
Accepts <code>.pdf</code>, <code>.doc</code>, <code>.docx</code>
</Fragment>
</InputUpload>
</div>
<div>
<h3>Custom trigger label</h3>
<InputUpload
title="Upload your résumé"
description="One PDF, 5 MB max"
trigger="Pick a PDF"
accept="application/pdf"
/>
</div>
</div>The passThrough prop provides per-slot escape hatches: style accepts a Panda CSS SystemStyleObject and props forwards arbitrary HTML attributes onto the slot element.
---
// GENERATED from packages/demos/input-upload/custom.demo.tsx — edit the source, not this file.
import InputUpload from "@pindoba/astro-input-upload";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "xl", direction: "column" })}>
<div>
<h3>Pill-shaped surface</h3>
<InputUpload
title="Drop here"
description="JPEG or PNG up to 5 MB"
passThrough={{
root: { style: { borderRadius: "full" } },
}}
/>
</div>
<div>
<h3>Solid border + accented icon</h3>
<InputUpload
title="Upload your resume"
description="PDF only, 10 MB max"
trigger="Pick a file"
passThrough={{
root: { style: { borderStyle: "solid" } },
icon: { style: { color: "primary.text" } },
}}
/>
</div>
<div>
<h3>Accessibility props</h3>
<InputUpload
title="Drop your avatar"
passThrough={{
input: {
props: {
"aria-label": "Upload avatar",
"data-testid": "avatar-upload",
},
},
}}
/>
</div>
</div>For compact entry points where a full dropzone is overkill, use the sibling <UploadButton> component. It renders a real Pindoba <Button> that opens the file picker on click and still accepts drag-and-drop on its surface. Forward emphasis and tone to pick the button look — they’re passed straight through to the underlying button.
---
// GENERATED from packages/demos/input-upload/upload-button.demo.tsx — edit the source, not this file.
import { UploadButton } from "@pindoba/astro-input-upload";
import { stack } from "@pindoba/styled-system/patterns";
---
<div class={stack({ gap: "md", direction: "column", align: "flex-start" })}>
<UploadButton emphasis="primary" label="Upload" />
<UploadButton emphasis="secondary" label="Upload" />
<UploadButton emphasis="ghost" label="Upload" />
<UploadButton emphasis="primary" tone="danger" label="Upload" />
<UploadButton emphasis="secondary" label="Upload (max 1 KB)" maxSize={1024} />
</div>UploadButton takes the same dropTarget as the dropzone: "document", a CSS selector or an element accepts dropped files into the button’s engine — same validation, entries, autoUpload and onEntriesChange as a pick from the button. That’s the file-browser pattern: an “Upload” button in the toolbar and drops anywhere on the file list. Give the button an id and point an UploadDropOverlay (and an UploadList) at it with for.
Files
Drop files anywhere on this panel, or use the Upload button.
---
// GENERATED from packages/demos/input-upload/upload-button-drop-target.demo.tsx — edit the source, not this file.
import {
UploadButton,
UploadList,
UploadDropOverlay,
} from "@pindoba/astro-input-upload";
import { css } from "@pindoba/styled-system/css";
import { flex, stack } from "@pindoba/styled-system/patterns";
---
<div
class={css({
position: "relative",
width: "100%",
minHeight: "24rem",
padding: "lg",
borderRadius: "lg",
borderWidth: "1px",
borderColor: "neutral.border.muted",
background: "neutral.surface.hill",
})}
data-ir-root="input-upload/upload-button-drop-target"
data-ir="1"
>
<div class={stack({ gap: "md", direction: "column" })}>
<div class={flex({ justify: "space-between", align: "center" })}>
<p class={css({ margin: 0, fontWeight: "semibold" })}>Files</p>
<UploadButton
id="astro-demo-upload-button-drop-target"
emphasis="primary"
multiple
label="Upload"
data-ir="2"
/>
</div>
<p class={css({ margin: 0, color: "neutral.text.muted" })}>
Drop files anywhere on this panel, or use the Upload button.
</p>
<UploadList for="astro-demo-upload-button-drop-target" density="compact" />
</div>
<UploadDropOverlay
for="astro-demo-upload-button-drop-target"
placement="absolute"
labels={{ description: "They'll upload right away" }}
/>
</div>
<script>
import { hydrate } from "./_client/upload-button-drop-target.ts";
document
.querySelectorAll('[data-ir-root="input-upload/upload-button-drop-target"]')
.forEach((root) => hydrate([root]));
</script>The headless engine (addEntries, removeEntry, retry, start, …) is output-only state, and each framework exposes it in its own idiom:
engineRef.engineRef, a callback that receives the engine.onEngine(engine), called once at setup; the same handle is on the component ref as engine.bind:engine.getInputUpload(root) from @pindoba/astro-input-upload/runtime; the engine is also registered under the input id.Built-in validation rejects files that exceed maxSize, surpass maxFiles, fail imageDimensions checks, or are flagged by a custom validate function. Rejected files are reported via onReject (Svelte) or a pindoba:reject CustomEvent (Astro). Combine with tone="danger" to indicate the error state.
In Astro, custom function-based validators are registered on window.pindobaValidators and referenced by name through the validatorName prop, since functions can’t be passed through frontmatter to inline scripts.
---
// GENERATED from packages/demos/input-upload/validation.demo.tsx — edit the source, not this file.
import InputUpload from "@pindoba/astro-input-upload";
import { css } from "@pindoba/styled-system/css";
import { stack } from "@pindoba/styled-system/patterns";
const errorText = css({ color: "danger.text", marginTop: "8px" });
const sizeError = null as string | null;
const countError = null as string | null;
const imageError = null as string | null;
const customError = null as string | null;
---
<div
class={stack({ gap: "xl", direction: "column" })}
data-ir-root="input-upload/validation"
>
<div>
<h3>Max file size</h3>
<InputUpload
accept="image/*,application/pdf"
description="Up to 2 MB"
maxSize={2 * 1024 * 1024}
tone={sizeError ? "danger" : "neutral"}
data-ir="1"
/>
<!--ir:2-->
{sizeError && (
<p class={errorText}>
<!--ir:3-->
{sizeError}
<!--/ir-->
</p>
)}
<!--/ir-->
</div>
<div>
<h3>Max file count</h3>
<InputUpload
multiple
description="Up to 2 files"
maxFiles={2}
tone={countError ? "danger" : "neutral"}
data-ir="4"
/>
<!--ir:5-->
{countError && (
<p class={errorText}>
<!--ir:6-->
{countError}
<!--/ir-->
</p>
)}
<!--/ir-->
</div>
<div>
<h3>Image dimensions</h3>
<InputUpload
accept="image/*"
description="Square images, 200×200 to 2000×2000"
imageDimensions={{
minWidth: 200,
maxWidth: 2000,
minHeight: 200,
maxHeight: 2000,
aspectRatio: 1,
aspectRatioTolerance: 0.05,
}}
tone={imageError ? "danger" : "neutral"}
data-ir="7"
/>
<!--ir:8-->
{imageError && (
<p class={errorText}>
<!--ir:9-->
{imageError}
<!--/ir-->
</p>
)}
<!--/ir-->
</div>
<div>
<h3>Custom validator</h3>
<InputUpload
description="Filename cannot contain spaces"
validate={(file) => (/\s/.test(file.name) ? "Filename has spaces" : null)}
tone={customError ? "danger" : "neutral"}
data-ir="10"
/>
<!--ir:11-->
{customError && (
<p class={errorText}>
<!--ir:12-->
{customError}
<!--/ir-->
</p>
)}
<!--/ir-->
</div>
</div>
<script>
import { hydrate } from "./_client/validation.ts";
document
.querySelectorAll('[data-ir-root="input-upload/validation"]')
.forEach((root) => hydrate([root]));
</script>