saccade
v0.13.0
Published
Slow down time to inspect animations and interactions
Downloads
1,887
Readme
saccade
Saccade is a drop-in animation inspector for web apps. Slow down time, record interactions (or just the elements you scope), scrub through transitions frame-by-frame, annotate moments, and copy structured animation state for AI coding agents.
Install
npm install saccade -DUsage
import { Saccade } from 'saccade'
function App() {
return (
<>
<YourApp />
<Saccade />
</>
)
}A floating panel appears in the corner. Use it to control speed, record, and scrub.
Slowing every animation library
Saccade slows anything driven by the standard time sources — CSS transitions,
CSS @keyframes, the Web Animations API, requestAnimationFrame loops, video
and audio, SVG SMIL (<animate>/<animateTransform>/<animateMotion>), and
JS libraries that read Date.now/performance.now/rAF such as
Framer Motion. To reach them all reliably you need two things:
1. Install before your app code (recommended)
Libraries that cache a time function before saccade patches it never see
slow-mo. Because <Saccade> mounts inside React, its patches go in after
anything imported earlier. Import the side-effect entry first in your app
entry so the timing APIs are patched before any other module runs:
// main.tsx — must be the first import
import 'saccade/install'
import { createRoot } from 'react-dom/client'
import { App } from './App'
// …This patches the same shared engine <Saccade> uses, so the panel still
controls everything. It's optional — without it, anything imported before
<Saccade> mounts may not slow down.
2. Register GSAP (ES-module imports only)
GSAP is auto-detected when loaded as a global (UMD/CDN). When you import it as
an ES module, window.gsap is undefined, so hand saccade your instance once:
import { gsap } from 'gsap'
import { getSharedEngine } from 'saccade'
getSharedEngine().registerGSAP(gsap)Features
- Speed control — Slow down or speed up all animations: CSS transitions, CSS
@keyframes, the Web Animations API,requestAnimationFrame, JS timers, video/audio, SVG SMIL, GSAP, and Framer Motion - Timeline recording — Record interactions, then scrub through captured frames to inspect mid-transition states. Playing
<video>/<audio>is paused while you scrub (video pixels aren't DOM-replayable) and resumes from where it stopped when you clear the timeline; SMIL timelines are seeked exactly - Element-scoped recording — Hold S and click elements to build a scope selection (tap S or the panel's scope button to latch the picker; Esc drops it). Pick several elements — pause the page first to catch a fast-moving one — then hit record: only the selection is captured, and on scrub everything else stays frozen. Hover a scoped element for the × badge to remove it. An extension option restores the legacy pick-records-immediately behavior
- Annotations — ⇧A while scrubbing turns the cursor into a picker over the frozen page: click an element mid-animation, type a note, and it pins to that element at that playhead moment as a numbered marker (hover for the note, click to edit, drafts auto-save if you click away). Annotations ride the LLM export with each element's animation state at its own timestamp — copying hands them off and clears them (setting-gated), the markers flying into the copy button
- Page-load recording — Alt/⌥ Option+click the record button (or press ⇧⌥R) to reload with recording live from
document_start: the page's entrance choreography is captured from the first paintable frame at your current speed setting, with DCL/load/LCP/fonts shown as timeline markers. Stops like any take (stop button, ⇧Space, or the 60s cap) - LLM export — Copy structured animation state (properties, keyframes, easing, progress) as markdown for AI coding agents
- Shadow DOM isolation — Panel styles never leak into your app
- Auto-stop — Recording caps at 60s (plus a frame-count memory bound) and stops when the tab is hidden for more than 5s. The take is kept, and a toast beside the panel names what ended it
- Zero config — Drop in the component and go
- Bug reports — Hold Alt/⌥ Option while hovering the panel to reveal a bug button. It copies a diagnostic bundle (engine state, recent internal events, environment) written for your AI agent — paste it into your agent and ask it to file the issue. Nothing is ever sent anywhere automatically.
Keyboard shortcuts
Active whenever the panel is mounted (never while typing in an input). Every hotkey takes Shift — plain keys belong to the page, so recording interactions that use Space (switches, checkboxes, buttons) or Esc (dismissing dialogs) never ends the take. The one plain-key exception is S (scope), a hold/tap gesture guarded against text entry and browser chords:
| Key | Action |
|-----|--------|
| ⇧R | Start/stop recording |
| ⇧⌥R | Record page load — reloads with recording live from document_start (same as ⌥+clicking record) |
| ⇧Space | Stop recording (while recording); otherwise pause/play — freezes time, or plays a video take while scrubbing |
| ⇧S | Cycle speed |
| ⇧V | Toggle video capture mode — pixel-truth takes for content DOM replay can't reach (canvas, WebGL, cross-origin iframes, <video>). If the page contains a visible cross-origin iframe when you start a DOM recording, the panel suggests this once per page load |
| ⇧C | Copy the LLM export (while scrubbing) |
| ⇧D | Cycle export detail level (while scrubbing) |
| ⇧A | Toggle annotate mode (while scrubbing) |
| S | Scope picker (while idle): hold for a momentary pick session, tap to latch it on/off; Esc drops an active latch |
| ⇧Esc | Stop recording / clear the timeline |
How it works
Saccade patches timing APIs (setTimeout, setInterval, requestAnimationFrame, performance.now, Date.now) to scale time by a configurable factor — this covers requestAnimationFrame loops and time-reading libraries like Framer Motion. CSS transitions, @keyframes, and Web Animations API animations are scaled via their playbackRate; GSAP via its global timeline timeScale; SVG SMIL has no playback rate, so its timelines are paused and pumped forward by the virtual clock each frame. During recording, it snapshots computed styles, attributes, and animation state every frame. In scrub mode, it replays those snapshots by applying inline styles directly to the DOM (SMIL timelines are seeked natively via setCurrentTime). Element-scoped takes gate capture by geometry — an element belongs to the scope when its center sits inside a scoped element's live-tracked box (portalled dropdowns triggered from the scope are captured through a separate channel).
Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| position | 'top-left' \| 'top-center' \| 'top-right' \| 'left-middle' \| 'right-middle' \| 'bottom-left' \| 'bottom-center' \| 'bottom-right' | 'bottom-left' | Panel position (the panel is also draggable — it snaps to the nearest slot) |
| engine | SaccadeEngine | shared singleton | Engine the panel drives. Defaults to the process-wide shared engine, so app code (via getSharedEngine()) and the panel control the same instance. Pass your own only to isolate. |
Core API
For non-React usage or programmatic control:
import { SaccadeEngine } from 'saccade/core'
const engine = new SaccadeEngine()
// Speed control
engine.setSpeed(0.25) // quarter speed
engine.getSpeed()
// Patch timing APIs now, without changing speed (win the early-load race).
// setSpeed/startRecording also install on demand, so this is only needed up front.
engine.install()
// Register a module-imported GSAP instance (window.gsap fallback otherwise)
engine.registerGSAP(gsap)
// Recording
engine.startRecording()
// …or scoped to one or more elements (everything else stays frozen in replay)
engine.startRecording(null, [document.querySelector('.hero')!])
const capture = engine.stopRecording()
// Scrubbing
engine.seekTo(500) // seek to 500ms
engine.release()
// Annotations (while scrubbing)
engine.addAnnotation(el, 'ease this out slower', 500)
engine.annotations // readonly records
engine.clearAnnotations()
// Export
const markdown = engine.exportForLLM(500, 'active', 'moderate')
// Behavior settings (extension options set these for you)
engine.setScopeAutoRecord(true) // scope pick starts recording immediately (default false)
engine.setClearAnnotationsOnExport(false) // keep annotations after copying (default true)
// Cleanup
engine.destroy()Shared engine
new SaccadeEngine() gives you an isolated engine. If you also render
<Saccade> and want app code to drive the same one the panel does, use the
process-wide singleton instead:
import { getSharedEngine } from 'saccade' // or 'saccade/core'
const engine = getSharedEngine() // same instance <Saccade> uses by default
engine.setSpeed(0.5)saccade/install installs this shared engine, so importing it and calling
getSharedEngine() always refer to the same instance.
React Hooks
import { SaccadeProvider, useSaccadeEngine, useTimeline, useSpeed } from 'saccade'useSpeed
const { speed, isPaused, setSpeed, togglePause } = useSpeed()useTimeline
const {
state, // 'idle' | 'recording' | 'scrubbing'
capture, // TimelineCapture | null
scrubTime, // current scrub position in ms
startRecording,
stopRecording,
seek,
release,
exportLLM,
} = useTimeline()Export Detail Levels
The LLM export supports four detail levels:
| Level | Description |
|-------|-------------|
| brief | One line per element — property names and timing only |
| moderate | Full property values, ranges, interaction state |
| detailed | Adds CSS variables, @keyframes source, transition conflicts |
| granular | Adds full structural element paths and environment (viewport, URL, user agent, device pixel ratio) |
Types
import type {
AnimationInfo,
TimelineCapture,
TimelineExport,
FrameSnapshot,
ExportFilter,
OutputDetailLevel,
} from 'saccade'Requirements
- React 18+ (for the
<Saccade>component) - No React dependency needed for
saccade/core
License
MIT
