Portable tutorial player component for React applications with interactive step-by-step walkthroughs, screenshots, and annotations
npm install @tutorial-maker/react-playerA portable, customizable tutorial player component for React applications. Display interactive step-by-step tutorials with screenshots and annotations in your React app.
- šÆ Interactive Tutorial Playback - Step through tutorials with smooth scrolling and navigation
- š¼ļø Screenshot Support - Display screenshots with annotations (arrows, text balloons, highlights, numbered badges)
- š± Responsive Design - Works seamlessly across different screen sizes
- šØ Customizable - Flexible image loading and path resolution
- š Universal - Works in both web and Tauri environments
- ā” Lightweight - Minimal dependencies, optimized bundle size
- š§ TypeScript - Full TypeScript support with type definitions
``bash`
npm install @tutorial-maker/react-playeror
yarn add @tutorial-maker/react-playeror
pnpm add @tutorial-maker/react-player
The simplest way to get started is using the embedded format (recommended):
`tsx
import { TutorialPlayer } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';
// Import your embedded tutorial file (exported from tutorial-maker app)
import tutorialData from './my-tutorial.tutorial.json';
function App() {
return (
That's it! No image loaders, no glob imports, no bundler configuration needed. The embedded format includes all screenshots as base64 data URLs in a single self-contained file.
Table of Contents
- Embedding Tutorial Projects
- Complete Integration Examples
- Props API
- Tutorial Data Format
- Image Loading
- Styling
- TypeScript Support
- Best Practices
Embedding Tutorial Projects
$3
When you create tutorials with the tutorial-maker app, it generates a project with this structure:
`
my-project/
āāā project.json # Main project file
āāā screenshots/ # Screenshots folder
āāā step1.png
āāā step2.png
āāā ...
`$3
`json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "My Tutorial Project",
"projectFolder": "my-project",
"tutorials": [
{
"id": "tutorial-1",
"title": "Getting Started",
"description": "Learn the basics",
"steps": [...],
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
],
"createdAt": "2025-01-01T00:00:00.000Z",
"updatedAt": "2025-01-01T00:00:00.000Z"
}
`Important: Pass
projectData.tutorials to the player, not the entire project object.$3
The tutorial-maker app supports two formats for distributing tutorials:
#### 1. Folder-Based Format (Default)
The standard format with separate files:
`
my-project/
āāā project.json # Main project file
āāā screenshots/ # Screenshots as separate files
āāā step1.png
āāā step2.png
āāā ...
`Best for: Development, version control, smaller file sizes
#### 2. Embedded Format (Single File)
A self-contained format where screenshots are embedded as base64 data URLs:
`json
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "My Tutorial",
"projectFolder": "my-project",
"embedded": true,
"tutorials": [
{
"steps": [
{
"screenshotPath": "data:image/png;base64,iVBORw0KGgoAAAANS..."
}
]
}
]
}
`Best for: Simple distribution, single-file deployment, no build configuration needed
Size Note: Embedded format is ~33% larger than folder-based due to base64 encoding.
Export Embedded Format: Use the "Export" button in the tutorial-maker app to create a
.tutorial.json file.$3
Copy the entire tutorial project folder into your React app:
`
your-app/
āāā src/
ā āāā App.tsx
ā āāā tutorials/
ā āāā my-project/ ā Copy tutorial project here
ā āāā project.json
ā āāā screenshots/
ā āāā step1.png
ā āāā step2.png
āāā package.json
`Or use the embedded format by importing a single
.tutorial.json file:`
your-app/
āāā src/
ā āāā App.tsx
ā āāā tutorials/
ā āāā my-tutorial.tutorial.json ā Single embedded file
āāā package.json
`Complete Integration Examples
$3
The simplest and most portable way to integrate tutorials. Perfect for quick integrations, CDN deployments, and when you want zero build configuration.
Step 1: Export your tutorial using the "Export" button in the tutorial-maker app. This creates a
.tutorial.json file with embedded base64 screenshots.Step 2: Import and use it directly:
`tsx
import React from 'react';
import { TutorialPlayer } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';// Import the embedded tutorial file
import tutorialData from './my-tutorial.tutorial.json';
function App() {
return (
tutorials={tutorialData.tutorials}
onClose={() => console.log('Tutorial closed')}
/>
);
}export default App;
`Why use embedded format?
ā
Zero configuration - No image loaders, no glob imports, no bundler setup required
ā
Single file - Easy to distribute, version, and deploy
ā
Works everywhere - Compatible with any bundler (Vite, Webpack, Rollup, etc.)
ā
Self-contained - All screenshots included as base64 data URLs
ā
Type-safe - Full TypeScript support out of the box
Trade-off: File size is ~33% larger due to base64 encoding.
File Structure:
`
src/
āāā App.tsx
āāā my-tutorial.tutorial.json ā Single embedded file (~2-3 MB typical)
`Real-world example: The DBill Delivery Helper app uses this approach for its built-in tutorials.
$3
This example bundles the tutorial project with your app using Vite's glob import feature.
`tsx
import React from 'react';
import { TutorialPlayer } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';// Import the project JSON
import projectData from './tutorials/my-project/project.json';
// Import all screenshots using Vite's glob import
const screenshots = import.meta.glob(
'./tutorials/*/screenshots/.{png,jpg}',
{ eager: true, as: 'url' }
);
function TutorialApp() {
// Custom loader for bundled images
const imageLoader = async (path: string): Promise => {
// Already a data URL? Return as-is
if (path.startsWith('data:')) {
return path;
}
// Find the image in our imports
const projectFolder = projectData.projectFolder;
const imageUrl = screenshots[
./tutorials/${projectFolder}/${path}]; if (!imageUrl) {
console.error(
Screenshot not found: ${path});
throw new Error(Screenshot not found: ${path});
} return imageUrl;
};
return (
tutorials={projectData.tutorials}
imageLoader={imageLoader}
onClose={() => console.log('Tutorial closed')}
/>
);
}export default TutorialApp;
`File Structure:
`
src/
āāā App.tsx
āāā tutorials/
āāā onboarding/
ā āāā project.json
ā āāā screenshots/
ā āāā welcome.png
ā āāā step1.png
āāā advanced/
āāā project.json
āāā screenshots/
āāā ...
`$3
For dynamic scenarios where tutorials are loaded from a server or API endpoint.
`tsx
import React, { useState, useEffect } from 'react';
import { TutorialPlayer, type Tutorial, defaultWebImageLoader } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';function TutorialApp() {
const [tutorials, setTutorials] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
async function loadTutorials() {
try {
// Fetch project JSON from server
const response = await fetch('/api/tutorials/my-project.json');
if (!response.ok) {
throw new Error('Failed to load tutorials');
}
const projectData = await response.json();
setTutorials(projectData.tutorials);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
console.error('Failed to load tutorials:', err);
} finally {
setLoading(false);
}
}
loadTutorials();
}, []);
if (loading) {
return (
Loading tutorials...
);
} if (error) {
return (
Error: {error}
);
} return (
tutorials={tutorials}
imageLoader={defaultWebImageLoader}
resolveImagePath={(relativePath) =>
/api/tutorials/my-project/${relativePath}
}
onClose={() => window.history.back()}
/>
);
}export default TutorialApp;
`$3
Switch between different tutorial projects with a selector.
`tsx
import React, { useState } from 'react';
import { TutorialPlayer, type Tutorial, type Project } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';// Import multiple projects
import onboardingProject from './tutorials/onboarding/project.json';
import advancedProject from './tutorials/advanced/project.json';
const screenshots = import.meta.glob(
'./tutorials/*/screenshots/.{png,jpg}',
{ eager: true, as: 'url' }
);
type ProjectKey = 'onboarding' | 'advanced';
function MultiTutorialApp() {
const [currentProject, setCurrentProject] = useState('onboarding');
const projectMap: Record = {
onboarding: onboardingProject as Project,
advanced: advancedProject as Project,
};
const currentProjectData = projectMap[currentProject];
const tutorials = currentProjectData.tutorials;
const imageLoader = async (path: string): Promise => {
if (path.startsWith('data:')) return path;
const projectFolder = currentProjectData.projectFolder;
const imageUrl = screenshots[
./tutorials/${projectFolder}/${path}]; if (!imageUrl) {
throw new Error(
Screenshot not found: ${path});
} return imageUrl;
};
return (
{/ Project Selector /}
position: 'fixed',
top: '10px',
left: '10px',
zIndex: 100,
backgroundColor: 'white',
padding: '8px',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}>
value={currentProject}
onChange={(e) => setCurrentProject(e.target.value as ProjectKey)}
style={{ padding: '4px 8px' }}
>
key={currentProject} // Force re-render on project change
tutorials={tutorials}
imageLoader={imageLoader}
/>
export default MultiTutorialApp;
`
For apps using Webpack or Create React App:
`tsx
import React from 'react';
import { TutorialPlayer } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';
import projectData from './tutorials/my-project/project.json';
// Import screenshots individually
import welcome from './tutorials/my-project/screenshots/welcome.png';
import step1 from './tutorials/my-project/screenshots/step1.png';
import step2 from './tutorials/my-project/screenshots/step2.png';
function TutorialApp() {
const imageMap: Record
'screenshots/welcome.png': welcome,
'screenshots/step1.png': step1,
'screenshots/step2.png': step2,
};
const imageLoader = async (path: string): Promise
if (path.startsWith('data:')) return path;
const imageUrl = imageMap[path];
if (!imageUrl) {
throw new Error(Screenshot not found: ${path});
}
return imageUrl;
};
return (
export default TutorialApp;
`
| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| tutorials | Tutorial[] | Yes | - | Array of tutorial objects (extract from project.tutorials) |initialTutorialId
| | string | No | First tutorial | ID of the initially selected tutorial |imageLoader
| | (path: string) => Promise | No | defaultWebImageLoader | Custom function to load images and return data URLs |resolveImagePath
| | (relativePath: string) => string | No | Identity function | Function to resolve relative image paths to absolute paths/URLs |onClose
| | () => void | No | - | Callback when the close button is clicked (if not provided, close button is hidden) |className
| | string | No | '' | Additional CSS class names for the root container |
`typescript
interface Project {
id: string;
name: string;
projectFolder: string; // Folder name only
tutorials: Tutorial[];
createdAt: string; // ISO date string
updatedAt: string; // ISO date string
embedded?: boolean; // True if screenshots are embedded as base64 data URLs
annotationDefaults?: {
arrowColor: string;
highlightColor: string;
balloonBackgroundColor: string;
balloonTextColor: string;
badgeBackgroundColor: string;
badgeTextColor: string;
rectColor: string;
circleColor: string;
};
}
interface Tutorial {
id: string;
title: string;
description: string;
steps: Step[];
createdAt: string;
updatedAt: string;
}
interface Step {
id: string;
title: string;
description: string;
screenshotPath: string | null; // Relative path like "screenshots/step1.png"
// OR data URL like "data:image/png;base64,..." for embedded format
subSteps: SubStep[];
order: number;
}
interface SubStep {
id: string;
title: string;
description: string;
order: number;
// Legacy format (still supported)
annotations?: Annotation[];
// New format (recommended)
annotationActions?: AnnotationAction[];
clearPreviousAnnotations?: boolean;
showAnnotationsSequentially?: boolean;
}
`
`typescript
type Annotation =
| ArrowAnnotation
| TextBalloonAnnotation
| HighlightAnnotation
| NumberedBadgeAnnotation
| RectAnnotation
| CircleAnnotation;
interface ArrowAnnotation {
id: string;
type: 'arrow';
position: Position;
startPosition: Position;
endPosition: Position;
controlPoint?: Position; // For curved arrows
color: string;
thickness: number;
doubleHeaded?: boolean;
}
interface TextBalloonAnnotation {
id: string;
type: 'textBalloon';
position: Position;
text: string;
size: Size;
backgroundColor: string;
textColor: string;
tailPosition?: TailPosition;
}
interface HighlightAnnotation {
id: string;
type: 'highlight';
position: Position;
size: Size;
color: string;
opacity: number;
}
interface NumberedBadgeAnnotation {
id: string;
type: 'numberedBadge';
position: Position;
number: number;
size: number;
backgroundColor: string;
textColor: string;
}
`
Screenshots are stored with relative paths in the tutorial JSON:
`json`
{
"screenshotPath": "screenshots/welcome.png"
}
You must resolve these to absolute paths or data URLs using the imageLoader and resolveImagePath props.
The package includes a default image loader for web environments:
`tsx
import { TutorialPlayer, defaultWebImageLoader } from '@tutorial-maker/react-player';
imageLoader={defaultWebImageLoader}
resolveImagePath={(path) => /tutorials/my-project/${path}}`
/>
`tsxBearer ${getAuthToken()}
const customImageLoader = async (path: string): Promise
const response = await fetch(path, {
headers: {
Authorization: ,
},
});
if (!response.ok) {
throw new Error(Failed to load image: ${path});
}
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
};
imageLoader={customImageLoader}
resolveImagePath={(path) => https://cdn.example.com/${path}}`
/>
For Tauri applications, create a custom loader using Tauri's file system API:
`tsx
import { readFile } from '@tauri-apps/plugin-fs';
import { TutorialPlayer, bytesToBase64 } from '@tutorial-maker/react-player';
const tauriImageLoader = async (absolutePath: string): Promise
const bytes = await readFile(absolutePath);
const base64 = bytesToBase64(bytes);
const mimeType = absolutePath.endsWith('.jpg') ? 'image/jpeg' : 'image/png';
return data:${mimeType};base64,${base64};
};
imageLoader={tauriImageLoader}
resolveImagePath={(relativePath) =>
${projectBasePath}/${relativePath}`
}
/>
Always import the styles in your entry component:
`tsx`
import '@tutorial-maker/react-player/styles.css';
The package uses CSS variables for theming. Override them in your CSS:
`css
:root {
/ Background and foreground colors /
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
/ Primary colors /
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
/ Secondary colors /
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
/ Muted colors /
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
/ Accent colors /
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
/ Card colors /
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
/ Border and input colors /
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
/ Border radius /
--radius: 0.5rem;
}
/ Dark mode /
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
/ ... /
}
`
`tsx`
className="my-custom-player"
/>
`css`
.my-custom-player {
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
max-width: 1400px;
margin: 0 auto;
}
The package is written in TypeScript and includes full type definitions.
`tsx`
import type {
Tutorial,
Project,
Step,
SubStep,
Annotation,
ArrowAnnotation,
TextBalloonAnnotation,
HighlightAnnotation,
NumberedBadgeAnnotation,
TutorialPlayerProps,
Position,
Size,
TailPosition,
} from '@tutorial-maker/react-player';
`tsx
import { TutorialPlayer, type Tutorial } from '@tutorial-maker/react-player';
const tutorials: Tutorial[] = [
{
id: 'tutorial-1',
title: 'My Tutorial',
description: 'A great tutorial',
steps: [/ ... /],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
];
`
Optimize screenshots before bundling:
- Use WebP format for better compression
- Resize images to appropriate dimensions (1920x1080 max recommended)
- Use image optimization tools in your build pipeline
`bash`Using sharp-cli
npx sharp-cli -i input.png -o output.webp --quality 85
For large tutorial projects:
- Consider loading tutorials on-demand rather than bundling all at once
- Use code splitting to lazy load the player component
- Compress project JSON files
`tsx
// Lazy load the player
const TutorialPlayer = React.lazy(() =>
import('@tutorial-maker/react-player').then(mod => ({
default: mod.TutorialPlayer
}))
);
function App() {
return (
$3
Always handle image loading errors gracefully:
`tsx
const imageLoader = async (path: string): Promise => {
try {
const imageUrl = screenshots[ ./tutorials/${projectFolder}/${path}]; if (!imageUrl) {
console.error(
Screenshot not found: ${path});
// Return a placeholder or throw error
return PLACEHOLDER_IMAGE;
} return imageUrl;
} catch (error) {
console.error('Failed to load image:', error);
throw error;
}
};
`$3
Show loading states while tutorials are being fetched:
`tsx
function TutorialApp() {
const [loading, setLoading] = useState(true);
const [tutorials, setTutorials] = useState([]); useEffect(() => {
loadTutorials().then(data => {
setTutorials(data.tutorials);
setLoading(false);
});
}, []);
if (loading) {
return ;
}
return ;
}
`$3
Always wrap the player in a properly sized container:
`tsx
width: '100%',
height: '100vh',
display: 'flex',
flexDirection: 'column'
}}>
Browser Support
- Modern browsers (Chrome 90+, Firefox 88+, Safari 14+, Edge 90+)
- ES2020+ support required
- CSS Grid and Flexbox support required
- CSS custom properties (CSS variables) support required
Keyboard Shortcuts
The player includes built-in keyboard navigation:
- Arrow Up/Down - Navigate between substeps
- Space - Next substep
- Home - Go to first substep
- End - Go to last substep
- Escape - Close player (if
onClose is provided)Troubleshooting
$3
1. Check that
imageLoader is returning data URLs
2. Verify resolveImagePath is generating correct paths
3. Check browser console for error messages
4. Ensure screenshot paths in JSON match actual file paths$3
1. Ensure you're importing types from the package
2. Check that your
tsconfig.json includes the package
3. Verify tutorial data matches the expected type structure$3
1. Ensure you've imported the CSS:
import '@tutorial-maker/react-player/styles.css'
2. Check for CSS specificity conflicts
3. Verify CSS variables are defined in your themeAI Agent Integration Guide
This section is for AI coding assistants (like Claude Code, GitHub Copilot, Cursor, etc.) helping developers integrate the tutorial player.
$3
When a developer asks to integrate tutorial player functionality, follow this pattern:
1. Installation:
`bash
npm install @tutorial-maker/react-player
`2. Embedded Format Integration (Recommended - Zero Config):
If the developer has a
.tutorial.json file (embedded format):`tsx
import { TutorialPlayer } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';
import tutorialData from './path/to/tutorial-file.tutorial.json';function TutorialsPage() {
return (
tutorials={tutorialData.tutorials}
onClose={() => console.log('Tutorial closed')}
/>
);
}
`That's it! No image loaders or additional configuration needed.
3. Folder-Based Format Integration (Advanced):
Only use this if they have a
project.json file with separate screenshot files:`tsx
import { TutorialPlayer } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';
import projectData from './tutorials/my-project/project.json';// Use Vite's glob import for screenshots
const screenshots = import.meta.glob(
'./tutorials/*/screenshots/.{png,jpg}',
{ eager: true, as: 'url' }
);
function TutorialsPage() {
const imageLoader = async (path: string) => {
if (path.startsWith('data:')) return path;
const projectFolder = projectData.projectFolder;
const imageUrl = screenshots[
./tutorials/${projectFolder}/${path}]; if (!imageUrl) {
throw new Error(
Screenshot not found: ${path});
} return imageUrl;
};
return (
tutorials={projectData.tutorials}
imageLoader={imageLoader}
onClose={() => console.log('Tutorial closed')}
/>
);
}
`$3
1. Always use embedded format (
.tutorial.json) when possible - it's simpler and requires zero configuration
2. Pass projectData.tutorials to the player, NOT the entire project object
3. Always import the CSS - the component requires it to function properly
4. Use proper container sizing - wrap in a div with explicit width/height
5. Embedded format includes base64 images - no imageLoader needed for embedded format
6. Folder format needs imageLoader - only use glob imports if they have separate screenshot files$3
Adding tutorials to existing React app:
- Create a new page component (e.g.,
TutorialsPage.tsx)
- Add navigation/routing to that page
- Import and render TutorialPlayer with embedded tutorial fileAdding to navigation menu:
`tsx
// Example for common UI patterns
`Integration with routing:
`tsx
// React Router example
} />// Or as modal/dialog
`$3
Embedded format indicators:
- File name ends with
.tutorial.json
- JSON has "embedded": true field
- Screenshots are "data:image/png;base64,..." stringsFolder format indicators:
- File named
project.json
- Has "projectFolder" field
- Screenshots are relative paths like "screenshots/step1.png"
- Separate screenshots/ folder exists$3
Common mistakes to avoid:
- ā Forgetting to import CSS
- ā Passing entire project object instead of
project.tutorials
- ā Using imageLoader for embedded format (not needed)
- ā Not wrapping in sized container
- ā Using wrong import path for CSSCorrect patterns:
- ā
Import CSS:
import '@tutorial-maker/react-player/styles.css'
- ā
Pass tutorials: tutorials={data.tutorials}
- ā
Sized container: MIT
Contributions are welcome! Please open an issue or submit a pull request on GitHub.
For issues, questions, or feature requests, please open an issue on the GitHub repository.