A thin React layer over the browser's Built-in AI APIs — models the browser downloads and runs on-device, including Gemini Nano (which powers the Prompt and writing APIs). Six task APIs plus the Prompt API (LanguageModel), each with a React hook and an imperative creator.
Used raw, these APIs make you hand-roll availability probes, user-activation-gated downloads, downloadprogress wiring, instance cleanup, and deduplication across components. The hooks fold all of that into one lifecycle state machine: read status and progress, call the task method. TypeScript-first, with option and return types exported for every API; ESM, no runtime dependencies (one type-only package).
Browser support — Chromium only (Chrome 138+, Edge); not Firefox or Safari. Some of the globals are still gated by Chrome flags or an origin trial, and all are absent on unsupported builds — feature-detect with isSupported() and render a fallback.
npm install @shayc/react-built-in-aiRequires React 18 or 19 (peer dependency). Client-only — add "use client" in RSC setups (e.g. the Next.js app router).
import { useTranslator } from "@shayc/react-built-in-ai";
function Translate() {
const translator = useTranslator({
sourceLanguage: "en",
targetLanguage: "es",
});
// Non-Chromium browser — see Capability check for feature detection.
if (translator.status === "unsupported") return <p>Not supported here.</p>;
return (
<button
disabled={translator.status === "downloading"}
onClick={async () => alert(await translator.translate("Hello, world."))}
>
Translate
</button>
);
}On a fresh browser, the first click triggers the model download (gated by user activation); subsequent clicks call translate directly. See Lifecycle for the full state machine.
| Browser API | React hook | Imperative creator | Chrome |
|---|---|---|---|
| Writer | useWriter |
createWriter |
138+ |
| Rewriter | useRewriter |
createRewriter |
138+ |
| Summarizer | useSummarizer |
createSummarizer |
138+ |
| Proofreader | useProofreader |
createProofreader |
138+ |
| Translator | useTranslator |
createTranslator |
138+ |
| Language Detector | useLanguageDetector |
createLanguageDetector |
138+ |
| Prompt ¹ | useLanguageModel |
createLanguageModel |
148+ (138+ in ext.) |
¹ Available earlier via the official prompt-api-polyfill. It's also a different shape — a stateful chat session, not a stateless task; see Prompt API for how useLanguageModel diverges from the other hooks.
Use the hook when options are known at render time (e.g. a translator bound to the user's current language pair). Use the creator when options are decided mid-flow and a hook can't be driven (queued work, command palettes, one-shot scripts).
Every hook shares the lifecycle surface plus task-specific methods (translate, rewrite, proofread, summarize, write, detect), along with streaming and measureInput variants where the underlying API supports them. Exceptions:
useProofreaderexposes onlyproofread— the browser API offers no streaming,measureInput, orinputQuota.useLanguageDetectorhas no streaming variant, and itsdetectresolves with an array of ranked{ detectedLanguage, confidence }candidates rather than a string.useLanguageModelis a stateful conversation rather than a one-shot task — see Prompt API.
Feature-detect before mounting any hook:
import { isSupported } from "@shayc/react-built-in-ai";
if (!isSupported("Translator")) return <Fallback />;isSupported(name) returns true when the matching global ("Writer", "Rewriter", "Summarizer", "Proofreader", "Translator", "LanguageDetector", "LanguageModel") is present on globalThis. Combine with the hook's status ("unavailable") for the full readiness picture — the global can exist on a device that still can't run the model.
checkAvailability(name, options) runs the same on-device readiness probe every hook and creator uses internally, without mounting a hook or creating an instance. Use it for a capability list or settings screen that needs a real status for options the user hasn't committed to yet. Options are optional for every API except "Translator", which needs a language pair to answer meaningfully:
import { checkAvailability } from "@shayc/react-built-in-ai";
const availability = await checkAvailability("Translator", {
sourceLanguage: "en",
targetLanguage: "es",
});
// "available" | "downloadable" | "downloading" | "unavailable"It throws UnsupportedError when the global is absent — check isSupported() first if you'd rather branch on that yourself. Unlike the hook's status, this is a one-shot probe: it doesn't stay in sync if availability changes afterward.
Every hook exposes status, progress, error, and prepare. The paths through the state machine:
checking → ready model already on device
checking → downloadable → downloading → ready download needed — starts on a user gesture
checking | downloading → error probe/create rejected — prepare() retries
unsupported / unavailable terminal — no global / device can't run the model
status is always one of:
Terminal states:
unsupported— the global namespace is missing on this browser.unavailable— the model reports it cannot run on this device.
Transition states:
checking— supported; probing availability, or quietly creating an already-downloaded model. Passes through toreadyon its own.downloadable— the model needs a download, which the browser only starts from a user activation. Render your download affordance off this state;prepare()(or any action method) called from a user gesture moves it along. (The name mirrors the browser'savailability()vocabulary.) Edge case: a gesture-less call doesn't fail immediately — it re-checks availability first, in case another component or tab finished the download since this hook last looked, and joins an in-flight download if it finds one.downloading— a download is running.progressisnulluntil the browser reports a fraction via adownloadprogressevent, then a number — no starting value is ever fabricated, so a joined download that's already partway done isn't misreported as0. The download may have been started by this hook or joined passively (another hook with different options, another tab, or an imperativecreate*call already had it in flight).
Active state:
ready— the instance is live; action methods can be called freely.
Failure state:
error—availability()orcreate()rejected. Callprepare()(from a user activation if a download is required) to tear down and re-initialize.
Hooks with equal options — same namespace, same options by value — share one underlying model instance and one status/progress/error. The instance is created on the first mount that needs it and torn down when the last component using those options unmounts; everything in between (including a prepare() retry from error) is visible to every component sharing it. This means two components — say, a settings panel showing download status and a toolbar actually using the model — stay in sync automatically as long as they're called with the same options.
useLanguageModel is the one exception: each mount owns a private session (conversations must stay isolated, and its options can't be structurally keyed), so equal-options mounts never share an instance. See Prompt API.
unsupportedon a current Chrome — some APIs are still flag- or origin-trial-gated depending on the API and Chrome release (the Prompt API on the web most notably). Check the Chrome doc linked in the API table for current status.unavailable— the device doesn't meet Chrome's hardware or storage requirements for that model (also listed in each API's Chrome doc). Render a fallback; retrying won't help.- Stuck on
downloadable— downloads start only from a user gesture. Callprepare()or any action method from a click/keypress handler, not from an effect.
Action methods are gated by the lifecycle — they throw UnsupportedError, UnavailableError, MissingUserActivationError, or NotReadyError when the state forbids them. A call rejected by the gate never mutates the hook's status or error. (A call made from downloadable with a user activation is not gate-rejected — it drives status through downloading to ready or error like prepare().)
function Demo() {
const translator = useTranslator({
sourceLanguage: "en",
targetLanguage: "es",
});
// 1. Guard against browsers/devices that can't run the model.
if (translator.status === "unsupported") return <p>Not supported.</p>;
if (translator.status === "unavailable") return <p>Not available.</p>;
return (
<button
// 2. Block re-entry while the model is downloading.
disabled={translator.status === "downloading"}
onClick={async () => {
// 3. The click is a user activation, so the hook is allowed to start
// the download here if status was "downloadable"; otherwise it runs
// at once.
const out = await translator.translate("…some text…");
console.log(out);
}}
>
{translator.status !== "downloading"
? "Translate"
: translator.progress === null // null until the browser reports a fraction
? "Downloading…"
: `Downloading (${Math.round(translator.progress * 100)}%)`}
</button>
);
}Accumulate chunks into React state to render incrementally:
const [output, setOutput] = useState("");
async function handleTranslate(text: string) {
setOutput("");
for await (const chunk of translator.translateStream(text)) {
setOutput((prev) => prev + chunk);
}
}try {
await using translator = await createTranslator({
sourceLanguage,
targetLanguage,
});
const text = await translator.translate(input);
} catch (error) {
if (!(error instanceof BuiltInAIError)) throw error;
// unsupported / unavailable / missing-activation — render a fallback.
}Each create* mirrors the hook lifecycle exactly — same three typed errors (UnsupportedError, UnavailableError, MissingUserActivationError), same progress wiring. Unlike with the hooks, other browser rejections surface unchanged — most commonly AbortError when signal fires, or NetworkError on a failed download. The instanceof BuiltInAIError check above is what separates the typed lifecycle errors from those pass-throughs.
The returned instance is AsyncDisposable — prefer await using (TypeScript 5.2+) so it's released on scope exit. On older toolchains, or to release earlier, call .destroy() in a finally.
Each creator accepts the same options as its hook, plus an optional signal that cancels both the download (if any) and the underlying create() call.
A creator requires a user activation only to start a download (availability() reporting "downloadable") — prefer calling it from an event handler, or pre-warm the model via the matching hook elsewhere in the tree before the call site is reached. If a download is already in flight elsewhere ("downloading"), the creator joins it gesture-free, same as a hook that finds one on probe.
- Per-instance — read
progressandstatusfrom the hook return, or from a creator's own thrown/awaited lifecycle. - Cross-instance —
useGlobalDownloadProgress(namespaces?)reports the progress of the least-complete in-flight download across every instance, regardless of which component (or imperative caller) initiated the download. The value never moves backwards when one of several downloads finishes, and returns tonullonce all of them complete — finished downloads stop being tracked, so key "done" offnull, notprogress === 1. Pass a namespace ("Writer","Rewriter","Summarizer","Proofreader","Translator","LanguageDetector","LanguageModel") or an array of namespaces to scope the aggregation, or call with no argument to track all Built-in AI downloads.
function GlobalDownloadBar() {
const progress = useGlobalDownloadProgress();
if (progress === null) return null;
return <ProgressBar value={progress} />;
}Options are compared structurally — sorted and compared by content, not by reference — so inline object/array literals are safe without memoization: a fresh literal with the same content resolves to the same underlying instance.
When a component's options change, it re-enters the state machine. If it was the last component holding the old options, that instance is destroyed and its in-flight work aborted with AbortError; if another component still holds the old options, that instance keeps running for it — an option change never disturbs components still sharing the old instance.
This applies to the six task hooks. useLanguageModel captures its options once, at mount, and ignores later changes — see below.
useLanguageModel wraps the Prompt API (LanguageModel) — a stateful chat session rather than a stateless task. It shares the same lifecycle (status, progress, error, prepare) and the same download/gesture rules as every other hook, but three things differ by design:
- Each mount owns a private session. Equal-options mounts never share one instance — sharing would cross-contaminate two components' conversations, and the options (closures in
tools, blobs ininitialPrompts) can't be keyed by value anyway. The expensive part — the model download — is still deduplicated by the browser across every session, so concurrent mounts join the same in-flight download gesture-free. - Options are captured at mount and never re-read from props. A stateful conversation must not be silently discarded because a parent re-rendered with a fresh literal — so inline option literals are safe here precisely because they're ignored after the first render. Change options explicitly with
reset(nextOptions)or akey=remount. - The session is not exposed. For the raw
LanguageModel(toclone(), or drivedestroy()yourself), usecreateLanguageModel().
function Chat() {
const model = useLanguageModel({
initialPrompts: [
{ role: "system", content: "You are a helpful assistant." },
],
});
const [reply, setReply] = useState("");
if (model.status === "unsupported") return <p>Prompt API not supported.</p>;
return (
<>
<button
disabled={model.status !== "ready"}
onClick={async () => setReply(await model.prompt("Say hi in French."))}
>
Ask
</button>
<p>{reply}</p>
<small>
{model.contextUsage} / {model.contextWindow} tokens
</small>
</>
);
}The session methods:
Core interaction:
prompt(input, options?)/promptStream(input, options?)— send a turn and get the full response, or stream it (concatenating chunks yields the same result). Both commit the turn to history.append(input, options?)— add to the history without prompting for a response, e.g. to pre-load context.
Session management:
measureContext(input, options?)— estimate how many tokensinputwould add, without sending it.reset(nextOptions?)— discard the conversation and provision a fresh session (with the same options, or a full replacement). Aborts in-flight calls withAbortError, destroys the old session, and zeroescontextUsage/overflowCount.
input is a string or a message array; options accepts responseConstraint (a JSON schema or RegExp for structured output) and signal. Multimodal input (expectedInputs with image / audio) and assistant-message prefix: true continuations pass through the create/prompt options unchanged.
contextUsagegrows every turn;contextWindowis the session's fixed budget (both0untilready).overflowCountcountscontextoverflowevents — the browser auto-evicting old turns once the window fills.useEffecton it to drive the session-compacting pattern: summarize the transcript (useSummarizerpairs naturally), thenresetwith the summary as compactedinitialPrompts.
const model = useLanguageModel({ initialPrompts });
useEffect(() => {
if (model.overflowCount === 0) return;
// Summarize the running transcript you've been tracking, then restart the
// session with a compacted context — the documented compacting recipe.
const compacted = compactTranscript();
model.reset({ initialPrompts: compacted });
}, [model.overflowCount]);Not exposed. In stable Chrome on the web, the raw topK / temperature params are silently ignored, and the spec's proposed replacement (samplingMode presets) hasn't shipped — neither is a working knob yet. samplingMode is still accepted in the options type (the browser ignores it until it ships); the type will widen once the surface settles.
Lifecycle gating throws BuiltInAIError subclasses. Action methods (translate, rewrite, …) pass the browser API's own rejections through unchanged — most commonly an AbortError DOMException when a signal fires. When the lifecycle wraps a browser rejection into "error" state, the original error is preserved as error.cause.
| Error | What to do |
|---|---|
UnsupportedError |
The namespace is missing. Feature-detect with isSupported() and render a fallback. |
UnavailableError |
The device can't run the model. Render a fallback; don't retry. |
MissingUserActivationError |
A download needed to be started without a user gesture. Trigger prepare() (or the first action) from a click/keypress handler. |
NotReadyError |
A prior create() failed. Call prepare() from a user activation to retry; inspect error.cause for the underlying reason. |
Components with equal options share one lifecycle: if another component sharing your options calls prepare() to retry from "error", that restarts the shared store — any of your own in-flight action calls reject with a DOMException named AbortError. Filter it like any other cancellation (error.name === "AbortError"), not as a failure.
A per-call signal cancels the caller's wait and the underlying action call, but does not tear down the shared model instance. If the hook is mid-download, aborting one call rejects that call with AbortError while the download keeps running for any other caller (and for the next call from the same component). The download is only cancelled when the last component sharing it unmounts (or changes options such that it's no longer the last holder). A sibling component's prepare() retry can also abort your in-flight call — see Errors above.
No network calls — everything runs against the browser's on-device model. Releases are published to npm with provenance attestations so the bytes you install can be traced back to a specific GitHub Actions run.
Found a security issue? Open a private advisory at github.com/shayc/react-built-in-ai/security/advisories/new.
Semver; see CHANGELOG.md.
See CONTRIBUTING.md for development setup (Node 22+, Vitest browser mode, the changeset workflow).
MIT © Shay Cojocaru