npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@xsolla/xui-image-uploader

v0.223.0

Published

A cross-platform React image uploader component supporting click-to-pick, drag-and-drop (web), controlled and uncontrolled usage, image preview with hover-to-remove (opt-out via `allowDelete`), automatic loading state from async `onUpload`, error states,

Readme

ImageUploader

A cross-platform React image uploader component supporting click-to-pick, drag-and-drop (web), controlled and uncontrolled usage, image preview with hover-to-remove (opt-out via allowDelete), automatic loading state from async onUpload, error states, square / custom-rectangle / wide layouts.

A specialised upload control for images. Renders as a square or landscape rectangle that acts as both the upload trigger and the image preview container. Once an image is uploaded, the zone fills with the image thumbnail; hovering over it reveals controls to replace or remove the image. Supports five sizes, two aspect ratios, and eight states covering the full upload lifecycle.

When to use

  • When the user must upload a single image — a profile photo, cover image, product thumbnail, brand logo, avatar, or banner
  • When the uploaded image should be previewed in place immediately after selection
  • When image replacement or removal must be available without navigating away
  • In profile settings, onboarding flows, CMS editors, and product or entity configuration forms

When not to use

  • For uploading non-image files (PDFs, CSVs, documents) — use FileUploader or Drag & Drop Uploader
  • When multiple images must be uploaded simultaneously — use a Drag & Drop Uploader with multiple file support or a dedicated gallery uploader
  • When the image upload is part of a broader drag-and-drop area — use Drag & Drop Uploader
  • When cropping or editing the image after selection is required — pair ImageUploader with a dedicated image crop modal triggered after selection

Content guidelines

  • Upload hint — inside the zone in Default state, use a short hint below the icon: "Upload photo", "Add cover image", "Choose image". Keep to one line.
  • Size and format hint — show accepted formats and max size as helper text below the zone (not inside it): "PNG, JPG, WebP — max 5 MB", "Recommended: 1200 × 630 px". At small sizes (S, XS) this may be omitted if space is too tight.
  • Error messages — be specific:
  • "File type not supported. Use JPG, PNG, or WebP."
  • "Image exceeds the 5 MB limit."
  • "Upload failed. Try again."
  • "Please upload a profile photo to continue." (required field)
  • Replace / Remove labels — use exactly "Replace" and "Remove" in the overlay. Do not use "Edit" for replace or "Delete" for remove in this context — the terms are specific to the image slot, not the entity.
  • Field label — always provide a visible label above the component: "Profile photo", "Cover image", "Company logo". Do not rely on the hint inside the zone alone.

Behaviour guidelines

  • Click to upload — clicking the zone in Default, Hover, or Focus state opens the native file dialog filtered to image types (e.g. accept="image/jpeg,image/png,image/webp,image/gif"). After the user selects a file, begin validation immediately.
  • File validation — validate client-side before uploading:
  • File type: accept only image formats supported by the product
  • File size: reject files above the maximum (e.g. 5 MB, 10 MB) If validation fails, switch to State=Error with a specific message. Do not start the upload for invalid files.
  • Uploading — after successful validation, transition to State=Uploading and begin the upload. The zone becomes non-interactive.
  • Upload success — on successful upload response, transition to State=Uploaded. The server-returned image URL (or the local object URL) fills the zone as a cropped preview.
  • Upload failure — on network error or server rejection, transition to State=Error with a specific message. The zone becomes interactive again — the user can click to retry.
  • Image fitting — the preview image fills the entire zone using object-fit: cover and object-position: center so the image is always fully visible and cropped to fit, regardless of the original aspect ratio. Do not letterbox or pillarbox.
  • Replace — clicking Replace in State=Uploaded-hover opens the file dialog. The current image stays visible until the new image is successfully uploaded. If the new upload fails, revert to showing the previous image (not the error state) and surface the error as a Toast or inline message.
  • Remove — clicking Remove deletes the image from the selection and returns to State=Default. If the image was already uploaded to a server, also trigger a server-side deletion request. Optionally confirm before removing if the action is irreversible.
  • Touch devices — State=Uploaded-hover is not reachable on touch. Provide always-visible controls below the zone (small icon buttons for Replace and Remove) when the component is used on mobile.
  • Disabled state — the zone cannot be clicked or focused. Provide a tooltip or nearby label explaining why upload is unavailable.

