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

@bayinformatics/croppie

v3.2.0

Published

A modern, TypeScript-first image cropper. Fork of Foliotek/Croppie.

Readme

@bayinformatics/croppie

npm version npm downloads license ci codecov

A modern, TypeScript-first image cropper for the web. Fork of Foliotek/Croppie.

Live demo: https://bayinformatics.github.io/croppie/

Highlights

  • 🦕 ES Modules - ESM-first, tree-shakeable, no UMD/IIFE wrappers
  • 📘 TypeScript - Full type definitions included
  • 🔧 Modern APIs - Pointer Events + touch gestures, no polyfills
  • 📱 Mobile First - Touch and gesture support built in
  • 🧪 Tested - Unit, integration, and visual regression (Playwright)

Why This Fork?

The original Croppie has been largely inactive for years. This fork modernizes the codebase, keeps types first-class, and aligns the API with modern tooling.

Installation

# npm
npm install @bayinformatics/croppie

# pnpm
pnpm add @bayinformatics/croppie

# bun
bun add @bayinformatics/croppie

Install from npm. The repository does not contain built files, so installing from a git URL does not work.

Compatibility

This is an ESM-only package for Node 20 or newer. It works with modern bundlers like Vite, Webpack, Rollup, Next.js, and Bun.

Breaking Change in v3: v2 shipped UMD (AMD, CommonJS and a global); v3 is ES modules only.

- const Croppie = require('croppie')
+ import Croppie from '@bayinformatics/croppie'

CommonJS require() works natively on Node 20.19+ and 22.12+, which can load an ES module from CommonJS, through the package's default export condition. The result is the module namespace:

const { Croppie } = require('@bayinformatics/croppie')
// or: const Croppie = require('@bayinformatics/croppie').default

Older runtimes and bundlers that cannot require() an ES module should use a dynamic import():

(async () => {
  const { default: Croppie } = await import('@bayinformatics/croppie')
  // use Croppie here
})()

For <script> tag usage without a bundler, this fork is not for you — use the original Croppie v2.x instead.

Quick Start

import Croppie from '@bayinformatics/croppie'
import '@bayinformatics/croppie/croppie.css'

const cropper = new Croppie(document.getElementById('cropper')!, {
  viewport: { width: 200, height: 200, type: 'circle' }
})

// Load an image
await cropper.bind({ url: 'photo.jpg' })

// Get the cropped result
const blob = await cropper.result({ type: 'blob' })

The stylesheet is also available as @bayinformatics/croppie/style.css, an alias of croppie.css.

API

Constructor

new Croppie(element: HTMLElement, options: CroppieOptions)

Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | viewport | { width, height, type } | Required | Crop area dimensions and shape ('circle' or 'square') | | boundary | { width, height } | viewport + 100px | Container dimensions | | showZoomer | boolean | true | Show zoom slider | | mouseWheelZoom | boolean \| 'ctrl' | true | Enable scroll zoom (optionally require Ctrl key) | | enableZoom | boolean | true | Let the user zoom with the slider, wheel and pinch. false removes all three (the slider is not rendered even with showZoomer); setZoom() and zoom = still work | | zoom | { min?, max?, enforceMinimumCoverage? } | { max: 10 }, min per image | Zoom limits and coverage enforcement. When min is not set, the minimum is the zoom at which the image just covers the viewport, so a large photo can zoom out further than 0.1; with enforceMinimumCoverage: false it is min(0.1, the zoom at which the whole image fits). A configured min is a floor. The effective minimum never exceeds max | | customClass | string | — | Extra class for the container | | enableExif | boolean | false | Reads the EXIF Orientation tag of JPEGs bound via bindFile() or as data URLs and exposes it as get().orientation. Browsers already display EXIF-oriented images upright; this never rotates pixels. Remote URLs are not read; use the exported readJpegOrientation() on bytes you fetch | | enableResize | boolean | false | Reserved for v2 compatibility (not implemented) | | enableOrientation | boolean | false | Deprecated, no effect: rotate() is always available |

