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.
Text a visitor can see is never replaced
Section titled “Text a visitor can see is never replaced”A visitor is never shown something and then has it replaced, removed, or pushed around. Every copy slot follows the same four rules:
- The slot holds, reserved. When a block comes into view and live copy is coming, its text slots stay blank for a short hold, with your fallback drawn invisibly in the exact space it would take, so nothing moves. Art, background and chrome are never held; only the text waits.
- Live copy lands in the reserved space. Generated copy types in as it arrives. Copy already generated for an earlier visitor in the same context lands whole, with one short fade. Either way it is the first text the visitor reads in that slot.
- If the hold runs out, your fallback lands, and it is final. It fades in once, in the space it already held. Adapted copy that arrives afterwards is not shown in that page view.
- The server HTML never shows a fallback a stream may follow. Server and first client render carry the same invisible reservation, so a server-rendered page holds exactly like a client-rendered one.
The hold lasts 2.5 seconds and restarts once if the stream proves it is alive, so the longest wait is 5 seconds. On a server-rendered page that is one clock from first paint: time spent waiting for the page’s JavaScript counts against the same 2.5 and 5 seconds. It starts when the block is in view: a block below the fold keeps its slot reserved and lets the stream work in the background, and when it scrolls into view finished copy is already there (or the hold starts then). A block that never scrolls into view never shows anything changing.
state.rewrites counts replacements of visible text. Within one stream it is
always 0.
Your fallback sets the size
Section titled “Your fallback sets the size”Each field’s fallback defines that field’s slot: its shape and its length. The slot is exactly the size your fallback takes, from the first paint to the end of the page view, whatever lands in it, so nothing around it ever moves.
- Live copy is written to fit. Each field’s fallback length is sent with the request as that field’s length budget, and the platform writes copy to it.
- Copy that would not fit is not shown. Before copy that arrives whole is painted, the slot measures it. If it would not fit the box your fallback sets, your fallback lands instead, final for the view, and the impression reports your fallback.
- Typed copy never grows the slot. Copy that types in is clipped to the box rather than pushing the page.
- A field your fallback does not have is never shown. There is no slot for it. Give a field a fallback to give it a place on the page.
Write each fallback as long as the copy you want in that place.
A slot remembers, for the page view, what it showed: per field, the text and its provenance. If the block mounts again (a suspended section resolving, a re-keyed parent), it shows exactly that again, and records no second impression.
Visible copy changes only when the visitor causes it
Section titled “Visible copy changes only when the visitor causes it”Copy on screen does not change because something changed behind it: the clock, a location or context value resolving, a re-render, a provider update. A change to what an island or a fragment group would show (its campaign, its context, its fallback) is handled like this:
- Nothing visible yet (the slot is still holding): the change applies at once, under the same clock.
- The visitor caused it: your interaction handler calls
markVisitorChange()before the state update. The old copy stays until the new copy is ready, then one cross-fade replaces it. Your fallback is never shown in between; if the new stream fails, the new fallback arrives through that same single cross-fade. This is the changestate.rewritescounts. - A new page view: the same single cross-fade, for a provider that
outlives page views and is given a new
viewKey(or a newserverContextfrom the next server read). - Anything else waits: the copy on screen stays until the block is off screen, and the change applies there, where nobody sees it.
import { markVisitorChange } from "@sparkletree/react";
<button onClick={() => { markVisitorChange(); setAudience("teams"); }}> For teams</button>One deliberate exception, one fade
Section titled “One deliberate exception, one fade”- A stream that fails while writing a line leaves what it already wrote, even an unfinished line. Only text that is clearly an error (an error message, a stack or status text, raw markup or JSON, or a model refusal) is replaced with your fallback, with one fade. A line the stream never started shows your fallback.
If the page’s JavaScript is late or missing
Section titled “If the page’s JavaScript is late or missing”The reservation in the server HTML carries a CSS-only failsafe: if the page’s JavaScript has not taken over within 5 seconds, your fallback becomes visible on its own, and visitors with scripting turned off see it at once. When the JavaScript does arrive it takes over the hold; if the failsafe had already shown your fallback, that fallback is final for the page view.
A held call-to-action is invisible as a whole, not just its label, and inert: it cannot be focused, clicked or followed until its label lands. In the server HTML it carries the same failsafe instead, so without JavaScript it reveals itself, label and all, as a working link.
The deadline holds when timers do not: in a background tab, or on a page restored from the browser’s back/forward cache, copy that arrives after the hold ran out is not shown, even if the page’s timer has not fired yet.
Finished creative: serverRendered
Section titled “Finished creative: serverRendered”serverRendered is for copy that is not a fallback at all: finished campaign
creative your server already rendered from the platform. It renders visibly
from the first frame, nothing holds, and the stream’s copy for those fields
is ignored (theme, media and the button action still apply).
<Hero campaignId="camp_123" fallback={presentedCreative} serverRendered />Leave it unset for an ordinary fallback.
Live copy in server-rendered HTML: initialCopy
Section titled “Live copy in server-rendered HTML: initialCopy”A page rendered on your server can read the live copy already cached for the
visitor’s context and render it as final text, so a returning context never
waits at all. Pass it as initialCopy: visible from the first frame,
settled, stamped with its provenance, and the island’s whole copy (the
stream writes no text into it). When the read finds nothing, the island
renders its reservation and holds as usual. See
Live copy in server-rendered pages.
The one trade of the hold: 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. holdMs={0} turns the hold off: your
fallback shows at once, on the server too, and stays.
Whatever you set, your fallback is the floor when the budget runs out. If you
draw text yourself instead of through StreamText or FragmentText, reserve
the space a holding field will take: heldReserve(field) returns the text to
reserve, and useFragments reports $group.holding while it lasts.
The one presentational knob is smooth, which turns the typing pace and the
fades on or off. It changes nothing about state or timing, and reduced-motion
visitors always get the final 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 |
Before the stream exists: your fallback, reserved invisibly (or visible with serverRendered, enabled={false} or holdMs={0}). |
holding |
Your artwork, with the text slots held for a moment, their space reserved by your invisible fallback. |
live |
Adapted copy, arriving. |
settled |
Final copy for this page view. |
failed |
Delivered copy stands, unfinished lines included; a blank slot gets your fallback, and only text that is clearly an error is replaced by it. |
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.