Accessibility

  • The upload zone must be implemented as a or a wrapping a hidden <input type="file" accept="image/"*>. It must be keyboard-focusable and activate on Enter / Space.
  • The zone must have aria-label describing its purpose — e.g. aria-label="Upload profile photo".
  • When State=Uploading, set aria-busy="true" on the zone and update aria-label to "Uploading profile photo…". The zone must not be focusable during upload.
  • When State=Uploaded, update aria-label to "Profile photo uploaded. Hover or focus to replace or remove." This tells keyboard users there are hidden actions available.
  • The Replace and Remove buttons in the overlay (State=Uploaded-hover) must always be keyboard-reachable via Tab, even when the overlay is visually hidden. Use CSS (opacity: 0; pointer-events: none) rather than display: none or visibility: hidden so they remain in the focus order.
  • The Replace button must have aria-label="Replace profile photo" and the Remove button aria-label="Remove profile photo" — not just "Replace" and "Remove" without context.
  • When State=Error, the error message must be associated via aria-describedby and placed in an aria-live="polite" region.
  • When State=Disable, the zone must have aria-disabled="true" and tabindex="-1".
  • The image preview is decorative — set aria-hidden="true" on the element inside the zone, since the zone's aria-label already communicates the current state.
  • For touch devices, the Replace and Remove controls that live outside the hover overlay must also have descriptive aria-label attributes.

Installation

npm install @xsolla/xui-image-uploader

Demo

Basic (Uncontrolled)

import * as React from "react";
import {
  ImageUploader,
  type ImageUploaderFile,
} from "@xsolla/xui-image-uploader";

export default function Basic() {
  const handleUpload = (file: ImageUploaderFile) => {
    console.log("Picked file:", file.name, file.size);
  };

  return <ImageUploader onUpload={handleUpload} />;
}

Controlled

import * as React from "react";
import {
  ImageUploader,
  type ImageUploaderValue,
} from "@xsolla/xui-image-uploader";

export default function Controlled() {
  const [value, setValue] = React.useState<ImageUploaderValue | null>(null);

  return (
    <ImageUploader
      value={value}
      onChange={setValue}
      placeholder="Upload avatar"
    />
  );
}

Wide View with Description

import * as React from "react";
import { ImageUploader } from "@xsolla/xui-image-uploader";

export default function WideView() {
  return (
    <ImageUploader
      wideView
      placeholder="Upload cover image"
      description="PNG or JPG, up to 5 MB"
    />
  );
}

Replace-only Slots (No Delete Affordance)

Some forms own a permanent image slot: the value can be swapped but never cleared. allowDelete={false} removes the removal path without disabling the field — the slot stays clickable, droppable, and replaceable.

import { ImageUploader } from "@xsolla/xui-image-uploader";

export default function CompanyLogo() {
  return (
    <ImageUploader
      allowDelete={false}
      value={value}
      onUpload={(file) => uploadFn(file.file)}
      onChange={setValue}
    />
  );
}

With allowDelete={false}:

  • The hover trash overlay (and its dimming scrim) is never rendered, on pointer devices, touch, and React Native alike.
  • Clicking or dropping onto a filled preview re-opens the picker and replaces the image instead of clearing it.
  • onDelete never fires, and onChange(null) is never emitted by the component (the consumer can still clear the value itself in controlled mode).
  • The button's aria-label reads "Replace image: <filename>" rather than "Remove image: <filename>".

This is deliberately independent of disabled. Use disabled when the whole control should be inert; use allowDelete={false} when only the removal path should be gone.

Fixed Non-Square Slots

size is a square token scale and wideView stretches to the parent's full width. When a design calls for a fixed rectangle that is neither — a 120 × 32 logo slot, for instance — pass customSize:

import { ImageUploader } from "@xsolla/xui-image-uploader";