A viewport or boundary dimension, zoom.min and zoom.max may also be numeric strings (such as data attribute values: "200"); they are converted to numbers. Invalid options throw a RangeError from the constructor: a viewport or boundary dimension, zoom.min or zoom.max that is not a positive finite number (a blank or non-numeric string is not), or a configured zoom.min greater than zoom.max (or than the default max of 10). A lone zoom.max below 0.1 is fine: the minimum is then per image and capped at zoom.max. A boundary smaller than the viewport only logs a warning.

Methods

bind(options: BindOptions | string): Promise<void>

Load an image into the cropper.

// Simple URL
await cropper.bind('photo.jpg')

// With options
await cropper.bind({
  url: 'photo.jpg',
  zoom: 1.5,
  points: { topLeftX: 0, topLeftY: 0, bottomRightX: 200, bottomRightY: 200 }
})

points can be an object ({ topLeftX, topLeftY, bottomRightX, bottomRightY }) or the v2-style array [x1, y1, x2, y2]. Malformed points (an array without exactly 4 entries, a coordinate that is not a number, a rect without width or height) are ignored with a console warning, and the image gets its default framing.

bind() rejects with an error for an image that has no intrinsic size (0×0, for example an SVG without width and height). Only the newest bind applies its image and fulfills: if you call bind() or bindFile() again before the previous image has loaded, the earlier call rejects with a DOMException named AbortError (message bind() was superseded by a later bind() call) without applying anything, also when the later call then fails to load. A bind that is still loading when you call destroy() rejects the same way (instance destroyed during bind()). A call rejected before it starts loading changes nothing and supersedes nothing: bind() with an invalid rotation (a RangeError), bindFile() given something that is not a File or Blob (a TypeError), and bind() or bindFile() on a destroyed instance. Malformed points do not make bind() fail (see above), so such a bind still wins like any other. bind() emits one update when it completes.

try {
  await cropper.bind(url)
} catch (error) {
  // A later bind replaced this one, or the cropper was destroyed: nothing to report
  if ((error as Error).name !== 'AbortError') throw error
}

Note: initial points are applied on bind — the transform is derived so the viewport shows the requested region. Aspect-matched points round-trip exactly through get() while the derived zoom stays within zoom.min/zoom.max; mismatched-aspect points are cover-fit and center-preserved at the applied (clamped) zoom.

bindFile(file: File | Blob, options?: BindFileOptions): Promise<void>

Load an image from a File input. options are those of bind() without url (rotation, orientation, points, zoom), validated and applied the same way, so a file can be bound again with what get() returned: await cropper.bindFile(file, { points, zoom, rotation }).

const input = document.querySelector('input[type="file"]')
input.addEventListener('change', async (e) => {
  const file = (e.target as HTMLInputElement).files?.[0]
  if (!file) return
  try {
    await cropper.bindFile(file)
  } catch (error) {
    if ((error as Error).name !== 'AbortError') throw error
  }
})

result(options: ResultOptions)

Get the cropped result. The return type follows options.type:

result(options: ResultOptions & { type: 'blob' }): Promise<Blob>
result(options: ResultOptions & { type: 'base64' }): Promise<string>
result(options: ResultOptions & { type: 'canvas' }): Promise<HTMLCanvasElement>
result(options: ResultOptions): Promise<Blob | string | HTMLCanvasElement>  // type only known at runtime
// Get as Blob (for uploading)
const blob = await cropper.result({ type: 'blob', format: 'png' })

// Get as base64 (for preview)
const base64 = await cropper.result({ type: 'base64', format: 'jpeg', quality: 0.9 })

// Get as Canvas (for further manipulation)
const canvas = await cropper.result({ type: 'canvas' })

// Custom output size
const blob = await cropper.result({
  type: 'blob',
  size: { width: 400, height: 400 }
})

get(): CroppieData

Get current crop data: points, zoom, rotation and, with enableExif, orientation.

points are in the natural frame: the pixel space of the image as the browser decoded it (EXIF orientation already applied). They are never rotated; rotation tells you how result() turns the crop, and bind({ points, rotation }) round-trips them exactly. orientation is the EXIF tag of the file: informational, never derived from rotation and never changed by rotate().

