@instantphoto/react
v0.4.3
Published
React components for instant film photo effects using WebGL
Maintainers
Readme
@instantphoto/react
React component library for applying authentic Polaroid 600 and Fujifilm Instax instant-film effects to images — entirely in the browser via WebGL.
- Zero server dependency — runs 100% client-side
- 300 DPI export for print-ready output
- Interactive pan, pinch-to-zoom, and scroll zoom
- Keyboard shortcuts (arrow keys, +/-, R, Ctrl+Z)
- Undo/redo transform history
- Batch processing API
- TypeScript-first, strict types, no
any
Installation
bun add @instantphoto/reactPeer dependencies — install separately if not already present:
bun add react react-dom # ^18 or ^19Live demo
Browse the component stories on GitHub Pages.
Quick start
import { InstantPhotoFrame } from '@instantphoto/react'
import '@instantphoto/react/styles.css'
export function App() {
return <InstantPhotoFrame src="/my-photo.jpg" />
}Components
<InstantPhotoFrame>
Static display component — renders a photo with film effects on a Polaroid/Instax card. No interactive pan/zoom.
import { useState } from 'react'
import { InstantPhotoFrame, type CaptureFn } from '@instantphoto/react'
import '@instantphoto/react/styles.css'
function PhotoCard() {
const [capture, setCapture] = useState<CaptureFn>()
return (
<>
<InstantPhotoFrame
src="/photo.jpg"
frameType="polaroid_600"
filmType="polaroid"
width={320}
onRender={fn => setCapture(() => fn)}
onError={err => console.error(err)}
/>
<button onClick={() => capture?.().then(blob => saveAs(blob, 'photo.png'))}>
Download image
</button>
<button onClick={() => capture?.({ target: 'frame' }).then(blob => saveAs(blob, 'frame.png'))}>
Download with frame
</button>
</>
)
}<InstantPhotoImageEditor>
Interactive editor with pan, pinch-to-zoom, scroll zoom, keyboard shortcuts, and undo/redo.
import { useState } from 'react'
import { InstantPhotoImageEditor, type CaptureFn, type InstantPhotoSettings } from '@instantphoto/react'
import '@instantphoto/react/styles.css'
function Editor() {
const [src, setSrc] = useState<string>()
const [capture, setCapture] = useState<CaptureFn>()
const [settings, setSettings] = useState<InstantPhotoSettings>()
return (
<>
<input type="file" onChange={e => setSrc(URL.createObjectURL(e.target.files![0]))} />
<InstantPhotoImageEditor
src={src}
frameType="polaroid_600"
filmType="polaroid"
width={480}
onRender={fn => setCapture(() => fn)}
onSettingsChange={setSettings}
onUndo={t => console.log('Undo to', t)}
onRedo={t => console.log('Redo to', t)}
/>
<button disabled={!capture} onClick={() => capture?.()}>
Export
</button>
{settings && (
<pre>{JSON.stringify(settings, null, 2)}</pre>
)}
</>
)
}<InstantPhotoEditor>
Higher-level convenience component that wraps InstantPhotoImageEditor and wires up a built-in upload button (shown when no image is loaded) and a delete button (shown as an overlay when an image is loaded).
import { useState } from 'react'
import { InstantPhotoEditor } from '@instantphoto/react'
import '@instantphoto/react/styles.css'
function App() {
const [src, setSrc] = useState<string>()
return (
<InstantPhotoEditor
src={src}
frameType="polaroid_600"
width={400}
onUpload={file => setSrc(URL.createObjectURL(file))}
onDelete={() => setSrc(undefined)}
/>
)
}- When
onUploadis provided andsrcis not set, a camera-icon upload button is centred inside the frame. - When
onDeleteis provided andsrcis set, a circular × button appears in the top-right corner of the image. - All
InstantPhotoImageEditorprops (exceptemptyState/imageOverlay) are forwarded as-is.
Keyboard shortcuts (click/focus the editor first):
| Key | Action |
|-----|--------|
| Arrow keys | Pan |
| + / = | Zoom in |
| - | Zoom out |
| r / R / 0 | Reset pan & zoom |
| Ctrl+Z / Cmd+Z | Undo |
| Ctrl+Y / Ctrl+Shift+Z | Redo |
Props
Shared props (InstantPhotoFrame and InstantPhotoImageEditor)
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| src | string \| HTMLImageElement \| ImageBitmap | — | Image source. InstantPhotoImageEditor makes this optional. |
| frameType | FrameType | 'polaroid_600' | Frame format. |
| filmType | FilmType | 'polaroid' | Film emulsion profile. |
| grainAmount | number | film default | Grain intensity 0–1. |
| grainSizePx | number | 2.08 | Grain particle size in output pixels. |
| grainColorAmount | number | 1.0 | Colored grain: 0 = monochrome, 1 = subtle color. |
| halationAmount | number | film default | Bloom/halation intensity 0–1. |
| vignetteIntensity | number | film default | Vignette intensity 0–1. |
| chromaticShift | number | film default | Chromatic aberration in source pixels. |
| saturationDelta | number | film default | Saturation shift –100 to +100. |
| filmCurveAmount | number | 1.0 | Film color curve blend 0–1. |
| shadowWideIntensity | number | 0.31 | Large inbound shadow intensity. |
| seed | number | 0 | Grain seed. 0 = random each render. |
| width | number \| string | '100%' | CSS width of the frame. |
| className | string | — | Extra CSS class on the frame div. |
| style | CSSProperties | — | Inline styles on the frame div. |
| onRender | (capture: CaptureFn) => void | — | Called after each render with a capture function. |
| onError | (error: Error) => void | — | Called on WebGL or image loading errors. |
InstantPhotoImageEditor-only props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| emptyState | React.ReactNode | — | Rendered centred inside the image area when src is not set. |
| imageOverlay | React.ReactNode | — | Rendered on top of the photo when src is set. Wrapper has pointer-events: none; add pointer-events: auto to interactive children. |
| maxZoom | number | 5 | Maximum zoom factor. |
| onRenderDelay | number | 600 | Debounce ms before onRender fires after a gesture. |
| liveUpdateDuringGesture | boolean | true | Full effects during drag (true) or raw preview (false). |
| onTransformChange | (transform: ImageTransform) => void | — | Called on every gesture event with updated pan/zoom. |
| onSettingsChange | (settings: InstantPhotoSettings) => void | — | Called when transform or effect params change. |
| onUndo | (transform: ImageTransform) => void | — | Called after keyboard undo is applied. |
| onRedo | (transform: ImageTransform) => void | — | Called after keyboard redo is applied. |
InstantPhotoEditor-only props
Accepts all InstantPhotoImageEditor props (except emptyState/imageOverlay) plus:
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| onUpload | (file: File) => void | — | Called when the user picks a file. Shows an upload button when set. |
| onDelete | () => void | — | Called when the delete overlay is clicked. Shows a delete button when set and src is provided. |
| accept | string | 'image/*' | accept attribute forwarded to the hidden file input. |
Frame types
| FrameType | Physical format | Canvas size |
|-------------|-----------------|-------------|
| polaroid_600 | Polaroid 600 (79×79 mm) | 933×933 px |
| instax_mini | Fujifilm Instax Mini (46×62 mm) | 543×732 px |
| instax_square | Fujifilm Instax Square (62×62 mm) | 732×732 px |
| instax_wide | Fujifilm Instax Wide (99×62 mm) | 1169×732 px |
All canvas dimensions are at 300 DPI, so exported images are print-ready at the authentic physical size.
Film types
| FilmType | Description |
|------------|-------------|
| polaroid | Polaroid 600 emulation — warm tones, prominent grain, strong vignette |
| instax | Fujifilm Instax emulation — cooler tones, lighter grain and vignette |
| original | No film emulation — only inbound shadow applied |
Capture API
onRender provides a stable CaptureFn that can be called at any time:
type CaptureFn = (options?: CaptureOptions) => Promise<Blob | null>
interface CaptureOptions {
target?: 'image' | 'frame' // 'image' = canvas only; 'frame' = full frame with white border
format?: 'image/png' | 'image/jpeg' | 'image/webp'
quality?: number // 0–1, for JPEG/WebP only
}// Export just the photo at 300 DPI:
const blob = await capture()
// Export the full frame (white border included):
const blob = await capture({ target: 'frame', format: 'image/jpeg', quality: 0.9 })Batch processing
Process multiple images off-screen with a single shared WebGL pipeline:
import { batchProcess } from '@instantphoto/react'
const blobs = await batchProcess(
files.map(f => ({ src: URL.createObjectURL(f) })),
{
frameType: 'instax_mini',
glOptions: { filmType: 'instax' },
captureOptions: { target: 'frame', format: 'image/jpeg', quality: 0.92 },
onProgress: (done, total) => setProgress(done / total),
}
)Settings export
InstantPhotoSettings is a serialisable snapshot of all editor parameters at a point in time. Use it to persist or restore editor state, or embed it alongside an exported image:
import type { InstantPhotoSettings } from '@instantphoto/react'
<InstantPhotoImageEditor
onSettingsChange={(settings: InstantPhotoSettings) => {
localStorage.setItem('editor-settings', JSON.stringify(settings))
}}
/>Browser support
| Feature | Chrome | Firefox | Safari | Edge |
|---------|--------|---------|--------|------|
| WebGL 1.0 | 8+ | 4+ | 5.1+ | 12+ |
| createImageBitmap | 50+ | 42+ | 10.1+¹ | 79+ |
| ResizeObserver | 64+ | 69+ | 13.1+ | 79+ |
| CSS aspect-ratio | 88+ | 89+ | 15+ | 88+ |
| Pointer Events | 55+ | 59+ | 13+ | 12+ |
¹ imageOrientation: 'flipY' is not supported in Safari < 17. The library automatically falls back to a canvas-based Y-flip, so rendering is correct on all Safari versions.
Minimum recommended versions: Chrome 88, Firefox 89, Safari 15, Edge 88.
Performance tips
- Set
liveUpdateDuringGesture={false}on low-end or mobile devices for smooth 60fps pan/zoom (uses a lightweight raw-crop preview during gesture, renders full effects on release). - Use
seed> 0 for deterministic grain (avoids visual jitter during interactions). - Use
batchProcessinstead of mounting multiple editors when processing many images. - The canvas always renders at 300 DPI. On high-DPI displays the CSS scale factor handles visual sharpness — no need to double the canvas size.
Development
# Install dependencies
bun install
# Run unit tests (watch mode)
bun test
# Run unit tests once with coverage report
bun run test:coverage
# Run Storybook (component explorer)
bun run storybook
# Run E2E tests against a built Storybook
bun run test:e2e
# Full local CI parity (lint, typecheck, coverage, build, storybook, e2e)
bun run test:ci
# Build the package
bun run build
# Lint and typecheck
bun run lint
bun run typecheckReleasing
This package uses Changesets for versioning and publishing.
# Document your change (creates a changeset file)
bun run changeset
# Apply pending changesets and bump the version
bun run version
# Build and publish to npm (done automatically by CI on merge to main)
bun run releaseOn every push to main, the release workflow either opens a version-bump PR (if there are pending changesets) or publishes to npm when that PR is merged. Add an NPM_TOKEN secret in the repository settings for publishing to work.
