component

Input Upload

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.

Feedback

Apply semantic feedback colors to indicate validation state or context (for example, a red dropzone after rejecting an oversized file).

---
import InputUpload from "../InputUpload.astro";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md", direction: "column" })}>
  <InputUpload feedback="neutral" description="JPEG, PNG, PDF up to 50 MB" />
  <InputUpload feedback="primary" description="JPEG, PNG, PDF up to 50 MB" />
  <InputUpload feedback="success" description="JPEG, PNG, PDF up to 50 MB" />
  <InputUpload feedback="danger" description="JPEG, PNG, PDF up to 50 MB" />
  <InputUpload feedback="warning" description="JPEG, PNG, PDF up to 50 MB" />
</div>

Size

Four sizes scale the dropzone’s min-height, padding, icon, and typography together.

---
import InputUpload from "../InputUpload.astro";
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>

Slots

Override any slot — icon, title, description, or trigger — to customize the dropzone content. Pass strings via the matching props (title, description, triggerLabel) for simple text overrides, or use named slots / snippets for rich content.

Custom icon

Rich title and description

Custom trigger label

---
import InputUpload from "../InputUpload.astro";
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"
      triggerLabel="Pick a PDF"
      accept="application/pdf"
    />
  </div>
</div>

Custom Styling

The passThrough prop provides per-slot escape hatches: style accepts a Panda CSS SystemStyleObject and props forwards arbitrary HTML attributes onto the slot element.

Pill-shaped surface

Solid border + accented icon

Accessibility props

---
import InputUpload from "../InputUpload.astro";
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"
      triggerLabel="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>

Upload Button

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 feedback to pick the button look — they’re passed straight through to the underlying button.

---
import UploadButtonComponent from "../UploadButton.astro";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "md", direction: "column", align: "flex-start" })}>
  <UploadButtonComponent emphasis="primary" label="Upload" />
  <UploadButtonComponent emphasis="secondary" label="Upload" />
  <UploadButtonComponent emphasis="ghost" label="Upload" />
  <UploadButtonComponent emphasis="primary" feedback="danger" label="Upload" />
  <UploadButtonComponent
    emphasis="secondary"
    label="Upload (max 1 KB)"
    maxSize={1024}
  />
</div>

Validation

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 feedback="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.

Max file size

Max file count

Image dimensions

Custom validator

---
import InputUpload from "../InputUpload.astro";
import { stack } from "@pindoba/styled-system/patterns";
---

<div class={stack({ gap: "xl", direction: "column" })}>
  <div data-demo="size">
    <h3>Max file size</h3>
    <InputUpload
      accept="image/*,application/pdf"
      description="Up to 2 MB"
      maxSize={2 * 1024 * 1024}
    />
    <p
      data-demo-error="size"
      style="color: var(--colors-danger-text); margin-top: 8px;"
    >
    </p>
  </div>

  <div data-demo="count">
    <h3>Max file count</h3>
    <InputUpload multiple description="Up to 2 files" maxFiles={2} />
    <p
      data-demo-error="count"
      style="color: var(--colors-danger-text); margin-top: 8px;"
    >
    </p>
  </div>

  <div data-demo="image">
    <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,
      }}
    />
    <p
      data-demo-error="image"
      style="color: var(--colors-danger-text); margin-top: 8px;"
    >
    </p>
  </div>

  <div data-demo="custom">
    <h3>Custom validator</h3>
    <InputUpload
      description="Filename cannot contain spaces"
      validatorName="noSpaces"
    />
    <p
      data-demo-error="custom"
      style="color: var(--colors-danger-text); margin-top: 8px;"
    >
    </p>
  </div>
</div>

<script>
  window.pindobaValidators = window.pindobaValidators ?? {};
  window.pindobaValidators.noSpaces = (file: File) =>
    /\s/.test(file.name) ? "Filename has spaces" : null;

  type RejectDetail = {
    rejections: { file: File; message: string }[];
  };

  document.querySelectorAll<HTMLElement>("[data-demo]").forEach((wrapper) => {
    const id = wrapper.dataset.demo;
    const root = wrapper.querySelector<HTMLElement>("[data-input-upload-root]");
    const errorEl = wrapper.querySelector<HTMLElement>(
      `[data-demo-error="${id}"]`,
    );
    if (!root || !errorEl) return;
    root.addEventListener("pindoba:reject", (e) => {
      const detail = (e as CustomEvent<RejectDetail>).detail;
      errorEl.textContent = detail.rejections
        .map((r) => `${r.file.name}: ${r.message}`)
        .join(", ");
      root.setAttribute("data-feedback-state", "danger");
    });
    root.addEventListener("pindoba:files", () => {
      errorEl.textContent = "";
    });
  });
</script>

Props

props · 18 total
prop type default req description
feedback "neutral""primary""success""danger""warning" "neutral" Semantic feedback color scheme. neutral: Default. primary: Primary color. success: Green. danger: Red. warning: Orange.
size "xs""sm""md""lg" "md" Size variant. Scales min-height, padding, icon, and typography. xs: Extra small. sm: Small. md: Default. lg: Large.
background "surface.soft""surface.step.1""surface.step.2""surface.step.3""surface.deep""transparent" "transparent" Surface background variant.
fullWidth boolean true Stretch the dropzone to fill its container width.
title stringSnippet "Choose a file or drag & drop it here" Primary line shown inside the dropzone. Pass a string, or use the matching slot (Astro) / snippet (Svelte) for rich content.
description stringSnippet undefined Helper text shown below the title (e.g., accepted formats and size limits). Empty by default.
triggerLabel string "Browse file" Label for the styled trigger pill (Astro). Svelte exposes a `trigger` prop that accepts string or snippet.
accept string undefined Native file input accept pattern (e.g., "image/*" or ".pdf,.doc").
multiple boolean false Allow selecting more than one file.
capture boolean"user""environment" undefined Hint for capture devices on mobile (camera/microphone).
maxSize number undefined Reject files larger than this many bytes.
maxFiles number undefined Reject files past this count when multiple is true.
imageDimensions { minWidth?, maxWidth?, minHeight?, maxHeight?, aspectRatio?, aspectRatioTolerance? } undefined Image-only constraints. Skipped for non-image files. Supports min/maxWidth, min/maxHeight, aspectRatio (with optional aspectRatioTolerance).
validate (file: File) => string | null | Promise<stringnull> undefined Svelte: function returning a string error or null. Runs after built-in checks pass.
validatorName string undefined Astro only. Name of a validator registered on window.pindobaValidators. Functions can't be serialized through frontmatter, so Astro looks the validator up by name.
onReject (rejections: UploadRejection[]) => void undefined Svelte: callback receiving an array of { file, reason, message } rejections. Astro: subscribe to the pindoba:reject CustomEvent on the root element.
passThrough { root?: { style?: SystemStyleObject; props?: Record<string, unknown> }; input?: ...; icon?: ...; title?: ...; description?: ...; trigger?: ... } undefined Custom styling and props for each slot: root (label), input (hidden file input), icon, title, description, trigger.
...rest HTMLAttributes<HTMLInputElement> - Standard HTML input attributes forwarded to the inner file input element.