To reproduce the crop on a server, auto-orient the original (sharp .rotate(), ImageMagick -auto-orient), crop points, then rotate the crop clockwise by rotation.

setZoom(value: number): void

Set the zoom level programmatically. The value is clamped to the zoom limits and the image zooms about the viewport center; a numeric string is converted, and a non-finite value or a blank or non-numeric string is ignored. Emits update and zoom when the clamped zoom changed.

zoom: number

Getter and setter for the current zoom level. Setting it clamps to the zoom limits, like setZoom().

reset(): void

Restores the rotation bind() started with, re-centers the image and returns the zoom to the coverage zoom of that rotation (the smallest zoom at which the image covers the viewport, clamped to the zoom limits). Emits rotate when the rotation changed, always emits update, and then zoom when the zoom changed. Does nothing before an image is bound.

on(event, handler): void / off(event, handler): void

Subscribe to or unsubscribe from events.

rotate(degrees: number): void

Rotate the image clockwise by degrees: any multiple of 90, positive or negative (-90 turns counter-clockwise); anything else throws a RangeError. The image pixel under the viewport center stays there unless the rotated image would then no longer cover the viewport, in which case the image moves the least needed. The zoom limits are recomputed for the rotated image, so with a non-square viewport a quarter turn can raise the zoom to the new minimum. Emits rotate, then update (and zoom if the zoom changed). Does nothing before an image is bound. reset() restores the rotation bind() started with. result() renders the rotated image.

cropper.rotate(90)   // clockwise
cropper.rotate(-90)  // counter-clockwise
cropper.get().rotation // 0 | 90 | 180 | 270

bind() also accepts rotation (a multiple of 90) for the initial rotation. points are not affected by it (see get() below). bind({ orientation }) (EXIF 1–8) is an explicit override for images whose tag was stripped: 1, 3, 6 and 8 map to a rotation of 0, 180, 90 and 270 (mirrored values are ignored with a warning), and an explicit rotation wins. Prefer rotation. If the file still carries its own tag, the browser already shows it upright, so an explicit orientation can rotate it twice (Croppie warns when enableExif shows this).

destroy(): void

Clean up and remove the cropper. It is safe to call more than once. Afterwards bind(), bindFile() and result() reject with a ... called on a destroyed instance error, setZoom(), zoom = and reset() do nothing, and get() returns zeroed points with the initial zoom of 1.

Result Options

| Option | Type | Default | Description | |--------|------|---------|-------------| | type | 'blob' \| 'base64' \| 'canvas' | Required | Output type | | size | { width, height } \| 'viewport' \| 'original' | 'viewport' | Output size. 'original' is the viewport area at image resolution. An 'original' or custom size is scaled down, keeping its shape, to at most 16,777,216 px (4096×4096) and 16,384 px a side, so the canvas stays within what browsers can allocate (iOS Safari draws nothing on a larger one), then rounded to whole pixels; a custom width or height that is not a positive finite number rejects with a RangeError. A size of another shape than the viewport keeps the proportions, with transparent or backgroundColor bars | | format | 'png' \| 'jpeg' \| 'webp' | 'png' | Output format for blob/base64 | | quality | number | 0.92 | JPEG/WebP quality (0-1) | | circle | boolean | viewport.type === 'circle' | Apply circular mask | | backgroundColor | string | — | Fill background for transparent images |

Events

cropper.on('update', (data) => {
  console.log('Crop changed:', data.points, data.zoom)
})

cropper.on('zoom', ({ zoom, previousZoom }) => {
  console.log(`Zoom: ${previousZoom} → ${zoom}`)
})

cropper.on('rotate', ({ rotation, previousRotation }) => {
  console.log(`Rotation: ${previousRotation}° → ${rotation}°`)
})

update carries the same data as get(); zoom carries { zoom, previousZoom }; rotate carries { rotation, previousRotation } and fires from rotate() and from reset() when it restores a different rotation. Within one change rotate fires first, then update, then zoom. Nothing is emitted when nothing changed (for example a zoom request that is clamped to the current zoom, or a drag the bounds absorb entirely).