<ImageUploader
  size="xs"
  customSize={{ width: 120, height: 32 }}
  placeholder={null}
  allowDelete={false}
/>;

customSize resizes only the outer box. Icon size, padding, border radius, gap and font sizes still come from the size token, so:

  • pair a small box with a small size, and
  • pass placeholder={null} when the box is too short to fit a text label (content is clipped, not wrapped — the box sets overflow: hidden).

width and height accept a number (rendered as px) or any CSS length string. When wideView is also set it keeps ownership of the width (width: 100%); customSize.height still applies.

Naming Each Uploader (Accessible Name)

By default the upload button is named by its state text alone — "Upload", "Uploading", "Remove image: <filename>" or "Replace image: <filename>". With several uploaders on one page, and especially with placeholder={null}, screen reader users cannot tell them apart. Name each one with aria-label or aria-labelledby. The state text is kept and announced after your label.

import { ImageUploader } from "@xsolla/xui-image-uploader";

// Announced as "Left logo, Upload"
<ImageUploader placeholder={null} aria-label="Left logo" />;

// Point the button at the visible field label.
// Announced as "Windows icon Upload"
<>
  <label id="windows-icon-label">Windows icon</label>
  <ImageUploader placeholder={null} aria-labelledby="windows-icon-label" />
</>;
  • aria-label is prefixed to the state text, separated by a comma.
  • aria-labelledby takes precedence over aria-label. The button references your element first and then itself, so its own state text follows the external label.
  • Both props are web only.

Async Upload (Recommended)

If onUpload returns a Promise, the component automatically:

  • Shows the uploading state (spinner + uploadingPlaceholder) until the promise settles.
  • On resolve with { url, filename? } or a bare string URL, fires onChange(value) with the canonical value — no manual wiring needed.
  • On reject, fires onChange(null, error).

You don't need async/await in the handler — just return the upload promise:

import { ImageUploader } from "@xsolla/xui-image-uploader";

export default function AutoUpload() {
  return (
    <ImageUploader
      uploadingPlaceholder="Uploading…"
      // uploadFn can return Promise<string> or Promise<{ url, filename? }>
      onUpload={(file) => uploadFn(file.file)}
      onChange={(value) => form.setFieldValue("avatar", value?.url ?? null)}
    />
  );
}

Manual Loading Control

For finer-grained control (e.g. uploads triggered outside the component, or to keep the spinner visible during post-upload work), pass loading explicitly:

import * as React from "react";
import {
  ImageUploader,
  type ImageUploaderFile,
} from "@xsolla/xui-image-uploader";

export default function WithLoading() {
  const [loading, setLoading] = React.useState(false);

  const handleUpload = async (file: ImageUploaderFile) => {
    setLoading(true);
    try {
      await uploadToServer(file.file);
    } finally {
      setLoading(false);
    }
  };

  return (
    <ImageUploader
      loading={loading}
      uploadingPlaceholder="Uploading…"
      onUpload={handleUpload}
    />
  );
}

Error State

import * as React from "react";
import { ImageUploader } from "@xsolla/xui-image-uploader";

export default function WithError() {
  return (
    <ImageUploader
      placeholder="cover.png"
      errorMessage="File is too large (max 5 MB)."
    />
  );
}

Error on an Uploader That Already Has an Image

Setting errorMessage does not hide an existing image. When a value (or an internal preview) is set, the thumbnail stays visible with an alert border and the message renders below it — so a rejected pick never looks like the saved image was deleted:

import { ImageUploader } from "@xsolla/xui-image-uploader";

<ImageUploader
  value={{ filename: "cover.png", url: "/cover.png" }}
  errorMessage="Image must be at least 512×512."
/>;

React Native (Custom Picker)

On native there is no DOM <input type="file">. Pass an openPicker prop that returns a normalized ImageUploaderFile (e.g. wrapping expo-image-picker or react-native-image-picker).

import * as React from "react";
import * as ImagePicker from "expo-image-picker";
import {
  ImageUploader,
  type ImageUploaderFile,
} from "@xsolla/xui-image-uploader";

