Web applications are entering a new era: On-Device AI.
Instead of routing every prompt to costly cloud endpoints, managing rate limits, and paying monthly API subscriptions, modern browsers (Chrome 138+) now ship with Gemini Nano directly built-in.
One of the most exciting additions is the Chrome Summarizer API. It allows you to distill lengthy articles, documentation, or chat logs into concise summaries, key points, or headlinesโcompletely client-side, with zero network latency, 100% privacy, and no API keys required.
In this article, weโll build a production-ready, streaming AI Summarizer component in React and TypeScript, complete with model download progress tracking, multiple summary formats, and official typings via @types/dom-chromium-ai.
๐ Why On-Device Summarization?
- ๐ Zero Data Leakage: Content never leaves the user's machine.
- โก Ultra-Low Latency: Local execution means near-instant time-to-first-token.
- ๐ธ Zero Cloud Costs: No OpenAI / Anthropic bills for reading or summarizing pages.
- ๐ถ Offline Capable: Works without an active internet connection once the model weights are downloaded.
๐ฆ 1. Setting Up TypeScript Definitions
Chrome's Built-in AI APIs adhere to the emerging W3C Web Incubator / WebML specs. To get autocomplete and compile-time validation, install the official typings:
npm install -D @types/dom-chromium-ai
Update your tsconfig.app.json (or tsconfig.json) to register the types:
{
"compilerOptions": {
"types": ["vite/client", "dom-chromium-ai"]
}
}
This gives you first-class types for Summarizer, LanguageModel, Writer, Rewriter, Translator, and more!
๐ 2. Probing API Availability
Before triggering AI inference, we must check if the user's browser supports the API and whether the local Gemini Nano model is ready:
// src/lib/summarizer.ts
/// <reference types="dom-chromium-ai" />
export type SummarizerAvailabilityStatus =
| 'readily'
| 'after-download'
| 'available'
| 'downloadable'
| 'downloading'
| 'unavailable'
| 'no'
| 'unsupported';
export function getSummarizerAPI() {
if (typeof window === 'undefined') return null;
const anyWin = window as any;
const anySelf = typeof self !== 'undefined' ? (self as any) : null;
return (
anyWin.ai?.summarizer ||
anySelf?.ai?.summarizer ||
anyWin.Summarizer ||
anySelf?.Summarizer ||
null
);
}
export async function checkSummarizerAvailability(
options?: SummarizerCreateOptions
): Promise<SummarizerAvailabilityStatus> {
try {
const api = getSummarizerAPI();
if (!api) return 'unsupported';
if (typeof api.availability === 'function') {
return (await api.availability(options)) as SummarizerAvailabilityStatus;
}
if (typeof api.capabilities === 'function') {
const caps = await api.capabilities();
return (caps?.available as SummarizerAvailabilityStatus) || 'unsupported';
}
return 'unsupported';
} catch (err) {
console.debug('Availability probe failed:', err);
return 'unsupported';
}
}
export function isSummarizerUsable(status: SummarizerAvailabilityStatus): boolean {
return ['readily', 'available', 'after-download', 'downloadable', 'downloading'].includes(status);
}
๐ก Pro-Tip: If the browser returns
unsupportedorno, you can simply hide the UI or render a fallback so non-Chrome users never experience broken buttons.
โก 3. Handling Model Downloads & Real-Time Streaming
The Summarizer API allows you to pass a monitor callback to observe downloading progress when the model is downloaded for the first time.
Additionally, summarizeStreaming() returns a ReadableStream (or AsyncIterable), letting us stream tokens sequentially into our UI:
// src/lib/summarizer.ts
export interface SummarizeExecutionOptions {
content: string;
type: 'key-points' | 'tldr' | 'teaser' | 'headline';
format: 'markdown' | 'plain-text';
length: 'short' | 'medium' | 'long';
sharedContext?: string;
onChunk?: (fullText: string, latestDelta: string) => void;
onDownloadProgress?: (percent: number) => void;
signal?: AbortSignal;
}
export async function generateArticleSummary({
content,
type,
format,
length,
sharedContext,
onChunk,
onDownloadProgress,
signal,
}: SummarizeExecutionOptions): Promise<string> {
const api = getSummarizerAPI();
if (!api) throw new Error('Summarizer API not supported.');
const createOptions: SummarizerCreateOptions = {
type,
format,
length,
outputLanguage: 'en',
sharedContext: sharedContext || 'Technical software article',
signal,
monitor: (monitor: CreateMonitor) => {
monitor.addEventListener('downloadprogress', (e: ProgressEvent) => {
if (e.total && e.total > 0) {
onDownloadProgress?.(Math.min(100, Math.round((e.loaded / e.total) * 100)));
} else if (e.loaded > 0) {
onDownloadProgress?.(Math.min(99, Math.round(e.loaded * 100)));
}
});
},
};
const summarizer = await api.create(createOptions);
try {
if (typeof summarizer.summarizeStreaming === 'function') {
const stream = summarizer.summarizeStreaming(content, { signal });
let accumulated = '';
// Async iterable reader
if (stream && typeof (stream as any)[Symbol.asyncIterator] === 'function') {
for await (const chunk of stream as AsyncIterable<string>) {
if (signal?.aborted) break;
accumulated += chunk;
onChunk?.(accumulated, chunk);
}
return accumulated;
}
// ReadableStream reader fallback
if (stream && typeof (stream as ReadableStream<string>).getReader === 'function') {
const reader = (stream as ReadableStream<string>).getReader();
try {
while (true) {
if (signal?.aborted) break;
const { done, value } = await reader.read();
if (done) break;
if (value) {
accumulated += value;
onChunk?.(accumulated, value);
}
}
} finally {
reader.releaseLock();
}
return accumulated;
}
}
// Direct fallback
const summary = await summarizer.summarize(content, { signal });
onChunk?.(summary, summary);
return summary;
} finally {
summarizer.destroy();
}
}
๐จ 4. Building the React HUD Summarizer Component
Now, let's create a sleek React component that:
- Hides automatically if the browser lacks support.
- Lets readers choose summary styles: Key Points, TL;DR, Teaser, or Headline.
- Renders streaming text in real time with an animated cursor.
- Provides a one-click copy button.
// src/components/ArticleSummarizer.tsx
import React, { useState, useEffect, useRef } from 'react';
import { Sparkles, Zap, Copy, Check, RotateCw, Square } from 'lucide-react';
import {
checkSummarizerAvailability,
isSummarizerUsable,
generateArticleSummary,
type SummarizerAvailabilityStatus,
} from '../lib/summarizer';
interface ArticleSummarizerProps {
content: string;
articleTitle?: string;
}
export const ArticleSummarizer: React.FC<ArticleSummarizerProps> = ({
content,
articleTitle,
}) => {
const [availability, setAvailability] = useState<SummarizerAvailabilityStatus | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [type, setType] = useState<'key-points' | 'tldr' | 'teaser' | 'headline'>('key-points');
const [length, setLength] = useState<'short' | 'medium' | 'long'>('medium');
const [summary, setSummary] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [progress, setProgress] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
let isMounted = true;
void checkSummarizerAvailability().then((status) => {
if (isMounted) setAvailability(status);
});
return () => { isMounted = false; };
}, []);
// Gracefully hide if browser has no Gemini Nano support
if (!availability || !isSummarizerUsable(availability)) {
return null;
}
const handleSummarize = async () => {
if (isGenerating) return;
setSummary('');
setProgress(null);
setIsGenerating(true);
setIsOpen(true);
const controller = new AbortController();
abortRef.current = controller;
try {
await generateArticleSummary({
content,
type,
format: 'markdown',
length,
sharedContext: articleTitle ? `Article titled "${articleTitle}"` : undefined,
signal: controller.signal,
onDownloadProgress: (pct) => setProgress(pct),
onChunk: (accumulated) => setSummary(accumulated),
});
} catch (err: any) {
if (!controller.signal.aborted) {
console.error('Summarization failed:', err);
}
} finally {
setIsGenerating(false);
setProgress(null);
}
};
const handleCopy = () => {
void navigator.clipboard.writeText(summary);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="rounded-xl border border-lime-500/30 bg-[#0a0d09]/95 p-4 text-slate-200">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles className="text-lime-400 animate-pulse" size={18} />
<span className="font-mono text-xs font-bold text-lime-400 uppercase">
GEMINI NANO // ON-DEVICE AI
</span>
</div>
{!summary && !isGenerating ? (
<button
onClick={() => void handleSummarize()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-lime-400 text-black text-xs font-mono font-bold hover:bg-lime-300 transition-all cursor-pointer"
>
<Zap size={13} />
<span>SUMMARIZE ARTICLE</span>
</button>
) : isGenerating ? (
<button
onClick={() => abortRef.current?.abort()}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-rose-500/20 text-rose-300 border border-rose-500/40 text-xs font-mono"
>
<Square size={12} className="fill-rose-400" />
<span>HALT</span>
</button>
) : null}
</div>
{isOpen && (
<div className="mt-4 space-y-4">
{/* Mode Selector */}
<div className="flex flex-wrap gap-2 text-xs font-mono">
{(['key-points', 'tldr', 'teaser', 'headline'] as const).map((t) => (
<button
key={t}
onClick={() => setType(t)}
disabled={isGenerating}
className={`px-2.5 py-1 rounded border transition-colors cursor-pointer ${
type === t
? 'border-lime-400 bg-lime-400/20 text-lime-300'
: 'border-[#232f1e] bg-black/40 text-slate-400'
}`}
>
{t.toUpperCase()}
</button>
))}
</div>
{/* Download Progress */}
{progress !== null && (
<div className="space-y-1 font-mono text-xs text-lime-300">
<div className="flex justify-between">
<span>Downloading Gemini Nano Weights...</span>
<span>{progress}%</span>
</div>
<div className="h-1.5 w-full bg-black rounded-full overflow-hidden border border-lime-500/30">
<div className="h-full bg-lime-400 transition-all" style={{ width: `${progress}%` }} />
</div>
</div>
)}
{/* Stream Output */}
{summary && (
<div className="p-4 rounded-lg bg-black/60 border border-[#232f1e] whitespace-pre-wrap text-sm leading-relaxed">
{summary}
{isGenerating && <span className="inline-block w-2 h-4 ml-1 bg-lime-400 animate-pulse" />}
</div>
)}
{/* Action Footer */}
{summary && !isGenerating && (
<div className="flex justify-between items-center text-xs font-mono">
<button
onClick={() => void handleSummarize()}
className="flex items-center gap-1.5 text-slate-400 hover:text-lime-400 transition-colors"
>
<RotateCw size={12} />
<span>REGENERATE</span>
</button>
<button
onClick={handleCopy}
className="flex items-center gap-1.5 px-3 py-1.5 rounded border border-[#232f1e] bg-black/40 text-slate-300 hover:text-lime-400"
>
{copied ? <Check size={13} className="text-lime-400" /> : <Copy size={13} />}
<span>{copied ? 'COPIED' : 'COPY'}</span>
</button>
</div>
)}
</div>
)}
</div>
);
};
๐ ๏ธ 5. Testing it in Chrome
To test the Summarizer API in your browser:
- Use Google Chrome (Canary / Dev / Beta 138+).
- Open
chrome://flagsin your address bar:-
#optimization-guide-on-device-model: Set to Enabled BypassPrefRequirement. -
#summarization-api-for-gemini-nano: Set to Enabled.
-
- Relaunch Chrome.
- Open
chrome://componentsand ensure Optimization Guide On Device Model is fully downloaded.
๐ฏ Wrap Up
The Web platform is becoming intelligent by default. With Chrome's Built-in AI and the Summarizer API:
- Your users get instant insights without paying API subscriptions.
- Their sensitive data never leaves the browser.
- You can build delightful, futuristic user experiences with minimal boilerplate.
Have you experimented with Gemini Nano in Chrome yet? Let me know your thoughts in the comments below! ๐
Found this helpful? Star my portfolio on GitHub: vitorstick/vitor-space
Top comments (0)