What your visitors see
These rules govern what appears on screen. They are worth ten minutes, because each one explains why an API you might reach for does not exist.
Throughout, an island is one block of SparkleTree-managed copy on your page:
a hero, or a call-to-action button. useIsland is the hook behind both.
Your own copy is what a visitor reads when the stream does not deliver
Section titled “Your own copy is what a visitor reads when the stream does not deliver”Your fallback prop is the text on screen whenever the adapted version is late,
missing, or never coming. A block either shows real copy or waits over your
artwork with your fallback held invisible in the space it will occupy; there
is no skeleton and no spinner. This is true on a fast connection and a
slow one, on the first visit and the hundredth.
So the fallback is never throwaway text. It is the copy a real person reads whenever the stream does not arrive.
Adapted copy lands once, or not at all
Section titled “Adapted copy lands once, or not at all”Over one page view a field changes at most once. state.rewrites counts it:
0 means no adapted copy has landed yet, 1 means it has. It never goes
to 2.
In islands, every entrance types in: onto the blank canvas the hold reserves, never over visible words. Generated copy types character by character as it arrives; copy already generated for an earlier visitor in the same context arrives in one piece and types in at the same paced rate, so a cache hit and a live generation read the same. Fragments pace their entrances the same way: their text arrives in one event rather than as deltas, but it types into the reserved space at the same rate, served copy and expired-budget fallback alike. Nothing in the SDK ever hard-paints an entrance.
Text a reader can already see is not blanked
Section titled “Text a reader can already see is not blanked”If your fallback has been on screen since the page painted, it stays on screen:
blanking a sentence someone is mid-way through is the thing this rule exists to
prevent. The SDK detects this by whether the block hydrated over server HTML
that already carried text, so a Next.js "use client" block is on that path
automatically. A client-only mount is not: its fallback was rendered by the same
commit that opened the request, and nobody has read it yet.
It is not frozen, but it is never typed over. Live deltas buffer off-canvas
and the copy changes at most once, composed, when the field settles; if the
stream dies first, your fallback simply stands. Retyping a sentence someone is
mid-way through destroys the read, so the delta-by-delta entrance is reserved
for the held path, where the canvas is blank and typing is an arrival rather
than churn. serverRendered declares the same on-screen status explicitly,
overriding the detection in either direction.
<Hero campaignId="camp_123" fallback={presentedCreative} serverRendered />An ordinary client-side mount has nothing on screen yet, so the block holds over your artwork instead. That wait is the hold. It arms the moment the block mounts, before the browser ever paints and covering any wait before the request itself can open, so your fallback never flashes ahead of the stream about to replace it. The hold reserves rather than blanks: your fallback is drawn invisibly, holding its exact space. A visitor sees the streamed copy type into that reserved space, or, after 2.5 seconds, your fallback type into it. Neither transition moves the page: every entrance is a typed entrance, whichever way the wait ends. The budget restarts once if the stream proves it is alive, so the longest wait is 5 seconds. When it runs out, including on a connection that never answers, your fallback types into the space it already holds and any later copy lands as the single permitted change. The one trade: invisible copy is not a Largest Contentful Paint candidate, so a block that waits out the full budget defers LCP by up to that long.
One timer, and it is yours
Section titled “One timer, and it is yours”The arrival of real text is what moves a block forward, so timing takes care of
itself and a block behaves the same on a fast office connection as on a train.
The one number you set is the hold budget, and every entry point takes it:
holdMs on Hero, Cta, useIsland and useFragments, and on openStream
in core. It defaults to 2.5 seconds.
Whatever you set, your fallback is the floor when the budget runs out. If you
want to draw something of your own in the holding window rather than the
reservation, useFragments reports $group.holding while it lasts.
The one presentational knob is smooth, which paces entrances so bursty text
reads as writing rather than pasting. It changes nothing about state or timing,
and reduced-motion visitors always get the full text at once.
Adapted copy always carries its provenance
Section titled “Adapted copy always carries its provenance”Every non-static field is stamped data-content-source in the DOM:
machine-readable, auditable, and never applied to copy the server declared
static. Copy served from cache counts as adapted too — it was generated, just
for an earlier visitor in the same context; calling it authored would be an
untruth in the other direction.
A visible disclosure label is opt-in. The package exports InlineMark
(an accessible sparkle glyph, default label “Adapted for you”) and
decideChrome tells you when a field counts as adapted for this viewer:
import { InlineMark, StreamText, useSparkletree } from "@sparkletree/react";import { decideChrome } from "@sparkletree/core";
function Headline({ field }) { const { prefersStandard } = useSparkletree(); const chrome = decideChrome(field.source, prefersStandard); return ( <h1> <StreamText field={field} /> {chrome.marked ? <InlineMark /> : null} </h1> );}Depending on where you operate, visible AI-disclosure may be a legal
obligation (e.g. EU AI Act Article 50); whether and how to show it is your
call, and decideChrome keeps the claim honest either way.
The two cases where marked is false
Section titled “The two cases where marked is false”- The copy was not adapted. Your fallback, a product name, a price:
anything whose
sourceisstatic, because there is nothing to disclose. Claiming authored copy is AI-written is treated as the worse error. - The visitor asked for the standard version. Any viewer can switch the preference for themselves; give them the control from the provider:
import { useSparkletree } from "@sparkletree/react";
function StandardVersionToggle() { const { prefersStandard, setPrefersStandard } = useSparkletree(); return ( <button onClick={() => setPrefersStandard(!prefersStandard)}> {prefersStandard ? "Show adapted copy" : "Show the standard version"} </button> );}The preference is stored per browser and honoured by every SparkleTree block on the page.
Fragments aggregate naturally at the group: one useFragments call is one
draw, so fragments.$group.marked is one decision for everything written
together, and the FragmentGroup container is stamped with the strongest
provenance among its fields. Render one InlineMark at the group’s corner if
you want a visible disclosure there.
InlineMark keeps the accessible wrapper (role="img", a localisable
markLabel) and takes children for your own glyph; style the default by
targeting .st-mark.
Pass the field, not the string
Section titled “Pass the field, not the string”// Renders the copy with its provenance and paced entrance.<StreamText field={state.fields.headline} as="h1" />
// Renders AI-written copy with no provenance stamp. Do not do this.<h1>{state.fields.headline.text}</h1>Two blocks of one campaign always tell the same story
Section titled “Two blocks of one campaign always tell the same story”A campaign can be running an experiment with several versions. Within one page
view, every block of that campaign is served the same version, so your hero
cannot come from one arm and your button from another. A client-side navigation
counts as a new page view only when the last provider unmounts. A provider at
your app root survives route changes, so call pageVariants.reset(), exported
from @sparkletree/react, when your router changes route.
Check which stage a block has reached
Section titled “Check which stage a block has reached”state.phase tells you where an island is, and it is stable enough to key
styling off:
state.phase | On screen |
|---|---|
idle | Your fallback. Nothing has been requested yet. |
holding | Your artwork, with replaceable copy briefly withheld, its space reserved by your invisible fallback. |
live | Adapted copy, arriving. |
settled | Final copy for this page view. |
failed | Delivered copy stands; anything unfinished or blank is repainted with your fallback. |
Hero and Cta stamp it on the element as data-phase, alongside
data-typing, data-settled and data-content-source on each piece of text.