A browser-based pixel animation editor built with React + TypeScript + Vite.
See release notes for the v1.2.1 collaboration fixes.
BrushandEraserwith two brush sizes (1x,2x)Fill(bucket) toolSelectionmask tool with floating selection/stamp workflow- Smooth line interpolation while drawing fast
- Draw a near-straight stroke and hold still (~0.6s) to snap it to a line
- Draw a round loop back to your starting point and hold still to snap it to a circle
- Scribbles and sketch strokes never snap — the hold only offers a shape when the stroke already resembles one
- A countdown ring in your draw color shows the hold progress; works with touch
- Shape previews render before commit; drag to adjust, release to paint
- Long-press on canvas to activate eyedropper
- Hold progress indicator with drain animation
- Works with magnified color picker overlay (
MagnifyingGlass) - Dropper messaging and timing are tuned for intentional use
- Every frame has:
Baselayer (pixelData)Toplayer (overlayPixelData)
- Layers can be viewed as:
Stacked(top over base)Unstacked(side-by-side)
- App starts in stacked mode
- In stacked mode, drawing targets top layer
- Frame duplication is layer-aware:
- Unstacked: duplicates selected layer only
- Stacked: duplicates both layers together
- Timeline preview is layer-aware:
- Unstacked: active layer preview
- Stacked: composited preview
- During stacked timeline press-select, top-only preview is shown while holding
- Playback:
- Stacked: composite playback
- Unstacked: each canvas plays its own layer
- Onion skin:
- Stacked: composite onion
- Unstacked: onion of current layer only
- Selection acts as a mask and creates a floating stamp
Enterstamps floating content into the active layer- Hold
Enter+ arrow keys for smudge-like nudge+stamp - Hold
Enteralso auto-stamps after rotate/flip transforms - Context messages explain inside-mask vs outside-mask drawing
- Horizontal filmstrip with drag-and-drop frame reordering
- Multi-frame paint selection (long-press + drag)
- Batch actions:
- duplicate selected frames
- delete selected frames
- Number-key navigation (
1..8,9,0) - Mouse wheel vertical scroll maps to horizontal timeline scroll
- FPS controls for playback speed
- Up to 64 frames per project
- Up to 255 preset colors; colors used by art are encoded in a wider 65,535-color space
- Palette selector with bundled Lospec templates (
palettes/*.hex) and live previews of your art in each candidate palette - Apply a template two ways: convert the art to the nearest colors (perceptual OKLab matching) or keep the art's exact colors
- Edit any palette entry's hex directly — pixels drawn with it recolor instantly
- Import
.hexfiles as custom templates (persisted locally) and download the current palette as a Lospec-compatible.hexfile - Recent colors strip
- Pixel data is stored palette-indexed (
Uint16Array) so merged peer palettes do not corrupt colors
- Share an encrypted live-room link with one Guest; no application backend owns the project
- Per-pixel Yjs merging, independent frame navigation, colored canvas cursors, and timeline presence dots
- Full local IndexedDB cache on each live peer, plus normal JSON saves as the durable portable copy
- Collaborative undo affects only your own pixel actions on the active frame
- Palette conversion and project replacement require the other peer's awareness-based approval
- Either peer leaving ends the live room immediately; both keep the latest merged drawing as local projects
- One-shot copy links use a separate immutable snapshot room, expire after 30 minutes, and detach after receipt
- Public signaling is used by default; set
VITE_COLLAB_SIGNALINGto a comma-separatedws:///wss://list to override it - Join and copy attempts time out with a retryable error; an absent host never causes a guest to become Host
- Invite links also work when opened in a tab where the app is already running
- Save/load the full project as JSON (frames, layers, palette, FPS, project name)
- Export options with per-layer modes (
merged,base,top):- Current frame or selected frames as PNG
- Sprite sheet PNG
- Animated GIF (via
gifenc) - Frames as JSON (re-importable)
- Import multiple JSON frames; they splice in after the active frame
- Responsive layout with a dedicated mobile top bar and toolbar/timeline toggles
- Touch-first timeline gestures: long-press paint-select, direction-gated drag reordering, and edge auto-scroll
- Drag empty workspace area to pan
- Mouse wheel and trackpad/pinch zoom support in workspace
- Scrollbars are visually hidden while scrolling remains functional
- Tools:
B,E,F/G,S/M - Brush size:
[/] - Playback:
Space - Stamp:
Enter - Smudge: hold
Enter+ arrow keys - Rotate/Flip selection:
R,Shift+R,Shift+H,Shift+V - Undo/Redo:
Cmd/Ctrl+Z,Shift+Z,Cmd/Ctrl+Y - Frame operations:
Shift+N,Shift+Delete
- Context-sensitive top status label (tool hints, mask hints, dropper hints)
- Optional shortcuts panel (hidden by default on startup)
- Pixel-focused cursor/preview behavior for precision editing
- React 19
- TypeScript
- Vite
- zustand (editor state store)
- dnd-kit (timeline drag/sort)
- gifenc (GIF export)
- mousetrap (keyboard shortcuts)
- lucide-react (icons)
- Yjs, y-webrtc, and y-indexeddb (collaboration and peer persistence)
- fractional-indexing and nanoid (convergent frame order and collision-safe ids)
The frontend can be deployed to Vercel without hosting your own backend. y-webrtc is MIT-licensed and provides public signaling services that introduce browsers to each other. Drawing updates then travel over WebRTC. This setup uses external infrastructure, but does not require you to operate a signaling server or deploy the optional Docker container.
-
Push the updated app code to the Git branch connected to your Vercel project. Redeploying an older commit will not include local fixes.
-
In the project's Environment Variables, use these defaults for Production and Preview:
Variable Default setup VITE_COLLAB_SIGNALINGOmit it to use wss://y-webrtc-eu.fly.devautomatically. Delete empty orlocalhostoverrides.VITE_COLLAB_ICE_SERVERSOmit it to keep the library's default STUN servers. Do not copy the test value []into deployment settings. -
Build and deploy the updated commit. The Vite frontend uses
npm run buildand thedistoutput directory. Environment values are included at build time, so changes require a new build. Leaving a variable unset means removing it, not saving an empty value. -
Open the deployed site, select Share → Invite a collaborator, and send the generated link to the Guest. Keep the Host's tab open while the Guest joins. If clipboard access fails, select and copy the visible link manually.
The public endpoint is a dependency whose availability must be checked when a connection fails. Selecting it explicitly does not repair an outage or a network restriction. A custom service is an option, not a prerequisite for deployment.
Use separate browsers or devices. Confirm that the Guest joins as Guest, both browsers show a connected collaborator, and a stroke drawn in either browser appears in the other. Same-browser tabs can communicate through BroadcastChannel without working signaling or WebRTC, so they are not sufficient to verify a public deployment.
There are two separate connection stages:
- Signaling: both browsers must reach a common signaling endpoint. Inspect WebSocket failures if the app cannot connect to the collaboration service.
- WebRTC: after exchanging connection details, the browsers must establish a peer connection. A working signaling service does not guarantee this stage; network restrictions can require a TURN relay.
Join and copy attempts time out after 45 seconds of waiting for a peer. Live joins also allow up to 15 seconds to load their local cache first. Errors allow retry or cancellation without installing an unreceived project. An absent Host never causes a Guest to become Host. A copy offer requires its sender to stay online until receipt; a live invite is not a hosted saved project.
To use another compatible y-webrtc signaling service, set VITE_COLLAB_SIGNALING
to its wss:// URL in the Vercel environment settings and rebuild. Multiple URLs
can be comma-separated. Both peers must share at least one reachable endpoint.
HTTPS pages require wss://; ws://localhost is only for local development.
For optional self-hosting, the installed y-webrtc signaling server runs with
npm ci --omit=dev followed by npm run collab:signaling. It listens on PORT
(default 4444). Expose it through TLS with WebSocket upgrades enabled and keep
one instance: its in-memory topic subscriptions are not shared across replicas.
The HTTP / endpoint returns okay for health checks.
Dockerfile.signaling packages that optional persistent Node service:
docker build -f Dockerfile.signaling -t pixel-signaling .
docker run --rm -p 4444:4444 pixel-signalingConfigure TLS on the container host's ingress. This container is not deployed by the Vercel frontend build. A serverless signaling implementation would need a separate adapter; one is not included in this release.
Some mobile, corporate, or restrictive NAT networks need a TURN relay.
VITE_COLLAB_ICE_SERVERS accepts a JSON array of browser RTCIceServer entries and
replaces the default STUN list. All VITE_* values are visible in the browser:
use only credentials intended for browser clients, never account API keys or a
TURN shared authentication secret. Static build-time credentials cannot refresh
themselves; services requiring short-lived TURN credentials need a server-side
credential endpoint, which this app does not currently provide.
Public signaling works with local development too; no environment file is
required. To use a local signaling server instead, copy .env.example to
.env.local, uncomment the localhost signaling override, run
npm run collab:signaling, and start Vite in another terminal. Restart Vite after
changing .env.local.
npm installnpm run devnpm run buildnpx tsc --noEmitnpm test
npm run test:e2e
npm run lintTo run the collaboration tests against the built frontend:
COLLAB_E2E_PREVIEW=1 npm run test:e2eThe WebRTC integration cases require local UDP connectivity between browser
processes. If signaling connects but these cases time out, inspect the browser's
ICE diagnostics and local firewall/network permissions. Test configuration uses
host-only ICE (VITE_COLLAB_ICE_SERVERS=[]) so it does not depend on public STUN
servers; production should use the default STUN list or configured STUN/TURN.