| Source | rotate | update | zoom | |--------|----------|----------|--------| | bind() completes | no | once, with the initial data | no | | Dragging the image | no | only when the (clamped) position changed | no | | Slider, mouse wheel, pinch, setZoom(), zoom = | no | only when the clamped zoom changed | only when the clamped zoom changed | | rotate() | always | always | only when the zoom changed (a quarter turn can raise it to the new minimum) | | reset() | only when the bind-time rotation differs from the current one | always | only when the zoom changed |

rotate() with 0 or a full turn, before an image is bound or after destroy() does nothing and emits nothing. When an update listener zooms again, its own zoom event reports the final zoom and the change that caused the update emits none, so zoom never reports a value that was already replaced.

Zooming keeps the point under the cursor (mouse wheel), between the fingers (pinch) or at the viewport center (slider, setZoom()) fixed. One mouse-wheel notch (100px, or 3 lines for a mouse that scrolls by lines) zooms by ×1.1; trackpad scrolling zooms proportionally to the scroll distance. A second finger touching down ends a drag, so a pinch does not also pan; when the fingers of a pinch lift until one is left, that finger pans again.

Zoom and accessibility

  • The zoom slider has the accessible name "Zoom" and announces its value as a percentage (aria-valuetext, for example "150%"), also before the first image is bound. Keyboard focus shows a visible ring in every browser.
  • If the image does not cover the viewport (zoomed out that far with enforceMinimumCoverage: false, or so small that the zoom it needs to cover it is above zoom.max), result() keeps the image's proportions: the image is drawn at its true scale, and the rest of the output is transparent or backgroundColor. get().points stays clamped to the image.
  • A 'circle' viewport that is not square is an ellipse, in the overlay and in the output mask.

Theming

The colors are CSS custom properties, set on :root by croppie.css. Override them anywhere in your own stylesheet:

.my-cropper {
  --croppie-boundary-bg: #202020;
  --croppie-slider-start: #0ea5e9;
  --croppie-slider-end: #7dd3fc;
}

| Property | Default | Used for | |----------|---------|----------| | --croppie-boundary-bg | #1a1a2e (#0f0f1a in dark mode) | Background of the crop area | | --croppie-slider-start / --croppie-slider-end | #4f46e5 / #818cf8 | Zoom slider gradient | | --croppie-slider-thumb | #ffffff | Slider thumb | | --croppie-slider-shadow, --croppie-slider-shadow-hover, --croppie-slider-shadow-focus | translucent indigo | Slider thumb glow in its normal, hover and focus states |

Dark mode: --croppie-boundary-bg switches to #0f0f1a under @media (prefers-color-scheme: dark) and under [data-theme="dark"] (for DaisyUI and similar frameworks).

Migrating from Croppie v2

Quick Reference

