slate-motion
v0.3.0
Published
A cinematic chalkboard-style HTML content reveal renderer for React
Maintainers
Readme
InkFrame
Cinematic Word-by-Word Chalkboard HTML Presentation Renderer for React
InkFrame is a high-performance React component designed to render HTML content word-by-word with the visual feel of a cinematic classroom chalkboard. It includes automated and audio-synchronized timelines, realistic drawing cursors with fluid motion, custom layers, and an integrated HUD control panel.
Table of Contents
- Key Features
- Architecture Overview
- Running Locally & Development
- Getting Ready for npm Package
- Publishing to npm
- Component API Reference
- Imperative Handle API
- Advanced Architecture Guidelines
- License
Key Features
- 🎬 Cinematic Aesthetics: Authentic dark chalkboard texture with a customizable grid, soft vignettes, custom cursive/code fonts, and smooth fades.
- ⚡ Zero-React 60fps Tick Loop: Animations, cursor movements, and word opacity transitions bypass React state entirely, writing direct styling to DOM elements inside a requestAnimationFrame (rAF) loop for stutter-free playback.
- 🎙️ Sub-millisecond Audio Sync: Synchronize word-by-word reveal timings to actual audio tracks using the Web Audio API with automatic drift correction.
- ✏️ Natural Cursor Motion: Realistic cursor types (
hand,chalk,marker,pen) with natural sway, pivot adjustments, and container-relative coordinate calculations to eliminate cursor drift. - 🧬 Rich Media Revelations: Renders images, inline SVGs, and KaTeX math formulas at the exact moment they are reached in the text timeline.
- 🎛️ Playable HUD Controls: Integrated responsive playback scrubber, status indicator, play/pause controls, and playback speed triggers.
Architecture Overview
InkFrame stacks 5 independent rendering layers within a single relative container element to isolate rendering concerns and optimize GPU performance:
<ChalkboardRenderer> (Relative outer container, fixed viewport dimensions)
│
├── <BackgroundLayer> (Canvas 2D, drawn once on initialization)
├── <ContentLayer> (Pre-renders words at opacity:0; registers DOM nodes)
├── <MediaLayer> (Absolute overlays for images, SVGs, and CDN KaTeX formulas)
├── <CursorLayer> (Absolutely positioned single element for pointer styling)
└── <HUDLayer> (React DOM controls, throttled to 10fps for scrubber & timing updates)Running Locally & Development
1. Installation
Ensure you have Node.js (v18+) installed. Clone the repository and install dependencies:
# Using npm
npm install
# Using yarn
yarn install2. Start the Local Playground / Dev Server
To run the demo sandbox locally (which renders the interactive lessons in src/App.tsx):
npm run devThis boots Vite and opens the local playground at http://localhost:5173.
3. Build & Preview the Demo Site
To build and preview the standalone static application:
# Build the React/Vite playground
npm run build
# Preview the local build
npm run preview4. Running Lints & Type Checks
To check for code cleanliness and run strict type checks:
# Run ESLint check
npm run lint
# Run strict TypeScript compilation
# NOTE: Avoid using global "npx tsc" as it may resolve a wrong package. Use the project path:
node node_modules/typescript/bin/tsc --noEmit --strictGetting Ready for npm Package
InkFrame is prepared to be compiled and shipped as a lightweight, clean, and self-contained npm package. The package files and export pipelines are pre-configured inside package.json, vite.config.ts, and tsconfig.lib.json.
Library Build Process
Vite bundles the library code and TypeScript generates the typings when running:
npm run build:libThis triggers the following events:
- JavaScript Bundling:
vite builduses the library config (src/chalkboard/index.tsas entry) to create two files indist/:dist/inkframe.es.js(ES Module format for bundlers like Webpack, Vite, Next.js)dist/inkframe.cjs.js(CommonJS module for traditional setups)- Note: React, React-DOM, and React JSX Runtimes are excluded from the bundle (externalized) so that the user's React runtime is reused.
- TypeScript Declarations:
tsc -p tsconfig.lib.jsoncompiles the chalkboard folder and emits types definitions (index.d.tsand related.d.tsfiles) directly intodist/.
Package Configurations
The package defines its public API via standard fields in package.json:
- Exports Barrel: Points to both CommonJS and ES formats, along with types declarations.
"main": "./dist/inkframe.cjs.js", "module": "./dist/inkframe.es.js", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/inkframe.es.js", "require": "./dist/inkframe.cjs.js", "types": "./dist/index.d.ts" } } - Included Files: Only the
distdirectory is shipped, keeping the package size minimal."files": [ "dist" ] - Npm Ignores:
.npmignoreexcludes development directories, config files, and TS configs from being packed:src/ public/ *.config.ts *.config.js tsconfig*.json .eslintrc* eslint.config* index.html
Publishing to npm
To publish InkFrame to the public npm registry, follow these step-by-step instructions:
Step 1: Automated Library Build Verification
Before publishing, ensure the library builds locally without any compilation or typing issues:
npm run build:libStep 2: Version Bump
Determine if the release is a patch, minor, or major update and bump the version inside package.json. This will also create a corresponding git version tag:
# Bumps 0.1.0 to 0.1.1 (Bug fixes, doc improvements)
npm version patch
# Bumps 0.1.0 to 0.2.0 (New features, backward-compatible API changes)
npm version minor
# Bumps 0.1.0 to 1.0.0 (Breaking changes or major launches)
npm version majorStep 3: Dry-Run Verification
Verify exactly which files will be packaged and uploaded to the registry to ensure no unwanted configuration files or source code leakage occurs:
npm publish --dry-runConfirm that only package.json, README.md, and the compiled dist/ files are listed in the tarball.
Step 4: Login to npm
Log into your npm account through the terminal command line (only needed once):
npm loginStep 5: Publish Package
Publish the library to the npm registry. Since the prepublishOnly lifecycle hook is configured, npm will automatically re-compile the library right before uploading:
# If your package has public access settings
npm publish --access publicComponent API Reference
Install InkFrame in your React application:
npm install slate-motionExample Usage
import React, { useRef } from 'react';
import { ChalkboardRenderer } from 'slate-motion';
import type { ChalkboardHandle } from 'slate-motion';
const LESSON_HTML = `
<h2>Solving equations</h2>
<p>To solve for <strong>x</strong>, we subtract 3 from both sides:</p>
<div class="math">x + 3 = 10</div>
<p>Therefore, we get <strong>x = 7</strong>.</p>
`;
export default function MathLesson() {
const chalkboardRef = useRef<ChalkboardHandle>(null);
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<ChalkboardRenderer
ref={chalkboardRef}
html={LESSON_HTML}
width={1100}
height={620}
theme="dark"
cursorStyle="chalk"
wpm={150}
showHUD={true}
onWordReveal={(id, word) => console.log(`Writing: ${word}`)}
onComplete={() => alert('Lesson completed!')}
/>
<div style={{ display: 'flex', gap: '8px' }}>
<button onClick={() => chalkboardRef.current?.play()}>Play</button>
<button onClick={() => chalkboardRef.current?.pause()}>Pause</button>
<button onClick={() => chalkboardRef.current?.reset()}>Restart</button>
</div>
</div>
);
}Props Reference
| Prop Name | Type | Default | Description |
| :--- | :--- | :--- | :--- |
| html | string | (Required) | The trusted input HTML content containing text blocks, headings, lists, inline SVGs, images, or KaTeX math classes. |
| audio | { src?: string; buffer?: ArrayBuffer } | undefined | Optional audio narration source. Uses Web Audio API for playback speed-adjusted synchronization. |
| timings | WordTiming[] | [] | Explicit word-level timestamp alignments matching the audio timeline. Format: [{ word: string, start: number, end: number }]. |
| width | number | 1280 | Renderer viewport width in pixels. |
| height | number | 720 | Renderer viewport height in pixels. |
| theme | 'dark' \| 'light' | 'dark' | Visual skin of the chalkboard. 'dark' gives a black slate look; 'light' replicates a classic physical whiteboard. |
| cursorStyle | 'chalk' \| 'marker' \| 'pen' \| 'hand' | 'chalk' | The active drawing pointer graphic displayed over the revealing text. |
| autoplay | boolean | false | If true, the chalkboard starts rendering text immediately upon layout measurement completion. |
| wpm / wordsPerMinute | number | 130 | Playback speed fallback used when no explicit audio timings are provided. |
| fadeDelay | number | 3.5 | Delay in seconds after the final word of a paragraph block is rendered before that block starts to fade out. |
| fadeDuration | number | 1.4 | Duration in seconds it takes for a fading paragraph block to become fully transparent. |
| contentPadding | { top: number, right: number, bottom: number, left: number } | { top: 64, right: 96, bottom: 64, left: 96 } | Inner border boundaries in pixels where text blocks are wrapped. |
| showHUD | boolean | true | Toggles whether the bottom media progress bar, play/pause controls, timer, and speed multiplier HUD are visible. |
| config | Partial<ChalkboardConfig> | undefined | Legacy support to feed a structured config object directly. |
| onWordReveal | (wordId: string, word: string) => void | undefined | Callback invoked as the writing pointer starts to construct a specific word span. |
| onProgress | (currentTime: number, duration: number) => void | undefined | Callback invoked on each rendering frame with current playback time and total timeline duration. |
| onComplete | () => void | undefined | Callback triggered when the timeline reaches its final frame. |
Imperative Handle API
By passing a React ref to <ChalkboardRenderer>, you obtain direct, synchronous control over the animation engine via the ChalkboardHandle interface.
Available Ref Commands
play(): voidResumes execution of the animation timeline. Automatically resumes associated audio narration if present.pause(): voidPauses the chalkboard animation loop and narration.seek(seconds: number): voidSeeks to a specific playback time in seconds. Recomputes word opacities and moves the cursor immediately to the corrected coordinate.setSpeed(rate: number): voidSets the playback speed rate multiplier (e.g.0.5to2.0). Affects both text speed and audio narration speed dynamically.reset(): voidResets the entire session back to0seconds, putting the cursor at its initial position and hiding all word layers.getState(): PlaybackStateRetrieves a snapshot of the current state:{ status: PlaybackStatus, duration: number, currentTime: number, speed: number }.getCurrentTime(): numberReturns the exact current playback coordinate in seconds.
Advanced Architecture Guidelines
When extending or editing the internal core layers, keep these critical guidelines in mind to avoid regressions:
1. StrictMode Double-Mount Handling
React 18's StrictMode double-mounts components in development to identify side-effects. In the ContentLayer.tsx measurement effect, a stale measuredRef guard would block on the second mount, causing the canvas to freeze forever on "preparing...".
- Rule: Do not add a
measuredRefguard back toContentLayer. The cleanup function cancels the previous frame ID (cancelAnimationFrame(frameId)), which is sufficient deduplication to allow the second layout pass to measure coordinates correctly.
2. Stable Measurement Callback (handleMeasured)
The handleMeasured callback passed from the main component must remain stable. If its reference changes on subsequent renders, it causes ContentLayer's measurement hook to fire repeatedly.
- Rule: Maintain the
measureCtxRefpattern insideChalkboardRenderer.tsx. Always copy current parameters to the mutable ref on render and execute inside a dependencies-freeuseCallback([], ...)block.
3. Container-Relative Cursor Coordinates
getBoundingClientRect() returns coordinates relative to the screen's viewport. Using these values directly inside CSS translate transitions causes the cursor to drift as soon as the container moves on the page.
- Rule: On initialization, the origin of the outermost container is subtracted from the viewport coordinates to map words into safe, absolute relative positions:
const origin = containerEl?.getBoundingClientRect(); const ox = origin?.left ?? 0; const oy = origin?.top ?? 0; token.position = new DOMRect(r.x - ox, r.y - oy, r.width, r.height);
4. Interactive Audio Gestures
Modern browsers block the startup of audio pipelines until an explicit user gesture is registered. Calling play() programmatically on page load without user input will lead to a suspended state.
- Rule: Prior to running programmatic calls, execute
audio.ensureContext()which yields to existing contexts or wakes them up during the initial button interaction.
License
MIT © Mukesh Seenivasan
