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.

Upload dialog

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().

Project files

Share briefs, footage and assets with the team

Import from a link
---
// 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>

Photo grid

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.

Gallery
Fixed columns, compact density
---
// 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>

Avatar

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.

Circle (default) — every size
Rounded, with an existing photo
---
// 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>

Compact

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.

Inline dropzone + compact rows
    Inline sizes (row heights match Input)
    ---
    // 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>

    Drop anywhere

    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>

      Controlled list

      UploadList is controlled: hand it entries and handle onRemove / onRetry yourself when an SDK (S3 multipart, tus, …) already tracks upload state.

      • quarterly-plan.pdf20 MB · 5 sec remaining
        32%
      • standup-recording.mov218 MB · 24 sec remaining
        67%
      • brand-guidelines.docx1.2 MB · Uploaded
      • hero-banner.pngConnection lost
      ---
      // 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>

      Tone

      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>

      Size

      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>

      Slots

      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.

      Custom icon

      Rich title and description

      Custom trigger label

      ---
      // 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>

      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

      ---
      // 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>

      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 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>

      Upload Button with a drop area

      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>

        Reaching the engine

        The headless engine (addEntries, removeEntry, retry, start, …) is output-only state, and each framework exposes it in its own idiom:

        • React — engineRef.
        • Solid — engineRef, a callback that receives the engine.
        • Vue — onEngine(engine), called once at setup; the same handle is on the component ref as engine.
        • Svelte — bind:engine.
        • Astro — getInputUpload(root) from @pindoba/astro-input-upload/runtime; the engine is also registered under the input id.

        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 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.

        Max file size

        Max file count

        Image dimensions

        Custom validator

        ---
        // 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>
        • Components
        • Blocks