Skip to content
TutorialAI & LLMs

Summarise text in the browser with Chrome's Summarizer API

Since Chrome 138, a web page can ask the browser for a summary without sending the text anywhere. Wrap the API in a typed composable and a Vue component that degrades politely everywhere else.

vuejs.school team1 Jul 20258 min readChrome 138 · Vue 3.5

Chrome 138 reached the stable channel on 24 June with the Summarizer API. A page hands the browser some text and gets back a summary in the shape it asks for, from a list of key points to a single headline. The model runs on the device, so the text never leaves the computer and you need no server. The catch is reach: the API works only in Chrome on desktop, on machines with enough disk space and graphics memory.

This tutorial builds a <TextSummariser> component that handles both cases. Where the API works, it shows a progress bar while the model downloads and streams the summary in as it is written. Everywhere else, it says plainly what is missing. The examples are plain Vue 3.5 in a Vite project, with explicit imports.

What the Summarizer API offers

The Summarizer API documentation describes a global Summarizer object with two static methods, availability() and create(), and an instance with summarize() and summarizeStreaming(). You choose the shape of the summary when you create it:

  • type: key-points (the default), tldr, teaser or headline.
  • format: markdown (the default) or plain-text.
  • length: short, medium or long. The specification and Chrome 138 default to short, while the docs page says medium, so pass it explicitly.

Lengths are maximums, and what they mean depends on the type. A short list of key points has at most three bullets and a long one seven, while a medium headline is at most 17 words.

A sharedContext string gives the model background that applies to every call, and each call can add its own context. Once created, a summariser's options cannot change; make a new one.

Check where it runs

The docs list four requirements for the machine:

  • Windows 10 or 11, macOS 13 or later, or Linux. Chrome on Android, iOS and ChromeOS does not support it.
  • At least 22 GB free on the drive that holds the Chrome profile. The model itself needs only a couple of gigabytes; the rest is a safety margin.
  • A GPU with strictly more than 4 GB of VRAM.
  • Unlimited data or an unmetered connection, because the first use downloads the model.

Firefox and Safari do not implement the API either. So treat a summary as an extra on top of a page that already works, never as something the page depends on.

Detect support before you promise anything

Two checks happen before you touch the model. The first is synchronous: does the global exist? The second is asynchronous and asks Chrome whether this device can run the configuration you want.

if ('Summarizer' in self) {
  const availability = await Summarizer.availability()
  console.log(availability)
}

availability() resolves to one of four strings:

  • available: the model is on disk and ready.
  • downloadable: the device qualifies, but the model has not been fetched yet.
  • downloading: a download is already in progress, perhaps started by another site.
  • unavailable: this browser or device cannot run the options you asked for.

Treat the first three as "yes, with a possible wait" and the last as a hard no.

There is one more rule that surprises people. When availability is downloadable or downloading, Chrome 138 accepts create() only while the page has user activation: the browser's record that the user clicked, tapped or pressed a key a moment ago. Without it, create() rejects with a NotAllowedError that reads Requires a user gesture when availability is "downloading" or "downloadable". In practice that means you create the summariser inside a click handler and never in onMounted. Checking availability on mount is fine; starting a large download without being asked is not.

Give TypeScript the shape of the API

The Summarizer global is not in TypeScript's DOM typings yet, so a strict project will refuse to compile Summarizer.create(). A small declaration file fixes that and documents the API at the same time. Put it anywhere your tsconfig includes.

src/types/summarizer.d.ts
type SummarizerAvailability = 'unavailable' | 'downloadable' | 'downloading' | 'available'

interface SummarizerMonitor extends EventTarget {
  addEventListener(type: 'downloadprogress', listener: (event: ProgressEvent) => void): void
}

interface SummarizerOptions {
  type?: 'key-points' | 'tldr' | 'teaser' | 'headline'
  format?: 'markdown' | 'plain-text'
  length?: 'short' | 'medium' | 'long'
  sharedContext?: string
  monitor?: (monitor: SummarizerMonitor) => void
}

interface Summarizer {
  summarize(input: string, options?: { context?: string }): Promise<string>
  summarizeStreaming(input: string, options?: { context?: string }): ReadableStream<string>
  destroy(): void
}

declare const Summarizer: {
  availability(options?: Omit<SummarizerOptions, 'monitor'>): Promise<SummarizerAvailability>
  create(options?: SummarizerOptions): Promise<Summarizer>
}

A .d.ts file with no import or export declares globals, which is what we want here. The monitor option is a function Chrome calls with an event target; you listen for downloadprogress on it, and the event's loaded property goes from 0 to 1 as the model arrives.

Wrap the model in a composable

The component should not know about availability strings or readers. Put that in a composable and expose state the template can bind to.

src/composables/useSummarizer.ts
import { onScopeDispose, ref } from 'vue'

export type SummaryStatus = 'idle' | 'downloading' | 'working' | 'ready' | 'unsupported' | 'error'
export type SummaryOptions = Omit<SummarizerOptions, 'monitor'>

