@bayinformatics/croppie
v3.2.0
Published
A modern, TypeScript-first image cropper. Fork of Foliotek/Croppie.
Maintainers
Readme
@bayinformatics/croppie
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/croppieInstall 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').defaultOlder 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 | 270bind() 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 abovezoom.max),result()keeps the image's proportions: the image is drawn at its true scale, and the rest of the output is transparent orbackgroundColor.get().pointsstays 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 withinzoomConfigbounds (v2's width-only-scale/top-left-anchor quirk, Foliotek/Croppie#767, is intentionally not replicated). - v2 rotation needed
enableOrientation; in v3rotate()is always available and rotates clockwise (v2'srotate(90)turned counter-clockwise, see Foliotek/Croppie#543: negate the argument if you depended on it).pointsstay in the natural (EXIF-oriented) frame andget().rotationcarries 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:packagedist/ 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!
