Logo

Completion callbacks

Smoothstream exposes two milestones for each mounted response. Both require receiving to become false and fire once, in order:

MilestoneWhen it firesTypical use
Reveal completeThe final presentation unit has entered the committed DOM. Its entrance animation may still be running.Show suggested replies or another application component.
Presentation completeAll Smoothstream entrances have finished and temporary reveal nodes have compacted to semantic HTML.Measure the final layout or run work that needs settled Markdown.

React and the DOM adapter call onRevealComplete and onPresentationComplete. Vue emits reveal-complete and presentation-complete. Closing input does not itself mean either milestone has fired. Browser paint cannot be observed precisely, so reveal complete means the final unit is committed and beginning its entrance, rather than a guarantee about an exact painted pixel.

Put a component between Markdown segments

Keep application controls outside Smoothstream’s managed Markdown subtree. Show them at reveal complete, then mount a separate Smoothstream instance for the selected continuation. Each instance parses its own Markdown document, so finish the first segment at a complete block boundary.

import { useState } from "react";import { Smoothstream } from "@smoothstream/react"; function Response({ markdown, receiving }: { markdown: string; receiving: boolean }) {  const [showChoices, setShowChoices] = useState(false);  const [settled, setSettled] = useState(false);  const [next, setNext] = useState<string | null>(null);   return (    <>      <Smoothstream        receiving={receiving}        onRevealComplete={() => setShowChoices(true)}        onPresentationComplete={() => setSettled(true)}      >        {markdown}      </Smoothstream>      {showChoices && (        <div role="group" aria-label="Choose what comes next">          <button onClick={() => setNext("An example follows.")}>Example</button>          <button onClick={() => setNext("Here are more details.")}>More detail</button>        </div>      )}      {next && <Smoothstream key={next} receiving={false}>{next}</Smoothstream>}      {settled && <p>The first segment has settled.</p>}    </>  );}

Give Response a new key for a different answer so its local choice state resets along with the stream.

<script setup lang="ts">import { ref } from "vue";import { Smoothstream } from "@smoothstream/vue"; defineProps<{ markdown: string; receiving: boolean }>();const showChoices = ref(false);const settled = ref(false);const next = ref<string | null>(null);</script> <template>  <Smoothstream    :markdown="markdown"    :receiving="receiving"    @reveal-complete="showChoices = true"    @presentation-complete="settled = true"  />  <div v-if="showChoices" role="group" aria-label="Choose what comes next">    <button @click="next = 'An example follows.'">Example</button>    <button @click="next = 'Here are more details.'">More detail</button>  </div>  <Smoothstream v-if="next" :key="next" :markdown="next" :receiving="false" />  <p v-if="settled">The first segment has settled.</p></template>

Give the component a new :key for a different answer so its choice state starts fresh.

import { createSmoothstream } from "@smoothstream/dom"; const firstHost = document.querySelector<HTMLElement>("#first")!;const choicesHost = document.querySelector<HTMLElement>("#choices")!;const nextHost = document.querySelector<HTMLElement>("#next")!;let continuation: ReturnType<typeof createSmoothstream> | undefined; const first = createSmoothstream(firstHost, {  receiving: true,  onRevealComplete() {    const button = document.createElement("button");    button.textContent = "Show an example";    button.onclick = () => {      continuation?.destroy();      continuation = createSmoothstream(nextHost, { receiving: false });      continuation.update("An example follows.");    };    choicesHost.replaceChildren(button);  },  onPresentationComplete() {    // The first segment's entrance and DOM compaction are finished.  },}); first.update("## Answer\n\nThe beginning", { receiving: true });first.update("## Answer\n\nThe beginning and the end.", { receiving: false });// On removal: first.destroy(); continuation?.destroy();

Create the second controller in a separate host; the first controller owns its own root. The two updates shown are illustrative accumulated snapshots. Continue updating with every received snapshot while input is open.

Lifecycle details

  • A final image waits until it has decoded or failed before entering, so it can delay reveal complete. In animated streaming mode, resources that gate reveal have resolved or failed before presentation complete. Images can still load afterward in static or reduced-motion mode.
  • Static mode and reduced motion can deliver both callbacks in the same commit. An empty response also delivers both. Neither fires while receiving is true.
  • Unmounting or destroying the instance cancels pending callbacks. For React and Vue, changing timing props rebuilds playback; keep them stable within a response.
  • The callbacks do not replace Smoothstream’s accessibility announcement. Keep your follow-up controls accessible and outside the Markdown subtree; see Accessibility.

In the repository, run npm run demo, npm run demo:vue, or npm run demo:vanilla, then select Callbacks: interactive handoff to see the milestones and continuation stream.

© 2026 Smoothstream. All rights reserved.

Built withRadiant Docs