export function useSummarizer() {
  const supported = typeof self !== 'undefined' && 'Summarizer' in self
  const status = ref<SummaryStatus>(supported ? 'idle' : 'unsupported')
  const progress = ref(0)
  const summary = ref('')
  const message = ref('')
  let instance: Summarizer | null = null

  async function check() {
    if (!supported) return
    if ((await Summarizer.availability()) === 'unavailable') {
      status.value = 'unsupported'
      message.value = 'This computer does not meet the requirements for the built-in model.'
    }
  }

  async function summarise(text: string, options: SummaryOptions) {
    if (!supported || status.value === 'unsupported') return
    summary.value = ''
    try {
      const availability = await Summarizer.availability(options)
      if (availability === 'unavailable') return check()
      if (availability !== 'available') status.value = 'downloading'
      instance?.destroy()
      instance = await Summarizer.create({
        ...options,
        monitor(monitor) {
          monitor.addEventListener('downloadprogress', (event) => {
            progress.value = Math.round(event.loaded * 100)
          })
        },
      })
      status.value = 'working'
      const reader = instance.summarizeStreaming(text).getReader()
      while (true) {
        const { value, done } = await reader.read()
        if (done) break
        summary.value += value
      }
      status.value = 'ready'
    } catch (error) {
      status.value = 'error'
      message.value = error instanceof Error ? error.message : String(error)
    }
  }

  onScopeDispose(() => instance?.destroy())

  return { supported, status, progress, summary, message, check, summarise }
}

Read it top to bottom. supported is decided once, synchronously, so the template can render the right thing on the first paint. check() runs the cheap availability call and flips the status to unsupported on a device that cannot qualify, without downloading anything. summarise() does the real work: it asks about availability for the exact options it is about to use, moves the status to downloading when a wait is coming, creates the summariser with a monitor that feeds the progress ref, and then reads the stream chunk by chunk, appending each one to summary.

Two details are deliberate. The summariser instance is a plain variable, not a ref, because the template never needs it and wrapping a native object in a reactive proxy buys nothing. And a fresh instance is created for every request, with the previous one destroyed, because options are fixed at creation and the user may have changed the type since the last run. onScopeDispose destroys the last instance when the component goes away, which releases the model session.

The docs read the stream with for await (const chunk of stream). That works in Chrome, but TypeScript rejects it in a project made with npm create vue: the lib list it inherits from @vue/tsconfig (ES2020, DOM, DOM.Iterable) leaves out DOM.AsyncIterable, which declares a stream's async iterator. A reader loop needs no configuration, so that is what the composable uses.

Build the summariser component

With the composable in place the component is almost all template.

src/components/TextSummariser.vue
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useSummarizer } from '@/composables/useSummarizer'

const { supported, status, progress, summary, message, check, summarise } = useSummarizer()

const text = ref('')
const type = ref<'key-points' | 'tldr' | 'headline'>('key-points')
const busy = computed(() => status.value === 'downloading' || status.value === 'working')

onMounted(check)

function run() {
  summarise(text.value, { type: type.value, format: 'plain-text', length: 'medium' })
}
</script>

<template>
  <section>
    <p v-if="!supported">
      Summaries need Chrome 138 or newer on a desktop computer. Your text is safe here; there is just nothing to run it through.
    </p>
    <p v-else-if="status === 'unsupported'">{{ message }}</p>
    <template v-else>
      <label for="source">Text to summarise</label>
      <textarea id="source" v-model="text" rows="10" />
      <label for="type">Kind of summary</label>
      <select id="type" v-model="type">
        <option value="key-points">Key points</option>
        <option value="tldr">Short version</option>
        <option value="headline">Headline</option>
      </select>
      <button type="button" :disabled="busy || !text.trim()" @click="run">Summarise</button>
      <progress v-if="status === 'downloading'" :value="progress" max="100">{{ progress }}%</progress>
      <p v-if="status === 'error'" role="alert">{{ message }}</p>
      <pre aria-live="polite" :aria-busy="status === 'working'">{{ summary }}</pre>
    </template>
  </section>
</template>

The first branch is the one most visitors will see, so write it as carefully as the happy path. It says what is needed and does not blame the reader. The second branch covers a Chrome that has the global but a machine that fails the hardware check. Only the third branch shows the form.

The button is a real <button type="button">, so the click gives the page the user activation that create() needs. It is disabled while a download or a summary is in progress, and when the textarea is empty. The <progress> element appears only during the download, bound to the 0 to 100 value the composable keeps.

The <pre> that holds the result is in the page from the start, empty, because screen readers announce a live region most reliably when it exists before its text changes. aria-live="polite" has them read new text once the user is idle. aria-busy stays true while the stream runs, which asks them to wait for the finished summary rather than read every chunk.

format: 'plain-text' keeps the output free of Markdown, which is the right choice for a <pre>; pick markdown only if you have a renderer for it.

Choose streaming or a single call

summarizeStreaming() returns a ReadableStream of strings, each one the next piece of the summary as the model writes it. Without streaming, nothing appears until the last word is written, and for a paragraph of output that wait is long enough to make a page feel stuck. Use streaming for anything a person watches.

summarize() resolves once with the whole string. Use it when the summary feeds another step rather than a screen, for example when you generate a headline for a list of saved notes in the background. Like the streaming call, it takes an optional second argument with a context string. Here summarizer is an instance created with type: 'headline':

const headline = await summarizer.summarize(note.body, {
  context: 'These are personal study notes about Vue components.',
})

Whichever you choose, give the model clean text. The docs recommend removing markup, and reading innerText rather than innerHTML when the source is an element on the page. Tags add to the input and nothing to the summary.

Design for the machines that cannot do this

Build the feature as a layer on top of a page that already works. The textarea, the saved notes, the share button: all of that should exist and function in Safari on an iPhone, where there is no Summarizer. The summary is a bonus for the people whose browser and hardware qualify. If the summary is essential to your product, this API is not the right tool today; you need a server or a hosted model, with the privacy trade-offs that brings.

Expect the first run to be slow and say so in the interface. The model is a couple of gigabytes, the download only starts after a click, and availability() tells you in advance whether it is coming. A sentence under the button, "The first summary downloads a model, which can take a few minutes", costs nothing and saves a support message.

Check it stuck3 questions from the Chrome's built-in AI APIs quiz.
Practice now
Was this helpful?

Keep reading