export default function Native() {
  const openPicker = async (): Promise<ImageUploaderFile | null> => {
    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ImagePicker.MediaTypeOptions.Images,
    });
    if (result.canceled) return null;
    const asset = result.assets[0];
    return {
      name: asset.fileName ?? "image",
      size: asset.fileSize ?? 0,
      uri: asset.uri,
      mimeType: asset.mimeType,
    };
  };

  return <ImageUploader openPicker={openPicker} />;
}

Anatomy

+--------------------------+
|                          |
|         [Image]          |  <- icon (or Spinner while loading)
|         Upload           |  <- placeholder
|     PNG/JPG, up to 5MB   |  <- description (wideView only)
|                          |
+--------------------------+
  File is too large…         <- errorMessage (when present)

When a file has been uploaded, the box renders the image preview and reveals a trash-can affordance over a dimming scrim on hover; clicking (or tapping) removes the image. On touch and React Native devices — where there is no hover — the trash overlay is shown unconditionally so the delete affordance is always discoverable.

Pass allowDelete={false} to opt out of that affordance entirely: the preview never enters the hover state, no scrim or trash is drawn on any platform, and interacting with the filled slot replaces the image instead of removing it.

Disabled with an existing image

disabled does not clear the preview. When the component is disabled while a value (or an internal preview) is set, the uploaded thumbnail stays on screen behind a dimming scrim instead of reverting to the icon + placeholder empty state. The trash affordance is suppressed, so the image cannot be removed while the control is disabled. A disabled uploader with no image still renders the regular greyed-out empty state.

Error with an existing image

errorMessage does not clear the preview either. When an error is set while a value (or an internal preview) is present, the thumbnail stays on screen with the alert border around it and the message below it, instead of being replaced by the empty error box. The hover trash affordance keeps working as usual. An uploader with an error and no image still renders the regular error box. If the uploader is also disabled or loading, those states win and the error is not shown, as before.

API Reference

ImageUploader

| Prop | Type | Default | Description | | :------------------- | :----------------------------------------------------------------------------------- | :------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | testID | string | — | Test ID for testing frameworks. On web this renders as data-testid; on React Native it renders as testID. | | size | "xl" \| "lg" \| "md" \| "sm" \| "xs" | "xl" | Visual size of the uploader. Drives the square box dimensions and the inner scale (icon, padding, radius, gap, font sizes). | | customSize | { width: number \| string; height: number \| string } | - | Explicit box dimensions for a fixed non-square slot, overriding the square size box. Only the outer box is resized — the inner scale still comes from size. Numbers render as px. Ignored for width when wideView is set; the height still applies. | | placeholder | React.ReactNode | "Upload" | Label shown under the icon (or filename in error state). Accepts a string or a custom React node. Pass null to render the icon alone. | | uploadingPlaceholder | React.ReactNode | "Uploading" | Label shown while loading is true. Accepts a string or a custom React node. | | description | React.ReactNode | - | Helper text below the placeholder. Only rendered when wideView is true. | | errorMessage | string | - | Error message — when provided, the component renders in the error state. An existing preview stays visible with an alert border (message below it) rather than being replaced by the empty error box. | | wideView | boolean | false | Use the horizontal layout. The box stretches to fill its parent's width. | | disabled | boolean | false | Disabled state. Makes the whole control inert — an existing preview stays visible behind a dimming scrim (with the delete affordance suppressed) rather than reverting to the empty upload zone. To keep the field active while hiding only the removal path, use allowDelete instead. | | allowDelete | boolean | true | Whether the filled preview exposes a delete affordance. When false, the hover trash overlay is never rendered, clicking or dropping onto a filled preview replaces the image instead of removing it, and onDelete never fires. | | loading | boolean | false | Controlled loading state (shows spinner + uploading label). | | value | ImageUploaderValue \| null | - | Controlled value. When provided, the component reflects this value. | | onUpload | (file: ImageUploaderFile) => void \| Promise<ImageUploaderValue \| string \| void> | - | Fires when the user picks (or drops) a file. If it returns a Promise, the component shows the uploading state until it settles, forwards a resolved value (object or bare URL string) to onChange, and forwards a rejection to onChange(null, error). | | onChange | (value: ImageUploaderValue \| null, error?: Error) => void | - | Fires when the displayed value changes — on file pick (with a local data-URL preview), after onUpload resolves (with the server value), or on remove (null). The optional error arg carries any rejection from onUpload. | | onDelete | () => void | - | Fires when the user removes the image (trash click / clear). Never fires when allowDelete is false. | | accept | string | "image/*" | Accepted MIME types for the file input (web only). | | openPicker | () => Promise<ImageUploaderFile \| null> | - | Native file picker hook. Required on native (no DOM <input type="file">). On web, omit this and the component falls back to a hidden file input. | | aria-label | string | - | Accessible name for the upload button — what the uploader is for (e.g. "Left logo"). The state text is appended, so the button is announced as "Left logo, Upload". Ignored when aria-labelledby is set. Web only. | | aria-labelledby | string | - | ID of the element naming the upload button, typically the visible field label. The state text is announced after it ("Left logo Upload"). Takes precedence over aria-label. Web only. | | themeMode | ThemeMode | - | Override the theme mode for this instance. | | themeProductContext | ThemeProductContext | - | Override the product theme context for this instance. |

