Skip to content

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.

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.

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 copy was not adapted. Your fallback, a product name, a price: anything whose source is static, 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.

// 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.

state.phase tells you where an island is, and it is stable enough to key styling off:

state.phaseOn screen
idleYour fallback. Nothing has been requested yet.
holdingYour artwork, with replaceable copy briefly withheld, its space reserved by your invisible fallback.
liveAdapted copy, arriving.
settledFinal copy for this page view.
failedDelivered 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.