Multimodal Browser AI with Transformers.js for Images and Speech
Multimodal Browser AI with Transformers.js for Images and Speech
Most browser AI tutorials cover text because it is a natural starting point, but the applications people actually want to build are rarely text-only.
In this article, you will learn how to build multimodal AI capabilities - image classification, image captioning, and speech transcription - that run entirely in the browser using Transformers.js, with no server, no API key, and no data leaving the user's device.
Topics we will cover include:
- How to set up and run image classification and image captioning pipelines using Vision Transformer models in the browser.
- How to implement browser-based speech transcription using OpenAI's Whisper architecture via the Web Audio API.
- How to combine all three pipelines into a single multimodal media analyzer that loads models in parallel and presents results in a unified dashboard.
Introduction
Most browser AI tutorials cover text because it is a natural starting point, but the applications people actually want to build are rarely text-only. Users take photos, record voice notes, upload screenshots. The data is multimodal and the AI should be too.
Transformers.js handles this natively. It supports computer vision (image classification, object detection, segmentation), audio (automatic speech recognition, audio classification, text-to-speech), and multimodal tasks, all running locally in the browser, with no server, no API key, and no data leaving the user's device.
This tutorial builds three capabilities in sequence: image classification, image captioning, and speech transcription. Each is a self-contained HTML file you can open in a browser. The final section combines all three into a single multimodal media analyzer.
What You Need
- A modern browser: Chrome 109+, Edge 109+, or Firefox 90+. These versions support ES modules and WebAssembly, both of which Transformers.js requires.
- A local web server: Browser security policies block ES module imports from
file://URLs - opening the HTML files directly by double-clicking will not work. You need to serve them over HTTP. You do not need Node.js, npm, or any build tools. The CDN import handles the library.
Starting a Local Server
Pick whichever option matches what you already have installed:
# Python -- pre-installed on macOS and most Linux systems
python3 -m http.server 8080
# Node.js
npx serve .
# VS Code -- install the Live Server extension, then right-click any HTML file
# and choose "Open with Live Server"
Once the server is running, open http://localhost:8080 in your browser.
Project Structure
Create one folder for the project. Each task gets its own HTML file:
multimodal-demo/
├── image-classifier.html
├── image-captioner.html
├── speech-transcriber.html
└── media-analyzer.html
Models and Download Sizes
Every model downloads once on the first run and caches in the browser. Subsequent loads are instant and work offline. Here is what to expect on the first run:
| Task | Model | Pipeline task | First-run download |
|---|---|---|---|
| Image Classification | Xenova/vit-base-patch16-224 |
image-classification |
~88 MB |
| Image Captioning | Xenova/vit-gpt2-image-captioning |
image-to-text |
~246 MB |
| Speech Transcription | Xenova/whisper-tiny.en |
automatic-speech-recognition |
~78 MB |
The combined app loads all three, roughly 400 MB total on first run. A progress indicator for each model is non-negotiable UX.
Task 1: Image Classification
Image classification assigns labels from a fixed set to an input image. The model used here is ViT-Base/16, a Vision Transformer trained by Google on ImageNet-21k and fine-tuned on ImageNet-1k, converted to ONNX format for browser use. It classifies images into 1,000 ImageNet categories and returns a ranked list with confidence scores.
What the output looks like:
// Output from classifier(imageUrl)
[
{ label: 'golden retriever', score: 0.9421 },
{ label: 'Labrador retriever', score: 0.0312 },
{ label: 'Sussex spaniel', score: 0.0098 },
// ... top_k results total
]
Each object has a label string (the ImageNet class name) and a score float between 0 and 1. By default, the pipeline returns 5 results. Set top_k in the call to get more or fewer.
Full Working Demo
Save this file as image-classifier.html in your project folder. Copy the code below and open it on your localhost.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Classifier</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; background: #f8fafc; color: #1e293b; }
h1 { margin-bottom: 0.25rem; font-size: 1.4rem; }
.subtitle { color: #64748b; font-size: 0.9rem; margin-bottom: 1.5rem; }
#status { font-size: 0.85rem; color: #64748b; margin-bottom: 1rem; }
.upload-area { border: 2px dashed #cbd5e1; border-radius: 8px; padding: 2rem; text-align: center; cursor: pointer; background: white; transition: border-color 0.2s; }
.upload-area:hover { border-color: #2563eb; }
.upload-area input { display: none; }
#preview { margin-top: 1rem; max-width: 100%; border-radius: 8px; display: none; }
.result-row { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.6rem; }
.result-label { min-width: 200px; font-size: 0.9rem; }
.bar-bg { flex: 1; background: #e2e8f0; border-radius: 4px; height: 16px; }
.bar-fill { background: #2563eb; height: 100%; border-radius: 4px; transition: width 0.4s ease; }
.result-score { min-width: 48px; text-align: right; font-size: 0.85rem; color: #475569; }
#results { margin-top: 1.25rem; }
#results h3 { font-size: 0.95rem; color: #374151; margin-bottom: 0.5rem; }
</style>
</head>
<body>
<h1>Image Classifier</h1>
<p class="subtitle">Upload any image - ViT classifies it into ImageNet categories. Runs entirely in your browser.</p>
<div id="status">Downloading model (~88 MB on first run)...</div>
<div id="drop-zone" class="upload-area">
<p>Click to upload or drag an image here</p>
<p style="font-size:0.8rem;color:#94a3b8;margin-top:0.5rem;">JPG, PNG, WebP, GIF supported</p>
<input type="file" id="file-input" accept="image/*">
</div>
<img id="preview" alt="Preview">
<div id="results"></div>
<script type="module">
import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.2';
const statusEl = document.getElementById('status');
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const preview = document.getElementById('preview');
const resultsEl = document.getElementById('results');
// ── Load the image classification pipeline ──────────────────────────
// Task: 'image-classification'
// Model: Xenova/vit-base-patch16-224 -- ViT trained on ImageNet-1k
// dtype: 'q8' -- 8-bit quantized for smaller download, good accuracy
let classifier;
pipeline('image-classification', 'Xenova/vit-base-patch16-224', {
dtype: 'q8',
progress_callback: (p) => {
if (p.status === 'progress') {
statusEl.textContent = `Downloading model: ${Math.round(p.progress ?? 0)}%`;
}
}
}).then(pipe => {
classifier = pipe;
statusEl.textContent = 'Model ready. Upload an image to classify it.';
dropZone.style.borderColor = '#22c55e';
}).catch(err => {
statusEl.textContent = `Error loading model: ${err.message}`;
});
// ── Classify an image from a data URL ───────────────────────────────
async function classifyImage(dataUrl) {
statusEl.textContent = 'Classifying...';
resultsEl.innerHTML = '';
try {
// Pass the data URL directly -- the pipeline handles image decoding
// top_k: 5 returns the 5 highest-scoring ImageNet labels
const results = await classifier(dataUrl, { top_k: 5 });
statusEl.textContent = 'Done.';
// Build a bar chart of results
let html = '<h3>Top predictions</h3>';
results.forEach(({ label, score }) => {
const pct = (score * 100).toFixed(1);
const bar = (score * 100).toFixed(0);
html += `
<div class="result-row">
<span class="result-label">${label}</span>
<div class="bar-bg"><div class="bar-fill" style="width:${bar}%"></div></div>
<span class="result-score">${pct}%</span>
</div>`;
});
resultsEl.innerHTML = html;
} catch (err) {
statusEl.textContent = `Classification error: ${err.message}`;
}
}
// ── File handling ────────────────────────────────────────────────────
function handleFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target.result;
// Show the image preview
preview.src = dataUrl;
preview.style.display = 'block';
// Classify only if the model has finished loading
if (classifier) {
classifyImage(dataUrl);
} else {
statusEl.textContent = 'Model still loading -- please wait a moment and try again.';
}
};
// Read as a base64 data URL -- works as pipeline input
reader.readAsDataURL(file);
}
// Click to browse
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => handleFile(e.target.files[0]));
// Drag and drop
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.style.borderColor = '#2563eb';
});
dropZone.addEventListener('dragleave', () => {
dropZone.style.borderColor = '#cbd5e1';
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.borderColor = '#cbd5e1';
handleFile(e.dataTransfer.files[0]);
});
</script>
</body>
</html>
What this code does:
- The
pipeline()call starts downloading the model immediately when the page opens. - The progress callback updates the status text so the user can see the download progressing.
- Once
classifieris assigned, the drop zone border turns green as a visual cue. - When a file is dropped or selected,
FileReaderconverts it to a base64 data URL, which the pipeline accepts directly as image input - no manual preprocessing needed. - The classifier returns an array of
{ label, score }objects, which the rendering loop converts into a horizontal bar chart. Thetop_k: 5option limits results to the five most likely classes.
Task 2: Image Captioning
Image captioning generates a natural language sentence describing what is in an image. It is meaningfully different from classification: instead of picking from 1,000 fixed labels, the model generates free-form text. "A golden retriever running through a field of tall grass" versus just "golden retriever." More descriptive, more flexible, larger model.
The model used here is Xenova/vit-gpt2-image-captioning, a Vision Transformer encoder that reads the image paired with a GPT-2 decoder that generates the caption. The ONNX version weighs in at 246 MB, noticeably larger than the classifier, because the generative decoder is a full language model.
What the output looks like:
// Output from captioner(imageUrl)
[{ generated_text: 'a dog is playing on a tennis court' }]
The output is an array with one object containing a generated_text string. It is always an array even for a single image, because the pipeline supports batching.
Full Working Demo
Save this file as image-captioner.html. Run it on http://localhost.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Captioner</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; max-width: 700px; margin: 2rem auto; padding: 0 1rem; background: #f8fafc; color: #1e293b; }
h1 { margin-bottom: 0.25rem; font-size: 1.4rem; }
.subtitle { color: #64748b; font-size: 0.9rem; margin-bottom: 1.5rem; }
#status { font-size: 0.85rem; color: #64748b; margin-bottom: 1rem; }
.upload-area { border: 2px dashed #cbd5e1; border-radius: 8px; padding: 2rem; text-align: center; cursor: pointer; background: white; }
.upload-area input { display: none; }
#preview { margin-top: 1rem; max-width: 100%; border-radius: 8px; display: none; }
.comparison { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-top: 1.25rem; }
.result-card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 1rem; }
.result-card h3 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; color: #64748b; margin-bottom: 0.5rem; }
.caption-text { font-size: 1rem; color: #1e293b; line-height: 1.5; font-style: italic; }
.label-list { list-style: none; }
.label-list li { font-size: 0.9rem; padding: 0.2rem 0; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; }
@media (max-width: 500px) { .comparison { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<h1>Image Captioner</h1>
<p class="subtitle">Generates a natural language description of any image. Runs classification and captioning in parallel for comparison.</p>
<div id="status">Downloading models (~334 MB on first run)...</div>
<div id="drop-zone" class="upload-area">
<p>Click to upload or drag an image here</p>
<input type="file" id="file-input" accept="image/*">
</div>
<img id="preview" alt="Preview">
<div class="comparison" id="comparison">
<div class="result-card">
<h3>Classification (fixed labels)</h3>
<ul id="label-list" class="label-list"></ul>
</div>
<div class="result-card">
<h3>Caption (generated text)</h3>
<p id="caption-text" class="caption-text">--</p>
</div>
</div>
<script type="module">
import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.0.2';
const statusEl = document.getElementById('status');
const dropZone = document.getElementById('drop-zone');
const fileInput = document.getElementById('file-input');
const preview = document.getElementById('preview');
const comparisonEl = document.getElementById('comparison');
const labelListEl = document.getElementById('label-list');
const captionEl = document.getElementById('caption-text');
// ── Load both pipelines in parallel ─────────────────────────────────
// Using Promise.all starts both downloads simultaneously,
// which is faster than loading them one after the other.
let classifier, captioner;
Promise.all([
pipeline('image-classification', 'Xenova/vit-base-patch16-224', {
dtype: 'q8',
progress_callback: (p) => {
if (p.status === 'progress') {
statusEl.textContent = `Downloading classifier: ${Math.round(p.progress ?? 0)}%`;
}
}
}),
pipeline('image-to-text', 'Xenova/vit-gpt2-image-captioning', {
dtype: 'q8',
progress_callback: (p) => {
if (p.status === 'progress') {
statusEl.textContent = `Downloading captioner: ${Math.round(p.progress ?? 0)}%`;
}
}
})
]).then(([classifierPipe, captionerPipe]) => {
classifier = classifierPipe;
captioner = captionerPipe;
statusEl.textContent = 'Both models ready. Upload an image.';
dropZone.style.borderColor = '#22c55e';
}).catch(err => {
statusEl.textContent = `Error loading models: ${err.message}`;
});
// ── Analyze an image ────────────────────────────────────────────────
async function analyzeImage(dataUrl) {
statusEl.textContent = 'Analyzing...';
labelListEl.innerHTML = '';
captionEl.textContent = 'Generating...';
try {
const [classResults, captionResults] = await Promise.all([
classifier(dataUrl, { top_k: 3 }),
captioner(dataUrl)
]);
statusEl.textContent = 'Done.';
// Show classification labels
let labelsHtml = '';
classResults.forEach(({ label, score }) => {
labelsHtml += `<li><span>${label}</span><span>${(score * 100).toFixed(1)}%</span></li>`;
});
labelListEl.innerHTML = labelsHtml;
// Show caption
captionEl.textContent = captionResults[0].generated_text;
} catch (err) {
statusEl.textContent = `Analysis error: ${err.message}`;
}
}
// ── File handling ────────────────────────────────────────────────────
function handleFile(file) {
if (!file || !file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = (e) => {
const dataUrl = e.target.result;
preview.src = dataUrl;
preview.style.display = 'block';
if (classifier && captioner) {
analyzeImage(dataUrl);
} else {
statusEl.textContent = 'Models still loading -- please wait.';
}
};
reader.readAsDataURL(file);
}
dropZone.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => handleFile(e.target.files[0]));
dropZone.addEventListener('dragover', (e) => e.preventDefault());
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
handleFile(e.dataTransfer.files[0]);
});
</script>
</body>
</html>
Comments
No comments yet. Start the discussion.