ImageUploaderValue

Controlled value shape.

interface ImageUploaderValue {
  filename: string;
  url?: string;
}

Loading and error states are driven by the loading and errorMessage props, not by fields on ImageUploaderValue.

ImageUploaderCustomSize

Explicit box dimensions, as an escape hatch from the square size enum.

interface ImageUploaderCustomSize {
  width: number | string;
  height: number | string;
}

ImageUploaderFile

Normalized file shape produced by the platform picker / drag-drop pipeline. On web, uri is a data-URL and the original DOM File is passed through for consumers that need it (e.g. to upload via FormData / fetch). On native, uri is a file URI and file is omitted.

interface ImageUploaderFile {
  name: string;
  size: number;
  uri: string;
  mimeType?: string;
  /** The original DOM File (web only) */
  file?: File;
}

Platform Support

This package works on both web and React Native. The component uses cross-platform primitives (Box, Text, Spinner) and gates web-only APIs (DOM file input, drag-and-drop, FileReader) behind an isWeb check. On native, supply an openPicker prop to integrate with your image picker library of choice.

Touch / Mobile

On touch web (detected via matchMedia("(hover: none)")) and on React Native, the component skips the hover-only delete affordance and always shows the trash overlay over the image preview, ensuring the remove action is reachable without a pointer. With allowDelete={false} there is no delete affordance to surface, so nothing is drawn over the preview on touch either.

Layout

In wideView, the component stretches to fill its parent's full width (width: 100%), letting the consumer's layout (form column, grid cell, etc.) control the maximum size. The square (non-wide) variants use fixed pixel dimensions from the size tokens. For a fixed rectangle that is neither square nor full width, pass customSize={{ width, height }} — see Fixed Non-Square Slots.

Accessibility

  • The picker surface is rendered as a <button> on web with an accessible label that reflects the current state ("Upload", "Uploading", "Remove image: <filename>", or "Replace image: <filename>" when allowDelete is false).
  • When the component is disabled with an image present, that label becomes "Uploaded image: <filename>" — neither remove nor replace is available, so the label does not advertise them.
  • Name each uploader with aria-label or aria-labelledby so several uploaders on one page can be told apart. The state text above is kept and announced after the consumer's label ("Left logo, Upload"). See Naming Each Uploader.
  • aria-busy is set while loading is true.
  • aria-invalid is set in the error state and the error message is linked via aria-describedby (and rendered with role="alert"). This also applies when the error is shown over an existing image preview.
  • The description element (when present) is linked via aria-describedby.
  • Decorative icons and the dimming scrim are marked aria-hidden.
  • Focus styling only appears for keyboard focus (:focus-visible), not for pointer focus left over after the native picker is dismissed.
  • The hidden file input is reset after every selection so the same file can be re-selected to retry a failed upload.