File Handling
The File Handling component provides an interface to the File Handling API (Launch Queue API), enabling your Progressive Web App to receive and process files that users open through the operating system. This makes your PWA behave like a native application that can be associated with specific file types.
This component is particularly useful for:
Image editors and viewers
Document processors (PDF, Office files)
Media players (audio, video)
Code editors and IDEs
Design tools (SVG, CAD files)
Data import/export applications
Looking for something else?
Manifest configuration (which file types to handle): See File Handlers documentation
Client-side implementation (how to process files): You're on the right page!
Complete quick start example: See Quick Start in File Handlers
How It Works
The File Handling feature consists of two parts:
Manifest Configuration: Declare which file types your PWA can handle (see File Handlers documentation)
Client-Side Handling: Receive and process files using the Stimulus controller (this page)
Complete Workflow
┌─────────────────────────────────────────────────────────────────┐
│ 1. User Action │
│ User double-clicks a .jpg file or right-clicks → Open with │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ 2. Operating System │
│ - Checks file type (.jpg) │
│ - Looks for registered PWA handlers │
│ - Finds your PWA in manifest file_handlers │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ 3. PWA Launch │
│ - Opens PWA to the URL specified in action parameter │
│ - Adds file(s) to Launch Queue │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ 4. Stimulus Controller (@pwa/file-handling) │
│ - Detects Launch Queue has files │
│ - Creates blob URL for each file │
│ - Dispatches pwa--file-handling:selected event │
└────────────────────────────┬────────────────────────────────────┘
│
┌────────────────────────────▼────────────────────────────────────┐
│ 5. Your Application Code │
│ - Listens to pwa--file-handling:selected │
│ - Receives file data (blob URL) │
│ - Processes file (display, edit, convert, etc.) │
└─────────────────────────────────────────────────────────────────┘Key Points:
The manifest
actionURL must match the page where you add the Stimulus controllerThe
acceptconfiguration in the manifest determines which files trigger your PWAEach file generates a separate
pwa--file-handling:selectedeventThe event provides a blob URL that you can use immediately
This page focuses on steps 4-5 (client-side implementation). For step 3 (manifest configuration), see the File Handlers manifest documentation.
Browser Support
The File Handling API is currently supported in Chromium-based browsers (Chrome, Edge) on desktop platforms. Support on mobile and other browsers is limited or not available.
Prerequisites
Step 1: Configure Manifest File Handlers
Before your PWA can receive files, you must declare the file types it can handle in the manifest. This tells the operating system to associate your PWA with specific file extensions.
pwa:
manifest:
file_handlers:
- action: "app_file_handler" # Route name or URL
accept:
"image/png": [".png"]
"image/jpeg": [".jpg", ".jpeg"]
"image/webp": [".webp"]Step 2: Add the Stimulus Controller
Add the @pwa/file-handling controller to the template/page specified in the manifest's action parameter:
<div {{ stimulus_controller('@pwa/file-handling') }}>
<!-- Your file handling UI -->
</div>The controller automatically listens for files passed through the Launch Queue API and dispatches the pwa--file-handling:selected event for each file.
Usage
Basic Image Viewer
<div {{ stimulus_controller('@pwa/file-handling') }}>
<div class="viewer">
<p id="status">Waiting for files to open...</p>
<div id="image-container"></div>
</div>
</div>
<script>
const status = document.getElementById('status');
const container = document.getElementById('image-container');
document.addEventListener('pwa--file-handling:selected', async (event) => {
const { data } = event.detail;
status.textContent = 'Loading image...';
// Create image element
const img = document.createElement('img');
img.src = data;
img.style.maxWidth = '100%';
img.onload = () => {
status.textContent = 'Image loaded successfully!';
};
// Clear previous content and add new image
container.innerHTML = '';
container.appendChild(img);
});
</script>Photo Editor with Multiple Files
<div {{ stimulus_controller('@pwa/file-handling') }}>
<div class="photo-editor">
<h2>Photo Editor</h2>
<div id="gallery" class="image-gallery"></div>
<div class="editor-tools">
<button id="apply-filter">Apply Filter</button>
<button id="rotate">Rotate</button>
<button id="save">Save</button>
</div>
</div>
</div>
<script>
const gallery = document.getElementById('gallery');
const loadedImages = [];
document.addEventListener('pwa--file-handling:selected', async (event) => {
const { data } = event.detail;
// Create thumbnail
const wrapper = document.createElement('div');
wrapper.className = 'thumbnail';
const img = document.createElement('img');
img.src = data;
img.onclick = () => selectImage(loadedImages.length);
wrapper.appendChild(img);
gallery.appendChild(wrapper);
// Store image data
loadedImages.push({
url: data,
element: img
});
console.log(`Loaded ${loadedImages.length} image(s)`);
});
function selectImage(index) {
// Highlight selected image
document.querySelectorAll('.thumbnail').forEach((el, i) => {
el.classList.toggle('selected', i === index);
});
// Load image in editor
console.log('Editing image:', loadedImages[index].url);
}
document.getElementById('apply-filter').addEventListener('click', () => {
// Apply filter to selected image
const selected = document.querySelector('.thumbnail.selected img');
if (selected) {
selected.style.filter = 'grayscale(100%)';
}
});
document.getElementById('rotate').addEventListener('click', () => {
// Rotate selected image
const selected = document.querySelector('.thumbnail.selected img');
if (selected) {
const currentRotation = selected.dataset.rotation || 0;
const newRotation = (parseInt(currentRotation) + 90) % 360;
selected.style.transform = `rotate(${newRotation}deg)`;
selected.dataset.rotation = newRotation;
}
});
</script>
<style>
.image-gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 10px;
margin: 20px 0;
}
.thumbnail {
border: 2px solid transparent;
padding: 5px;
cursor: pointer;
transition: border-color 0.2s;
}
.thumbnail.selected {
border-color: #007bff;
}
.thumbnail img {
width: 100%;
height: 150px;
object-fit: cover;
border-radius: 4px;
}
.editor-tools {
display: flex;
gap: 10px;
margin-top: 20px;
}
.editor-tools button {
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>Document Viewer (PDF, Text)
<div {{ stimulus_controller('@pwa/file-handling') }}>
<div class="document-viewer">
<div class="toolbar">
<h2 id="document-title">No document loaded</h2>
<button id="download">Download</button>
</div>
<div class="document-content">
<iframe id="document-frame" style="width:100%; height:600px; border:none;"></iframe>
</div>
</div>
</div>
<script>
const frame = document.getElementById('document-frame');
const title = document.getElementById('document-title');
const downloadBtn = document.getElementById('download');
let currentFileUrl = null;
document.addEventListener('pwa--file-handling:selected', async (event) => {
const { data } = event.detail;
currentFileUrl = data;
// Display document in iframe
frame.src = data;
// Extract filename from URL if possible
try {
const url = new URL(data);
const filename = url.pathname.split('/').pop() || 'document';
title.textContent = decodeURIComponent(filename);
} catch {
title.textContent = 'Document loaded';
}
downloadBtn.style.display = 'block';
});
downloadBtn.addEventListener('click', () => {
if (currentFileUrl) {
const a = document.createElement('a');
a.href = currentFileUrl;
a.download = title.textContent || 'document';
a.click();
}
});
</script>Media Player with Playlist
<div {{ stimulus_controller('@pwa/file-handling') }}>
<div class="media-player">
<h2>Media Player</h2>
<video id="player" controls style="width:100%; max-width:800px;"></video>
<div class="playlist">
<h3>Playlist</h3>
<ul id="playlist-items"></ul>
</div>
</div>
</div>
<script>
const player = document.getElementById('player');
const playlistEl = document.getElementById('playlist-items');
const playlist = [];
document.addEventListener('pwa--file-handling:selected', async (event) => {
const { data } = event.detail;
// Add to playlist
const index = playlist.length;
playlist.push({ url: data, title: `Video ${index + 1}` });
// Create playlist item
const li = document.createElement('li');
li.textContent = `Video ${index + 1}`;
li.onclick = () => playVideo(index);
playlistEl.appendChild(li);
// Auto-play first video
if (playlist.length === 1) {
playVideo(0);
}
console.log(`Added video to playlist (${playlist.length} total)`);
});
function playVideo(index) {
if (index >= 0 && index < playlist.length) {
player.src = playlist[index].url;
player.play();
// Highlight current item
document.querySelectorAll('#playlist-items li').forEach((li, i) => {
li.style.fontWeight = i === index ? 'bold' : 'normal';
li.style.color = i === index ? '#007bff' : 'inherit';
});
}
}
// Auto-play next video when current ends
player.addEventListener('ended', () => {
const currentIndex = playlist.findIndex(item => item.url === player.src);
const nextIndex = currentIndex + 1;
if (nextIndex < playlist.length) {
playVideo(nextIndex);
}
});
</script>
<style>
.playlist {
margin-top: 20px;
}
#playlist-items {
list-style: none;
padding: 0;
}
#playlist-items li {
padding: 10px;
cursor: pointer;
border-bottom: 1px solid #eee;
transition: background 0.2s;
}
#playlist-items li:hover {
background: #f5f5f5;
}
</style>Code Editor with Syntax Highlighting
<div {{ stimulus_controller('@pwa/file-handling') }}>
<div class="code-editor">
<div class="editor-header">
<span id="filename">No file opened</span>
<button id="save-file">Save</button>
</div>
<textarea id="code-content" spellcheck="false"></textarea>
<div class="editor-status">
<span id="line-count">0 lines</span>
<span id="char-count">0 characters</span>
</div>
</div>
</div>
<script>
const codeContent = document.getElementById('code-content');
const filename = document.getElementById('filename');
const lineCount = document.getElementById('line-count');
const charCount = document.getElementById('char-count');
let currentFileHandle = null;
document.addEventListener('pwa--file-handling:selected', async (event) => {
const { data } = event.detail;
try {
// Fetch file content
const response = await fetch(data);
const text = await response.text();
// Display in editor
codeContent.value = text;
filename.textContent = 'Opened file';
updateStats();
} catch (error) {
console.error('Error loading file:', error);
alert('Failed to load file');
}
});
// Update statistics
function updateStats() {
const lines = codeContent.value.split('\n').length;
const chars = codeContent.value.length;
lineCount.textContent = `${lines} lines`;
charCount.textContent = `${chars} characters`;
}
codeContent.addEventListener('input', updateStats);
document.getElementById('save-file').addEventListener('click', async () => {
// In a real app, you would use the File System Access API to save
const blob = new Blob([codeContent.value], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename.textContent || 'file.txt';
a.click();
URL.revokeObjectURL(url);
});
</script>
<style>
.code-editor {
border: 1px solid #ccc;
border-radius: 4px;
overflow: hidden;
}
.editor-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: #f5f5f5;
border-bottom: 1px solid #ccc;
}
#code-content {
width: 100%;
height: 500px;
padding: 10px;
font-family: 'Courier New', monospace;
font-size: 14px;
border: none;
resize: vertical;
}
.editor-status {
display: flex;
gap: 20px;
padding: 10px;
background: #f5f5f5;
border-top: 1px solid #ccc;
font-size: 12px;
color: #666;
}
</style>CSV Data Importer
<div {{ stimulus_controller('@pwa/file-handling') }}>
<div class="csv-importer">
<h2>CSV Data Importer</h2>
<div id="file-info">
<p>Waiting for CSV file...</p>
</div>
<div id="data-preview" style="display:none;">
<h3>Data Preview</h3>
<table id="preview-table">
<thead id="table-head"></thead>
<tbody id="table-body"></tbody>
</table>
<button id="import-data">Import Data</button>
</div>
</div>
</div>
<script>
const fileInfo = document.getElementById('file-info');
const dataPreview = document.getElementById('data-preview');
const tableHead = document.getElementById('table-head');
const tableBody = document.getElementById('table-body');
let parsedData = null;
document.addEventListener('pwa--file-handling:selected', async (event) => {
const { data } = event.detail;
try {
// Fetch and parse CSV
const response = await fetch(data);
const text = await response.text();
parsedData = parseCSV(text);
displayPreview(parsedData);
} catch (error) {
console.error('Error loading CSV:', error);
fileInfo.innerHTML = '<p style="color:red;">Failed to load CSV file</p>';
}
});
function parseCSV(text) {
const lines = text.trim().split('\n');
const headers = lines[0].split(',').map(h => h.trim());
const rows = lines.slice(1).map(line =>
line.split(',').map(cell => cell.trim())
);
return { headers, rows };
}
function displayPreview(data) {
fileInfo.innerHTML = `<p>Found ${data.rows.length} rows</p>`;
dataPreview.style.display = 'block';
// Headers
tableHead.innerHTML = '<tr>' +
data.headers.map(h => `<th>${h}</th>`).join('') +
'</tr>';
// Preview first 10 rows
const previewRows = data.rows.slice(0, 10);
tableBody.innerHTML = previewRows.map(row =>
'<tr>' + row.map(cell => `<td>${cell}</td>`).join('') + '</tr>'
).join('');
if (data.rows.length > 10) {
fileInfo.innerHTML += `<p><em>Showing first 10 of ${data.rows.length} rows</em></p>`;
}
}
document.getElementById('import-data').addEventListener('click', () => {
if (parsedData) {
console.log('Importing data:', parsedData);
alert(`Importing ${parsedData.rows.length} rows...`);
// Send to server or process locally
}
});
</script>
<style>
#preview-table {
width: 100%;
border-collapse: collapse;
margin: 20px 0;
}
#preview-table th,
#preview-table td {
border: 1px solid #ddd;
padding: 8px;
text-align: left;
}
#preview-table th {
background: #f5f5f5;
font-weight: bold;
}
#import-data {
padding: 10px 20px;
background: #28a745;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>Parameters
None
Actions
None - This controller automatically listens for files passed through the Launch Queue API.
Targets
None
Events
pwa--file-handling:selected
pwa--file-handling:selectedDispatched for each file that the app receives through the Launch Queue API. This event is triggered when:
User right-clicks a file and selects "Open with..." your PWA
User double-clicks a file associated with your PWA
File is dragged onto your PWA icon
Payload: {data}
data(string): A blob URL or data URL representing the file content
Example:
document.addEventListener('pwa--file-handling:selected', async (event) => {
const { data } = event.detail;
console.log('Received file:', data);
// Process image
if (data.startsWith('data:image/') || data.includes('.jpg') || data.includes('.png')) {
const img = document.createElement('img');
img.src = data;
document.body.appendChild(img);
}
// Process text/code file
else if (data.startsWith('data:text/') || data.includes('.txt') || data.includes('.js')) {
const response = await fetch(data);
const text = await response.text();
console.log('File content:', text);
}
});Best Practices
Validate file types: Always validate that received files match expected types
Handle errors gracefully: File loading can fail for various reasons
Show loading states: Provide feedback while processing files
Support multiple files: The event fires once per file, handle multiple files properly
Cleanup resources: Revoke blob URLs when done to free memory
Declare file handlers: Properly configure manifest file handlers
Test thoroughly: Test with various file types and sizes
File Type Detection
document.addEventListener('pwa--file-handling:selected', async (event) => {
const { data } = event.detail;
// Fetch to get actual file type
const response = await fetch(data);
const blob = await response.blob();
const mimeType = blob.type;
console.log('MIME type:', mimeType);
// Handle based on type
if (mimeType.startsWith('image/')) {
handleImage(data);
} else if (mimeType.startsWith('video/')) {
handleVideo(data);
} else if (mimeType === 'application/pdf') {
handlePDF(data);
} else if (mimeType.startsWith('text/')) {
handleText(data);
} else {
console.warn('Unsupported file type:', mimeType);
}
});Resource Cleanup
Always clean up blob URLs to prevent memory leaks:
const loadedFiles = [];
document.addEventListener('pwa--file-handling:selected', (event) => {
const { data } = event.detail;
// Store for later cleanup
loadedFiles.push(data);
// Use the file...
});
// Clean up when done
function cleanup() {
loadedFiles.forEach(url => {
if (url.startsWith('blob:')) {
URL.revokeObjectURL(url);
}
});
loadedFiles.length = 0;
}
// Call cleanup when appropriate (e.g., when closing document)
window.addEventListener('beforeunload', cleanup);Complete Example: Multi-Format File Viewer
<div {{ stimulus_controller('@pwa/file-handling') }}>
<div class="file-viewer">
<div class="viewer-header">
<h2>Universal File Viewer</h2>
<div id="file-tabs"></div>
</div>
<div class="viewer-content">
<div id="viewer-display"></div>
</div>
<div class="viewer-footer">
<span id="file-count">No files loaded</span>
</div>
</div>
</div>
<script>
const tabsContainer = document.getElementById('file-tabs');
const display = document.getElementById('viewer-display');
const fileCount = document.getElementById('file-count');
const openFiles = [];
document.addEventListener('pwa--file-handling:selected', async (event) => {
const { data } = event.detail;
// Fetch file to get metadata
const response = await fetch(data);
const blob = await response.blob();
const mimeType = blob.type;
const fileIndex = openFiles.length;
const file = {
url: data,
type: mimeType,
blob: blob,
name: `File ${fileIndex + 1}`
};
openFiles.push(file);
createTab(file, fileIndex);
displayFile(file, fileIndex);
updateFileCount();
});
function createTab(file, index) {
const tab = document.createElement('button');
tab.className = 'file-tab';
tab.textContent = file.name;
tab.onclick = () => displayFile(file, index);
const closeBtn = document.createElement('span');
closeBtn.textContent = '×';
closeBtn.className = 'close-tab';
closeBtn.onclick = (e) => {
e.stopPropagation();
closeFile(index);
};
tab.appendChild(closeBtn);
tabsContainer.appendChild(tab);
}
function displayFile(file, index) {
// Highlight active tab
document.querySelectorAll('.file-tab').forEach((tab, i) => {
tab.classList.toggle('active', i === index);
});
// Display based on type
display.innerHTML = '';
if (file.type.startsWith('image/')) {
const img = document.createElement('img');
img.src = file.url;
img.style.maxWidth = '100%';
display.appendChild(img);
}
else if (file.type.startsWith('video/')) {
const video = document.createElement('video');
video.src = file.url;
video.controls = true;
video.style.maxWidth = '100%';
display.appendChild(video);
}
else if (file.type === 'application/pdf') {
const iframe = document.createElement('iframe');
iframe.src = file.url;
iframe.style.width = '100%';
iframe.style.height = '600px';
iframe.style.border = 'none';
display.appendChild(iframe);
}
else if (file.type.startsWith('text/')) {
const pre = document.createElement('pre');
file.blob.text().then(text => {
pre.textContent = text;
pre.style.padding = '20px';
pre.style.background = '#f5f5f5';
pre.style.overflow = 'auto';
});
display.appendChild(pre);
}
else {
display.innerHTML = `<p>Unsupported file type: ${file.type}</p>`;
}
}
function closeFile(index) {
// Revoke blob URL
if (openFiles[index].url.startsWith('blob:')) {
URL.revokeObjectURL(openFiles[index].url);
}
// Remove file and tab
openFiles.splice(index, 1);
tabsContainer.children[index].remove();
updateFileCount();
// Display previous file or clear
if (openFiles.length > 0) {
const newIndex = Math.max(0, index - 1);
displayFile(openFiles[newIndex], newIndex);
} else {
display.innerHTML = '<p>No files loaded</p>';
}
}
function updateFileCount() {
fileCount.textContent = openFiles.length === 0
? 'No files loaded'
: `${openFiles.length} file(s) loaded`;
}
</script>
<style>
.file-viewer {
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.viewer-header {
background: #f5f5f5;
padding: 15px;
border-bottom: 1px solid #ddd;
}
.viewer-header h2 {
margin: 0 0 10px 0;
}
#file-tabs {
display: flex;
gap: 5px;
overflow-x: auto;
}
.file-tab {
padding: 8px 12px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.file-tab.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.close-tab {
font-size: 18px;
line-height: 1;
opacity: 0.7;
}
.close-tab:hover {
opacity: 1;
}
.viewer-content {
padding: 20px;
min-height: 400px;
}
#viewer-display {
min-height: 300px;
}
.viewer-footer {
padding: 10px 15px;
background: #f5f5f5;
border-top: 1px solid #ddd;
font-size: 12px;
color: #666;
}
</style>Browser Compatibility
File Handling API
✅ 102+
✅ 102+
❌
❌
Launch Queue
✅ 102+
✅ 102+
❌
❌
Always provide alternative ways to open files (file input, drag-and-drop) for browsers that don't support the File Handling API.
Last updated
Was this helpful?