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

@substrate-system/image-input

v0.0.13

Published

[![tests](https://img.shields.io/github/actions/workflow/status/mycelial-systems/image-input/nodejs.yml?style=flat-square)](https://github.com/mycelial-systems/image-input/actions/workflows/nodejs.yml) [![types](https://img.shields.io/npm/types/@substrate

Readme

image input

tests types module install size GZip size semantic versioning Common Changelog license

Web component for inputting images. Includes client-side preview, a crop tool, and alt text input.

See a live demo

Install

npm i -S @substrate-system/image-input

ESM

import { ImageInput } from '@substrate-system/image-input'

<image-crop>, the cropper, is exported from the root too, and from its own subpath:

import { ImageCrop } from '@substrate-system/image-input'
import { ImageCrop } from '@substrate-system/image-input/crop'

Common JS

require('@substrate-system/image-input')

CSS

Import CSS

import '@substrate-system/image-input/css'

Or minified:

import '@substrate-system/image-input/css/min'

If you use <image-crop> on its own, import just its styles and skip the <image-input> box, preview, overlay and dialog rules:

import '@substrate-system/image-input/css/crop'

Or minified:

import '@substrate-system/image-input/css/crop/min'

CSS variables

image-input sets no width, height, min-height or aspect-ratio on the box -- size it yourself:

image-input .box {
    width: 100%;
    max-width: 480px;
    min-height: 200px;
}

Everything else is a CSS custom property, overridable from your own stylesheet: the --image-input-* ones in _vars.css, and the --image-crop-* ones in _vars-crop.css.

  • --image-input-border-width, --image-input-border-style, --image-input-border-color -- the box's border.
  • --image-input-border-color-drag, --image-input-border-style-drag -- the border while a file is dragged over the box.
  • --image-input-box-bg, --image-input-box-bg-drag -- box background, default and while dragging.
  • --image-input-box-padding -- padding around the prompt in the empty box.
  • --image-input-prompt-color, --image-input-prompt-icon-size, --image-input-prompt-gap -- the empty box's icon and text.
  • --image-input-radius -- corner radius, shared by the box and the preview image.
  • --image-input-focus-color -- the keyboard focus ring, shared with the overlay buttons.
  • --image-input-dialog-bg, --image-input-dialog-radius, --image-input-dialog-padding, --image-input-dialog-max-width, --image-input-dialog-backdrop-color -- the built-in alt-text and crop dialogs' background, corner radius (defaults to --image-input-radius), padding, max width, and backdrop color.
  • --image-input-dialog-max-width-viewport -- the second half of the dialog width clamp, 90vw by default. The dialog is never wider than the smaller of this and --image-input-dialog-max-width, so it cannot overflow a narrow screen.
  • --image-input-dialog-menu-gap, --image-input-dialog-menu-margin -- the gap between the dialogs' Cancel and Save buttons, and the space above that row.
  • --image-input-dialog-field-gap, --image-input-dialog-textarea-padding -- the space between the alt dialog's label and its textarea, and the textarea's own padding.
  • --image-input-dialog-crop-max-width -- the widest the <image-crop> inside the crop dialog will render. 100% by default, so it fills the dialog's content box.
  • --image-input-dialog-duration, --image-input-dialog-scale-from -- the duration of the dialogs' open/close fade-and-scale, and the scale they animate in from.

The --image-crop-* variables, in _vars-crop.css:

  • --image-crop-max-height -- the tallest an <image-crop> will render, 65vh by default. This is what keeps a tall portrait image from pushing the Cancel and Save buttons off screen. It must be a value that resolves to a length, because ImageCrop reads it back to size the crop frame; a percentage is treated as no cap at all.

Example

This calls the global function customElements.define. Just import, then use the tag in your HTML.

const input = document.querySelector('image-input')

input.addEventListener('image-input:change', ev => {
    const { file, alt } = ev.detail
    // upload the file, etc.
})

input.addEventListener('image-input:edit', ev => {
    // image-input opens its own crop dialog after this event fires --
    // you don't need this listener at all to get that behavior. Call
    // preventDefault() only if you want to replace it with your own
    // crop UI:
    ev.preventDefault()
    const { file } = ev.detail
    // open your own crop dialog, then:
    // input.setImage(croppedBlob)
})

Drop target

image-input renders as a bordered box that is both the file picker and the drop target. Click the box, or focus it with Tab and press Space, to open the native file picker -- or drag an image file onto the box and drop it. Click and drop are equivalent: both result in the same preview, the same image-input:change event, and the same entry in input.files for form submission. The box's border and background change while a file is dragged over it, and clicking the box again after an image is loaded reopens the picker and replaces it.

Attributes

All attributes are reflected as properties, so input.alt = 'a photo' is the same as setting the attribute in HTML.

  • accept -- File types the input accepts, passed through to the native file input. Defaults to image/*.

  • name -- Name for the internal file input, so the component works inside a <form>.

  • alt -- Alt text for the preview image. Changing it emits an image-input:alt-change event and updates the ALT badge state.

  • required -- Boolean. Marks the internal file input as required for form validation.

  • label -- Prompt text shown in the empty box, and used as the file input's aria-label. Defaults to Drop an image, or click to choose one.

  • crop -- Locks the crop dialog's rect to a fixed shape, instead of the default free-form crop. Forwarded as-is to the <image-crop> the dialog creates -- see image-crop below for the three value forms and what locking actually changes.

    crop="circle" also rounds the preview: the stylesheet draws the preview image as a circle, so what you see after saving a crop matches what you framed in the dialog. That is display only. The file in input.files, and the one in the image-input:change event, is still a square with its corners intact. A file that was picked but never cropped is centered and squared in the preview by object-fit: cover, so it reads as a circle too, while the file itself keeps its original shape.

  • nocrop -- Boolean. Removes the Edit button from the preview overlay. While it is set the button is hidden by the stylesheet and image-input:edit never fires, so a page that offers no cropping shows no dead affordance. Use it when you cancel image-input:edit and supply nothing in its place, or when an image should not be recroppable once picked.

    It suppresses the trigger and nothing else. The ALT badge, Remove, picking, dropping, the preview, form participation and setImage() all behave exactly as they do without it -- you can still replace the image programmatically while offering the user no way to ask for it. It is honored whether it is in the served markup or toggled at runtime, and it takes effect immediately.

    nocrop and crop are unrelated and never appear together. nocrop is about whether image-input offers a trigger; crop is about the shape the crop rect enforces once a cropper exists.

    Note the button is hidden by a rule in this package's stylesheet (image-input[nocrop] .edit), not omitted from the markup. If you ship your own stylesheet instead of ours, hide it yourself -- the handler guard keeps it inert either way, but cannot make it disappear.

<image-input
    accept="image/png, image/jpeg"
    name="avatar"
    alt="A description of the image"
    crop="circle"
    required
></image-input>

Events

All events are namespaced with the tag name, e.g. image-input:change. They bubble, so you can listen on the element or an ancestor.

  • image-input:change -- A file was selected, or setImage(blob) was called. detail is { file:File, alt:string }.
  • image-input:remove -- The remove button was clicked. The preview and file have already been cleared. No detail.
  • image-input:edit -- The edit button was clicked. detail is { file:File }. Cancelable: by default, the built-in crop dialog (see Built-in dialogs below) opens right after this event fires. Call preventDefault() on it to suppress that dialog and open your own crop UI instead, then call setImage(blob) with the result. Never fires while the nocrop attribute is set.
  • image-input:alt -- The ALT badge was clicked. detail is { file:File, alt:string }. Cancelable: by default, the built-in alt-text dialog opens right after this event fires. Call preventDefault() on it to suppress that dialog and open your own alt text editor instead, then set the alt property with the result.
  • image-input:alt-change -- The alt attribute or property changed. detail is { alt:string }.
  • image-input:error -- A picked or dropped file was not an image. detail is { reason:'not-an-image' }. The component shows no message of its own; use this event to report the error yourself.

Typescript

The detail shapes above are typed, so listeners do not need an annotation or a cast. .on() and .off() take the non-namespaced name and infer the event from it:

import { ImageInput } from '@substrate-system/image-input'

const input = document.querySelector('image-input')!

input.on('change', ev => {
    ev.detail.file.name  // File
    ev.detail.alt        // string
})

addEventListener is typed too, under the namespaced name. These events bubble, so this works on an ancestor as well as on the element itself:

document.body.addEventListener('image-input:error', ev => {
    ev.detail.reason  // 'not-an-image'
})

The map itself is exported as ImageInputEventMap if you want to name a handler's parameter type:

import type { ImageInputEventMap } from '@substrate-system/image-input'

function handleChange (ev:ImageInputEventMap['change']) {
    console.log(ev.detail.file.name)
}

Note the addEventListener typing works by augmenting the global HTMLElementEventMap, so the six image-input:* keys become visible on every HTMLElement in a project that imports this package.

Built-in dialogs

image-input renders two <dialog> elements as siblings of .box, inside the element itself: .alt-dialog and .crop-dialog. The ALT badge opens .alt-dialog; the edit button opens .crop-dialog. Both fade and scale in when opened. You get this for free -- no markup or wiring of your own is required.

  • .alt-dialog contains a <textarea>, an .alt-cancel button and an .alt-save button. Saving assigns the textarea's value to input.alt, which emits image-input:alt-change.
  • .crop-dialog contains a .crop-slot div, a .crop-cancel button and a .crop-save button. The <image-crop> element is created the first time the crop dialog is opened, and reused after that -- it is not present in the initial markup. Saving calls cropEl.getBlob() and passes the result to input.setImage(blob), which emits image-input:change.

No new event types exist for this. Both dialogs sit on top of the events already documented above: image-input:edit and image-input:alt open them (unless canceled), and saving fires the existing image-input:change / image-input:alt-change. The nocrop attribute stops image-input:edit at the source, so the crop dialog is unreachable while it is set; the alt dialog is unaffected.

Neither dialog's markup has an id -- image-input is a light-DOM component, so an id in its own markup would collide with every other image-input on the page. Each dialog is labeled with aria-label instead.

Changing the dialog copy

ImageInput.TEXT is a static object with keys altHeading, altLabel, cropHeading, save and cancel. Each element reads it when it renders, so reassigning it affects every image-input that upgrades afterwards, and leaves already-rendered elements on the old copy. Set it before the first image-input upgrades -- next to the import is the simplest place -- and the whole page is consistent. The copy is page-wide, not per element:

import { ImageInput } from '@substrate-system/image-input'

ImageInput.TEXT = {
    ...ImageInput.TEXT,
    cropHeading: 'Crop your photo'
}

TEXT only controls the dialogs' headings and button labels. The alt text value is still set per element, through the existing alt attribute/property.

Browser support for the open/close animation

The fade-and-scale transition uses @starting-style and transition-behavior: allow-discrete, both Baseline since 2024-08-06. The overlay value, which keeps a closing dialog painted for the length of its exit transition, is Chromium-only. Firefox and Safari animate the dialog opening, then close it instantly -- a known limitation of specifying only an entry animation, not a bug.

image-crop

The package also exports an image-crop component, a cropping UI with draggable handles. image-input uses it internally for the built-in crop dialog, described above. You can also use it directly if you build your own crop UI, as in the reframed image-input:edit example above.

import { ImageCrop } from '@substrate-system/image-input/crop'

Attributes

  • src -- URL of the image to crop. Reflected as a property. You can also pass a File directly with cropEl.setFile(file).

  • crop -- Locks the crop rect to a fixed aspect ratio, instead of the default free-form rect that starts at the whole image with all eight resize handles. Not reflected as a property -- image-crop already has a .crop getter for the current rect (see below), so set this with cropEl.setAttribute('crop', 'circle'), not cropEl.crop = 'circle'. Three value forms:

    • constrain -- locks to the loaded image's own aspect ratio.
    • circle -- locks to 1:1 and draws the crop area as a circle.
    • A ratio literal, following CSS aspect-ratio syntax: 3/4, 3 / 4, or the bare number 0.75 all mean the same thing.

    Locking changes three things: the rect starts as the largest rect of that ratio, centered, instead of covering the whole image; only the four corner handles are shown, and a corner drag (or shift plus an arrow key) anchors the opposite corner and scales the rect proportionally instead of resizing one side; and the rect can never be dragged, or keyed, past the image's edges without breaking the ratio. Changing the attribute after an image has loaded re-fits the rect to the new constraint.

    circle is crop-UI chrome, not a pixel mask -- getBlob() still returns an ordinary square image, in whatever type you requested. Round it with CSS border-radius when you display it. Using image-crop through image-input already does that for the preview; this applies when you display the blob yourself.

    A value that is not one of the keywords and not a usable ratio (a typo, 0, a negative number) falls back to free-form cropping and is reported through the debug channel. Nothing throws and no error event fires.

Events

  • image-crop:load -- The image finished decoding and the crop rect has been fitted to it. Until this fires, cropEl.crop reads { x: 0, y: 0, width: 0, height: 0 } and getBlob() rejects, so this is the signal that the element is usable. detail is { naturalWidth:number, naturalHeight:number }. It fires once per image, so loading a second image into the same element fires it again.
  • image-crop:change -- The crop rectangle changed. detail is the crop rect, { x:number, y:number, width:number, height:number }, in natural (not displayed) image pixels. This fires for a pointer drag, for a keyboard move or resize, when the crop attribute changes, and once on load with the initial rect -- so a listener on this event alone sees every rect the element has ever had, starting with the first.

Attach these before the call that starts the load (setFile, or setting src). The load is asynchronous, so a listener attached in the same tick as that call still catches it.

Methods

  • setFile(file) -- Load a File into the cropper. Revokes the previous object URL, resets the natural size and the crop rect, and starts an asynchronous load that ends in image-crop:load.

  • crop -- A getter, not a method: the current crop rect in natural image pixels. Reads { x: 0, y: 0, width: 0, height: 0 } until the image has loaded.

  • getBlob(opts?) -- Render the crop region to a canvas at natural resolution and resolve it as a Blob. opts is { type?:string, quality?:number }, matching canvas.toBlob.

    With no type, the blob keeps the source file's own type when the canvas can encode it -- image/png, image/jpeg and image/webp -- and falls back to image/jpeg for anything else, including an element driven by the src attribute with no file behind it. The fallback exists because canvas.toBlob silently encodes PNG for a type it cannot write, so passing an unencodable type through would return bytes that disagree with the blob's own type.

    Rejects if no image has finished loading.

cropDialog

Use the standalone crop dialog when the image does not belong to an image-input element. The function accepts a File, Blob, or URL string and resolves with the cropped Blob. Canceling with Cancel, Escape, or the backdrop resolves with null.

import { cropDialog } from '@substrate-system/image-input/crop-dialog'

const cropped = await cropDialog(file, {
    heading: 'Trim photo',
    crop: 'circle'
})

if (cropped) saveImage(cropped)

The heading, save, and cancel options change the dialog copy. The crop option accepts the same values as the image-crop crop attribute, including constrain, circle, and ratio literals. Load @substrate-system/image-input/css/crop separately. The function does not inject styles.

Errors from getBlob() reject the promise. The dialog is removed from the document after saving, canceling, or an error.

API

Methods

Each method meant to be called from outside the component exists in two forms: an instance method, and a static taking the element as its first argument. They are the same code -- the instance method delegates to the static -- so use whichever reads better. The static form is handy when you have no this to bind:

document.querySelectorAll('image-input').forEach(ImageInput.clear)
  • setImage(blob, name?) / ImageInput.setImage(el, blob, name?) -- Replace the preview with a Blob, e.g. a cropped image from your own crop UI. The blob is promoted to a File (named after the current file, with an extension matching the blob's type, unless you pass name), written into the internal <input> so a surrounding form sees it, and emitted as image-input:change.
  • clear() / ImageInput.clear(el) -- Clear the selected file and put the box back in its empty state: the preview is hidden, the object URL is revoked, the internal <input> is reset, and alt is set to null (which emits image-input:alt-change with an empty string). This does not emit image-input:remove -- that event means the user clicked the remove button, so a page that clears the input itself already knows it happened.

Server rendering

The same markup is available as a string, so a page can render the input server-side and attach behavior later, or never.

import { html } from '@substrate-system/image-input/html'

res.send(`<image-input>${html({ name: 'avatar' })}</image-input>`)

Two rules:

  • The host element must be an <image-input> tag. Every rule in the stylesheet is scoped under that element selector, so markup dropped into a <div> renders unstyled.
  • Import the CSS. The markup carries no inline styles.

The page is styled and the picker works with no JavaScript at all, because the control is a real <input type="file">. To add the preview, the overlay buttons and the dialogs, hydrate it:

import { ImageInputClient } from '@substrate-system/image-input/client'

const client = new ImageInputClient(
    document.querySelector('image-input')
)

ImageInputClient emits image-input:change, image-input:remove, image-input:edit, image-input:alt and image-input:alt-change, honors preventDefault() on :edit and :alt the same way the custom element does, and exposes setImage(blob), clear() and destroy().

nocrop works on this path too. Write it on the <image-input> host -- the stylesheet rule and the client's guard both read it from there, so res.send('<image-input nocrop>' + html() + '</image-input>') hides the Edit button and keeps it inert, hydrated or not. It is not an html() option: the markup is the same either way.

html() takes accept, name, required, alt and label, plus dialogs: false to leave the built-in dialogs out when you supply your own editing UI. Pass text (the same shape as ImageInput.TEXT) to set the built-in dialogs' copy on this path, without importing the custom element:

import { DEFAULT_TEXT } from '@substrate-system/image-input/dialogs'

html({ name: 'avatar', text: { ...DEFAULT_TEXT, cropHeading: 'Crop' } })

Some differences from the custom element:

  • ImageInputClient does not write the picked file back into input.files, so a cropped image does not submit with a surrounding form on this path. Use the custom element where form submission matters.
  • There is no drop target on this path. Dropping a file onto the box falls through to the browser's default behavior (typically navigating to the file) instead of showing a preview -- only the file picker works. The custom element's drag-and-drop wiring is not part of ImageInputClient.
  • ImageInputClient never emits image-input:error. Picking a non-image file is silently ignored rather than reported; there is no drop path to report errors from either.

Use the custom element where any of these matter.

Modules

This exposes ESM and common JS via package.json exports field.

JS

import '@substrate-system/image-input'

HTML

<div>
    <image-input></image-input>
</div>

pre-built

This package exposes minified JS and CSS files too. Copy them to a location that is accessible to your web server, then link to them in HTML.

Copy

cp ./node_modules/@substrate-system/image-input/dist/index.min.js ./public/image-input.min.js
cp ./node_modules/@substrate-system/image-input/dist/style.min.css ./public/image-input.css

HTML

<head>
    <link rel="stylesheet" href="./image-input.css">
</head>
<body>
    <!-- ... -->
    <script type="module" src="./image-input.min.js"></script>
</body>