The open-source browser recording library behind Reechy.
Screen + camera PIP · draggable overlay · shape masks · audio mixing · zero server · TypeScript.
// Screen + camera PIP recording in 5 lines of code
const recorder = new BrowserRecorder({ screen: true, pip: { shape: 'rounded' } })
await recorder.init()
recorder.start()
// ... user records ...
const blob = await recorder.stop() // → WebM with composited screen + cameraRecordRTC hasn't shipped a release since 2021. No TypeScript, no PIP, no React hooks, 180KB.
| Feature | RecordRTC | @reechy-tools/recorder |
|---|---|---|
| TypeScript — native, strict | ❌ | ✅ |
| Screen + camera PIP | ❌ | ✅ Canvas compositing |
| Draggable & resizable PIP | ❌ | ✅ Pointer events + rAF |
| PIP shape masks (circle, rounded, square) | ❌ | ✅ |
| Audio mixing without crashing MediaRecorder | ❌ | ✅ AudioContext graph |
| Camera switch with freeze-frame (no flash) | ❌ | ✅ |
| Add screen share mid-recording | ❌ | ✅ |
| Max duration with auto-stop | ❌ | ✅ |
React hooks (useRecorder, usePip) |
❌ | ✅ |
Tailwind-ready data-* state attributes |
❌ | ✅ |
| Structured errors with recovery hints | ❌ | ✅ |
AI-friendly (CLAUDE.md, llms.txt) |
❌ | ✅ |
| Bundle size | ~180KB | ~10KB gzipped |
| Last updated | 2021 | Active |
npm install @reechy-tools/recorder
# pnpm add @reechy-tools/recorder
# yarn add @reechy-tools/recorderReact hooks are in a separate subpath — React is a peer dependency:
import { useRecorder, usePip } from '@reechy-tools/recorder/react'import { BrowserRecorder } from '@reechy-tools/recorder'
const recorder = new BrowserRecorder({
camera: { width: 1280, height: 720, facingMode: 'user' },
screen: true, // enable screen share + PIP
pip: { shape: 'rounded', mirrored: true },
audio: true,
countdown: 3, // 3-second countdown before recording
codec: 'auto', // VP9 → VP8 → H264 → MP4
maxDuration: 300, // auto-stop at 5 minutes
})
// 1. Request permissions (call on user gesture)
await recorder.init()
// 2. Bind to video elements
cameraVideoEl.srcObject = recorder.getCameraStream()
// 3. Listen to events
recorder.on('state-change', (state) => console.log(state))
recorder.on('countdown', (n) => showCountdown(n))
recorder.on('duration', (ms) => updateTimer(ms))
recorder.on('duration-limit', () => console.log('Time is up — auto-stopped'))
recorder.on('error', (err) => console.error(err.code, err.recovery))
// 4. Start (with countdown)
recorder.start()
// 5. Mid-recording controls
recorder.toggleMic() // mute/unmute
recorder.toggleCamera() // hide/show camera
await recorder.toggleScreenShare() // add/remove screen share
await recorder.switchCamera() // front ↔ back (mobile)
recorder.setPipPosition({ x: 25, y: 75, width: 30 })
recorder.setPipShape('circle')
// 6. Stop → Blob
const blob = await recorder.stop() // → Blob (WebM)
const url = URL.createObjectURL(blob)import { useRef } from 'react'
import { useRecorder, usePip } from '@reechy-tools/recorder/react'
export function RecordingPage() {
const containerRef = useRef<HTMLDivElement>(null)
const recorder = useRecorder({
screen: true,
pip: { shape: 'rounded', mirrored: true },
maxDuration: 300,
})
const pip = usePip({
containerRef,
initialPosition: { x: 75, y: 75, width: 22 },
onPositionChange: (pos) => recorder.setPipPosition(pos),
})
const handleStop = async () => {
const blob = await recorder.stop()
const url = URL.createObjectURL(blob)
// upload, play back, or download
}
return (
// Spread dataAttrs → Tailwind can target [data-recording], [data-paused], etc.
<div
ref={containerRef}
{...recorder.dataAttrs}
className="relative aspect-video w-full
[&[data-recording]]:ring-2 [&[data-recording]]:ring-red-500
[&[data-paused]]:opacity-60"
>
{/* Screen share background */}
{recorder.screenStream && (
<video
ref={el => { if (el) el.srcObject = recorder.screenStream }}
autoPlay muted playsInline
className="h-full w-full object-contain"
/>
)}
{/* Draggable camera PIP */}
{recorder.cameraStream && recorder.hasScreenShare && (
<div {...pip.dragProps} style={{ ...pip.style, borderRadius: 12, overflow: 'hidden' }}>
<video
ref={el => { if (el) el.srcObject = recorder.cameraStream }}
autoPlay muted playsInline
className="h-full w-full object-cover [transform:scaleX(-1)]"
/>
<div {...pip.resizeProps} />
</div>
)}
{/* Camera only (no screen share) */}
{recorder.cameraStream && !recorder.hasScreenShare && (
<video
ref={el => { if (el) el.srcObject = recorder.cameraStream }}
autoPlay muted playsInline
className="h-full w-full object-cover [transform:scaleX(-1)]"
/>
)}
{/* Countdown overlay */}
{recorder.countdown !== null && (
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-[120px] font-bold text-white">{recorder.countdown}</span>
</div>
)}
{/* Controls */}
<div className="absolute bottom-4 left-0 right-0 flex justify-center gap-2">
{recorder.state === 'idle' && <button onClick={recorder.init}>Allow Camera</button>}
{recorder.state === 'ready' && <button onClick={recorder.start}>Record</button>}
{recorder.isRecording && <>
<button onClick={recorder.pause}>Pause</button>
<button onClick={handleStop}>Stop</button>
<button onClick={recorder.toggleScreenShare}>
{recorder.hasScreenShare ? 'Stop Screen' : 'Share Screen'}
</button>
</>}
</div>
</div>
)
}| Option | Type | Default | Description |
|---|---|---|---|
camera |
CameraConfig | boolean |
true |
Camera constraints or false to disable |
screen |
boolean |
false |
Enable screen capture (getDisplayMedia) |
pip.position |
PipPosition |
bottom-right | PIP position as percentages of container |
pip.shape |
PipShape |
'rounded' |
circle, rounded, square, or none |
pip.mirrored |
boolean |
true |
Mirror camera horizontally |
audio |
AudioConfig | boolean |
true |
Audio constraints or false to disable |
countdown |
number |
3 |
Countdown seconds before recording (0 to skip) |
codec |
CodecPreference |
'auto' |
vp9, vp8, h264, av1, auto |
frameRate |
number |
30 |
Canvas recording frame rate |
maxDuration |
number |
undefined |
Max recording seconds — auto-stops + emits duration-limit |
{ x: number, y: number, width: number }
// All values are percentages (0–100) relative to the container
// x, y = center point of the PIP
// width = PIP width (height derived from camera aspect ratio)recorder.on('state-change', (state: RecorderState) => void) // idle|ready|countdown|recording|paused|stopping
recorder.on('data', (blob: Blob) => void) // raw MediaRecorder data chunks
recorder.on('error', (err: RecorderError) => void)
recorder.on('countdown', (seconds: number) => void) // 3, 2, 1
recorder.on('duration', (ms: number) => void) // fires every 100ms
recorder.on('duration-limit', () => void) // maxDuration reached, auto-stop imminent
recorder.on('camera-change', (enabled: boolean) => void) // toggleCamera() result
recorder.on('screen-change', (stream: MediaStream | null) => void) // screen share started/stopped
recorder.on('screen-ended', () => void) // user stopped sharing via browser UIAll the above, plus:
recorder.dataAttrs // spread on your container element
// {
// 'data-state': 'idle' | 'ready' | 'countdown' | 'recording' | 'paused' | 'stopping'
// 'data-recording'?: '' // present only when recording
// 'data-paused'?: ''
// 'data-countdown'?: ''
// 'data-has-screen'?: ''
// 'data-error'?: ''
// 'data-duration-limit'?: ''
// }Every RecorderError has code, message, and recovery (user-facing hint).
| Code | When |
|---|---|
CAMERA_PERMISSION_DENIED |
User denied camera access |
CAMERA_NOT_FOUND |
No camera device available |
CAMERA_IN_USE |
Camera already in use by another app |
CAMERA_SWITCH_FAILED |
Failed to switch front/back camera |
SCREEN_SHARE_DENIED |
User cancelled screen picker |
SCREEN_SHARE_NOT_SUPPORTED |
Mobile or old browser |
CODEC_NOT_SUPPORTED |
No supported codec found |
RECORDER_NOT_INITIALIZED |
init() not called before start() |
ALREADY_RECORDING |
start() called while already recording |
NOT_RECORDING |
stop()/pause() called when not recording |
AUDIO_CONTEXT_FAILED |
AudioContext creation failed (iOS needs user gesture) |
CANVAS_CAPTURE_NOT_SUPPORTED |
Safari — use a supported browser |
| Browser | Camera | Screen | PIP |
|---|---|---|---|
| Chrome 88+ | ✅ | ✅ | ✅ |
| Firefox 84+ | ✅ | ✅ | ✅ |
| Edge 88+ | ✅ | ✅ | ✅ |
| Safari 15+ | ✅ | ❌ | ❌ |
| iOS Safari | ✅ | ❌ | ❌ |
| Chrome Android | ✅ | ❌ | ❌ |
examples/vanilla/— plain HTML + JS, no build stepexamples/react/— React withuseRecorder+usePip
Run locally:
git clone https://github.com/reechy-tools/recorder
cd recorder && npm install
npm run dev # builds + serves at http://localhost:4321
# open http://localhost:4321/examples/vanilla/Add this badge to your project README:
[](https://github.com/reechy-tools/recorder)Built and battle-tested at Reechy — browser-based video pitch recorder for sales, hiring, and async communication. This library is extracted directly from Reechy's production codebase.
PRs welcome. Please open an issue first for significant changes.
git clone https://github.com/reechy-tools/recorder
cd recorder && npm install
npm run dev # tsup watch + local server at :4321
npm test # vitest (24 tests)
npm run build # production build → dist/MIT © Reechy