| v2 (Original) | v3 (This Fork) | |---------------|----------------| | $('#el').croppie({...}) | new Croppie(element, {...}) | | croppie.bind(url) | await croppie.bind(url) | | croppie.bind({ url, points: [x1,y1,x2,y2] }) | await croppie.bind({ url, points: [x1,y1,x2,y2] }) (an object {topLeftX, topLeftY, bottomRightX, bottomRightY} also works) | | enforceBoundary | zoom: { enforceMinimumCoverage } | | minZoom / maxZoom | zoom: { min, max } | | enableOrientation: true | not needed: rotate() is always available | | croppie.rotate(90) | cropper.rotate(-90): v2 turned counter-clockwise (Foliotek/Croppie#543), v3 turns clockwise | | bind({ url, orientation }) | prefer bind({ url, rotation }); get().orientation is informational only | | croppie.result({...}).then(cb) | const result = await croppie.result({...}) | | $el.on('update', cb) | croppie.on('update', cb) | | import 'croppie/croppie.css' | import '@bayinformatics/croppie/croppie.css' |

Key Differences from Croppie v2

  • v2 shipped UMD (AMD/CommonJS/global); v3 is ESM-only.
  • v2 bind() points/relative points are fully supported; v3 applies points on bind with cover-fit + center preservation within zoomConfig bounds (v2's width-only-scale/top-left-anchor quirk, Foliotek/Croppie#767, is intentionally not replicated).
  • v2 rotation needed enableOrientation; in v3 rotate() is always available and rotates clockwise (v2's rotate(90) turned counter-clockwise, see Foliotek/Croppie#543: negate the argument if you depended on it). points stay in the natural (EXIF-oriented) frame and get().rotation carries the turn.
  • v2 supported <script> tag usage; v3 requires a bundler.

Detailed Changes

- import Croppie from 'croppie'
+ import Croppie from '@bayinformatics/croppie'

- import 'croppie/croppie.css'
+ import '@bayinformatics/croppie/croppie.css'

// result() now returns a Promise for all types
- cropper.result({ type: 'canvas' }).then(canvas => {})
+ const canvas = await cropper.result({ type: 'canvas' })

// Zoom limits moved into the zoom option
- minZoom: 0.5, maxZoom: 3, enforceBoundary: true
+ zoom: { min: 0.5, max: 3, enforceMinimumCoverage: true }

// Points: the array form still works; an object is also accepted
points: [x1, y1, x2, y2]
points: { topLeftX, topLeftY, bottomRightX, bottomRightY }

Framework Examples

Stimulus (Hotwire)

import { Controller } from '@hotwired/stimulus'
import Croppie from '@bayinformatics/croppie'

export default class extends Controller {
  static targets = ['input', 'preview']

  croppie?: Croppie

  connect() {
    this.croppie = new Croppie(this.previewTarget, {
      viewport: { width: 200, height: 200, type: 'circle' }
    })
  }

  async selectFile(event: Event) {
    const file = (event.target as HTMLInputElement).files?.[0]
    if (!file) return
    try {
      await this.croppie?.bindFile(file)
    } catch (error) {
      // Another file was chosen, or the controller disconnected, before this one loaded
      if ((error as Error).name !== 'AbortError') throw error
    }
  }

  async crop() {
    return this.croppie?.result({ type: 'blob' })
  }

  disconnect() {
    this.croppie?.destroy()
  }
}

React

import { useRef, useEffect } from 'react'
import Croppie from '@bayinformatics/croppie'

function ImageCropper({ src, onCrop }) {
  const containerRef = useRef<HTMLDivElement>(null)
  const croppieRef = useRef<Croppie | null>(null)

  useEffect(() => {
    if (containerRef.current) {
      croppieRef.current = new Croppie(containerRef.current, {
        viewport: { width: 200, height: 200, type: 'circle' }
      })
      croppieRef.current.bind(src).catch((error) => {
        // The effect was cleaned up (src changed, or unmount) before the image loaded
        if (error.name !== 'AbortError') console.error(error)
      })
    }
    return () => croppieRef.current?.destroy()
  }, [src])

  const handleCrop = async () => {
    const blob = await croppieRef.current?.result({ type: 'blob' })
    onCrop(blob)
  }

  return (
    <div>
      <div ref={containerRef} />
      <button onClick={handleCrop}>Crop</button>
    </div>
  )
}

Development

# Install dependencies (use the Bun version in .bun-version)
bun install --frozen-lockfile

# Run the tests
bun run test

# Lint (sources, tests, scripts and Playwright config) and type-check (the same, minus scripts)
bun run lint
bun run typecheck

# Build for production (cleans dist/ first)
bun run build

# Watch build into dist/
bun run dev

# Visual regression tests (Playwright, against the built bundle)
bun run test:visual

# Check the packed package with publint and Are the Types Wrong?
bun run check:package

dist/ and the demo bundle in docs/ are build output: they are git-ignored and CI builds them for npm and GitHub Pages, so do not commit them. See CONTRIBUTING.md for the full workflow.

Visual regression runs in CI using Playwright against test fixtures in tests/visual/.

License

MIT - See LICENSE

Original work Copyright (c) 2015 Foliotek Inc. Modified work Copyright (c) 2026 Bay Informatics

Credits

This project is a fork of Croppie by Foliotek. Thanks to the original authors for their work!