Package Exports
- @tutorial-maker/react-player
- @tutorial-maker/react-player/dist/react-player.css
- @tutorial-maker/react-player/styles.css
Readme
@tutorial-maker/react-player
A portable, customizable tutorial player component for React applications. Display interactive step-by-step tutorials with screenshots and annotations in your React app.
Features
- 🎯 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
Installation
npm install @tutorial-maker/react-player
# or
yarn add @tutorial-maker/react-player
# or
pnpm add @tutorial-maker/react-playerQuick Start
import { TutorialPlayer } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';
import projectData from './tutorials/my-project/project.json';
function App() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<TutorialPlayer
tutorials={projectData.tutorials}
onClose={() => console.log('Tutorial closed')}
/>
</div>
);
}Table of Contents
- Embedding Tutorial Projects
- Complete Integration Examples
- Props API
- Tutorial Data Format
- Image Loading
- Styling
- TypeScript Support
- Best Practices
Embedding Tutorial Projects
Project Structure
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
└── ...Project JSON Format
{
"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.
Tutorial Formats
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:
{
"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.
Embedding in Your App
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.jsonOr 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.jsonComplete Integration Examples
Example 1: Bundled Tutorial with Vite (Recommended)
This example bundles the tutorial project with your app using Vite's glob import feature.
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<string> => {
// 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 (
<div style={{ width: '100vw', height: '100vh' }}>
<TutorialPlayer
tutorials={projectData.tutorials}
imageLoader={imageLoader}
onClose={() => console.log('Tutorial closed')}
/>
</div>
);
}
export default TutorialApp;File Structure:
src/
├── App.tsx
└── tutorials/
├── onboarding/
│ ├── project.json
│ └── screenshots/
│ ├── welcome.png
│ └── step1.png
└── advanced/
├── project.json
└── screenshots/
└── ...Example 2: Loading from Server/API
Load tutorial projects dynamically from a server or API endpoint.
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<Tutorial[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
Loading tutorials...
</div>
);
}
if (error) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh' }}>
Error: {error}
</div>
);
}
return (
<div style={{ width: '100vw', height: '100vh' }}>
<TutorialPlayer
tutorials={tutorials}
imageLoader={defaultWebImageLoader}
resolveImagePath={(relativePath) =>
`/api/tutorials/my-project/${relativePath}`
}
onClose={() => window.history.back()}
/>
</div>
);
}
export default TutorialApp;Example 3: Multiple Tutorial Projects
Switch between different tutorial projects with a selector.
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<ProjectKey>('onboarding');
const projectMap: Record<ProjectKey, Project> = {
onboarding: onboardingProject as Project,
advanced: advancedProject as Project,
};
const currentProjectData = projectMap[currentProject];
const tutorials = currentProjectData.tutorials;
const imageLoader = async (path: string): Promise<string> => {
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 (
<div style={{ width: '100vw', height: '100vh', position: 'relative' }}>
{/* Project Selector */}
<div style={{
position: 'fixed',
top: '10px',
left: '10px',
zIndex: 100,
backgroundColor: 'white',
padding: '8px',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}>
<select
value={currentProject}
onChange={(e) => setCurrentProject(e.target.value as ProjectKey)}
style={{ padding: '4px 8px' }}
>
<option value="onboarding">Onboarding Tutorial</option>
<option value="advanced">Advanced Tutorial</option>
</select>
</div>
<TutorialPlayer
key={currentProject} // Force re-render on project change
tutorials={tutorials}
imageLoader={imageLoader}
/>
</div>
);
}
export default MultiTutorialApp;Example 4: Webpack/Create React App
For apps using Webpack or Create React App:
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<string, string> = {
'screenshots/welcome.png': welcome,
'screenshots/step1.png': step1,
'screenshots/step2.png': step2,
};
const imageLoader = async (path: string): Promise<string> => {
if (path.startsWith('data:')) return path;
const imageUrl = imageMap[path];
if (!imageUrl) {
throw new Error(`Screenshot not found: ${path}`);
}
return imageUrl;
};
return (
<div style={{ width: '100vw', height: '100vh' }}>
<TutorialPlayer
tutorials={projectData.tutorials}
imageLoader={imageLoader}
/>
</div>
);
}
export default TutorialApp;Example 5: Embedded Format (No Build Configuration)
The simplest way to integrate tutorials - just import a single .tutorial.json file with embedded screenshots. Perfect for quick integrations and CDN deployments.
Step 1: Export your tutorial using the "Export" button in the tutorial-maker app to create a .tutorial.json file.
Step 2: Import and use it:
import React from 'react';
import { TutorialPlayer, defaultWebImageLoader } from '@tutorial-maker/react-player';
import '@tutorial-maker/react-player/styles.css';
// Import the embedded tutorial file
import embeddedTutorial from './tutorials/my-tutorial.tutorial.json';
function TutorialApp() {
return (
<div style={{ width: '100vw', height: '100vh' }}>
<TutorialPlayer
tutorials={embeddedTutorial.tutorials}
imageLoader={defaultWebImageLoader}
onClose={() => console.log('Tutorial closed')}
/>
</div>
);
}
export default TutorialApp;Why use embedded format?
✅ Zero configuration - No glob imports, no image mapping, no bundler setup ✅ Single file - Easy to distribute, version, and deploy ✅ Works everywhere - CDN, static hosting, any bundler ✅ 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
└── tutorials/
└── onboarding.tutorial.json ← Single embedded file (~2-3 MB)Converting existing projects: Open your folder-based project in the tutorial-maker app and click "Export" to generate the embedded version.
Props API
TutorialPlayerProps
| 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<string> |
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 |
Tutorial Data Format
Complete Type Definitions
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;
}Annotation Types
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;
}Image Loading
Understanding Screenshot Paths
Screenshots are stored with relative paths in the tutorial JSON:
{
"screenshotPath": "screenshots/welcome.png"
}You must resolve these to absolute paths or data URLs using the imageLoader and resolveImagePath props.
Default Web Loader
The package includes a default image loader for web environments:
import { TutorialPlayer, defaultWebImageLoader } from '@tutorial-maker/react-player';
<TutorialPlayer
tutorials={tutorials}
imageLoader={defaultWebImageLoader}
resolveImagePath={(path) => `/tutorials/my-project/${path}`}
/>Custom Image Loader with Authentication
const customImageLoader = async (path: string): Promise<string> => {
const response = await fetch(path, {
headers: {
Authorization: `Bearer ${getAuthToken()}`,
},
});
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);
});
};
<TutorialPlayer
tutorials={tutorials}
imageLoader={customImageLoader}
resolveImagePath={(path) => `https://cdn.example.com/${path}`}
/>Tauri/Desktop Applications
For Tauri applications, create a custom loader using Tauri's file system API:
import { readFile } from '@tauri-apps/plugin-fs';
import { TutorialPlayer, bytesToBase64 } from '@tutorial-maker/react-player';
const tauriImageLoader = async (absolutePath: string): Promise<string> => {
const bytes = await readFile(absolutePath);
const base64 = bytesToBase64(bytes);
const mimeType = absolutePath.endsWith('.jpg') ? 'image/jpeg' : 'image/png';
return `data:${mimeType};base64,${base64}`;
};
<TutorialPlayer
tutorials={tutorials}
imageLoader={tauriImageLoader}
resolveImagePath={(relativePath) =>
`${projectBasePath}/${relativePath}`
}
/>Styling
CSS Import
Always import the styles in your entry component:
import '@tutorial-maker/react-player/styles.css';Theme Customization
The package uses CSS variables for theming. Override them in your 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%;
/* ... */
}Custom Container Styling
<TutorialPlayer
tutorials={tutorials}
className="my-custom-player"
/>.my-custom-player {
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
max-width: 1400px;
margin: 0 auto;
}TypeScript Support
The package is written in TypeScript and includes full type definitions.
Importing Types
import type {
Tutorial,
Project,
Step,
SubStep,
Annotation,
ArrowAnnotation,
TextBalloonAnnotation,
HighlightAnnotation,
NumberedBadgeAnnotation,
TutorialPlayerProps,
Position,
Size,
TailPosition,
} from '@tutorial-maker/react-player';Type-Safe Tutorial Data
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(),
},
];
<TutorialPlayer tutorials={tutorials} />Best Practices
1. Image Optimization
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
# Using sharp-cli
npx sharp-cli -i input.png -o output.webp --quality 852. Bundle Size Considerations
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
// Lazy load the player
const TutorialPlayer = React.lazy(() =>
import('@tutorial-maker/react-player').then(mod => ({
default: mod.TutorialPlayer
}))
);
function App() {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<TutorialPlayer tutorials={tutorials} />
</React.Suspense>
);
}3. Error Handling
Always handle image loading errors gracefully:
const imageLoader = async (path: string): Promise<string> => {
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;
}
};4. Loading States
Show loading states while tutorials are being fetched:
function TutorialApp() {
const [loading, setLoading] = useState(true);
const [tutorials, setTutorials] = useState<Tutorial[]>([]);
useEffect(() => {
loadTutorials().then(data => {
setTutorials(data.tutorials);
setLoading(false);
});
}, []);
if (loading) {
return <LoadingSpinner />;
}
return <TutorialPlayer tutorials={tutorials} />;
}5. Responsive Container
Always wrap the player in a properly sized container:
<div style={{
width: '100%',
height: '100vh',
display: 'flex',
flexDirection: 'column'
}}>
<TutorialPlayer tutorials={tutorials} />
</div>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
onCloseis provided)
Troubleshooting
Images Not Loading
- Check that
imageLoaderis returning data URLs - Verify
resolveImagePathis generating correct paths - Check browser console for error messages
- Ensure screenshot paths in JSON match actual file paths
TypeScript Errors
- Ensure you're importing types from the package
- Check that your
tsconfig.jsonincludes the package - Verify tutorial data matches the expected type structure
Styling Issues
- Ensure you've imported the CSS:
import '@tutorial-maker/react-player/styles.css' - Check for CSS specificity conflicts
- Verify CSS variables are defined in your theme
License
MIT
Contributing
Contributions are welcome! Please open an issue or submit a pull request on GitHub.
Support
For issues, questions, or feature requests, please open an issue on the GitHub repository.