Skip to content

Provider, components and useIsland

An island is one block of SparkleTree-managed copy: a hero, or a call-to-action. Hero and Cta are thin wrappers over the useIsland hook; when their layout does not suit you, call the hook and write your own.

Wrap the page once with SparkletreeProvider

Section titled “Wrap the page once with SparkletreeProvider”

Everything else must render inside it, or it throws with a message telling you so.

<SparkletreeProvider publishableKey={KEY}>{children}</SparkletreeProvider>
Prop What it does
publishableKey st_pk_live_… or st_pk_test_… from the dashboard. A signed credential sent on every request as st-key; the server refuses requests without it. Also decoded locally to an organization id and API host.
apiBase API host, if you self-host. Wins over the key.
organizationId Your organization id. Wins over the key.
context Extra context merged over what the SDK collects. Yours wins on conflict. Must be JSON-serializable.
serverContext The visitor context and variant your server read copy with: pass getIslandCopy(...).context. For this page view the browser sends the same device type, time of day, time zone and language, and keeps the server’s variant. See Live copy in server-rendered pages.
mintToken A signed token, or a function receiving the write ({ context, schema, contextHash, schemaHash }) and returning a token bound to it, for fragment minting.
storage Where the visitor’s “standard version” preference is kept. Defaults to window.localStorage; pass your own if a consent manager gates it.
fetchImpl Replacement fetch, for tests.

Pass publishableKey, or both apiBase and organizationId. With neither, the provider throws at render. A misconfigured integration is a setup error, not something to silently degrade around.

An inline context={{ … }} object is fine. Identity is compared by content, so re-creating the object each render does not re-request anything.

Time of day, day of week, timezone, language, a coarse mobile/desktop flag, and the origin of the referring page, never the full referrer URL. No permission prompt, no geolocation, no fingerprinting. Each signal is optional: missing ones cost a less well-adapted creative, not a working page.

Override any of them, or drop one entirely:

<SparkletreeProvider publishableKey={KEY} context={{ deviceType: "signage", referrer: undefined }}>

One exception: time of day. The server derives the daypart from the timezone itself, so a timeOfDay override is ignored for island copy — override the timezone if you need a different clock.

Hero renders a greeting, headline and body

Section titled “Hero renders a greeting, headline and body”
<Hero
campaignId="camp_123"
fallback={{
greeting: "Good evening",
headline: "Software that meets the moment",
body: "Adaptive creative for the whole funnel.",
}}
className="my-hero"
/>

Fields you leave out of fallback are not rendered. The component draws the campaign’s background image behind the copy when there is one, and applies the campaign’s colours as CSS custom properties on the section.

<Cta campaignId="camp_123" fallback={{ cta: "Start free" }} href="/signup" />

It renders an <a> when a destination survives vetting: the campaign’s own if it supplies one, otherwise your href. With neither it renders a non-interactive <span> instead. Clicks are reported automatically, and your onClick still runs.

While its label holds, the whole button keeps its space but is invisible and inert: it cannot be focused, clicked or followed. It appears, usable, when its label lands. Your own button gets the same with useHeldControl:

const control = useHeldControl(state.fields.cta);
<a href={href} {...control.props} onClick={control.guard(onClick)}>…</a>
Prop What it does
campaignId The campaign to play.
surfaceId Use instead of campaignId when a screen or placement decides what plays. Pass one or the other.
fallback Required. Your copy: { greeting?, headline?, body?, cta? }. Each field’s fallback sets that field’s slot, its shape and its length: live copy is written to its length, whole copy that would not fit is not shown, and a field without a fallback is never shown.
serverRendered Finished campaign creative your server already rendered from the platform, not an authored fallback. It renders visibly from the first frame and is final: nothing holds, and the stream’s copy for those fields is ignored. Omit it for an ordinary fallback, which is reserved invisibly (in the server HTML too) until live copy or the end of the hold.
initialCopy Copy already produced for this view, such as getIslandCopy(...).islands.hero. It renders visibly from the first frame, settled and final, with its provenance; it is the island’s whole copy, and the impression records when the island is seen. Pass undefined when there is none.
heldBeforeMs How long the slot already held blank before this render. Spent from the hold and the 5 second maximum. Not needed after an IslandReservation: the island measures that wait from the reservation’s first paint.
slot Tells apart two blocks with the same campaign (or surface) on one page. A block remembers what it showed for the page view, so mounting it again shows the same copy and records no second impression; blocks with the same campaign and slot share that memory.
nonce A nonce for the small inline script in the server HTML, for pages whose Content Security Policy requires one.
enabled false skips the network entirely and renders your fallback visibly. For Storybook, previews and tests.
sessionId Keeps copy continuous for a returning visitor on an individual screen. Omit and each page view is independent.
pin Defaults to true: this block shares the page’s campaign version. Set false for a block that is meant to say something different for the same campaign.
smooth Defaults to true: live copy types in at a steady pace, copy that lands whole fades in once, and a deliberate replacement cross-fades once. Pass false for text verbatim. Presentation only.
holdMs How long the text slots hold, once the block is in view, waiting for live copy before your fallback lands. While holding, the fallback occupies its space invisibly, so nothing moves when copy lands. Defaults to 2500, and restarts once when the stream proves it is alive. Whatever lands is final for the page view. 0 turns the hold off: the fallback shows at once and stays.
className, style Passed through.
layouts (Hero only) Named render functions the campaign may choose between.

A campaign can ask for a named layout; you decide which names you draw. The layout arrives with the stream, so the block starts in the default arrangement and the component swaps when it lands. An undeclared name falls through to the built-in arrangement, so a campaign can never ask for markup you did not write.

<Hero
campaignId="camp_123"
fallback={copy}
layouts={{
split: ({ headline, body, backgroundImage }) => (
<div className="grid grid-cols-2">
<div>{headline}{body}</div>
<img src={backgroundImage ?? ""} alt="" />
</div>
),
}}
/>
import { useIsland, heldReserve, StreamText } from "@sparkletree/react";
function CustomHero() {
const fallback = { headline: "Software that meets the moment" };
const { state, ref } = useIsland({ campaignId: "camp_123", island: "hero", fallback });
// The hold reserves, and useIsland does the reserving: while the canvas
// waits, each blank field arrives already carrying its held copy, and
// StreamText keeps the geometry invisibly. Nothing to wire. The one
// question left to your markup is heldReserve's: a blank field with a
// reserve still occupies its space, so keep its element mounted.
return (
<section ref={ref} data-phase={state.phase}>
<StreamText field={state.fields.headline} as="h1" />
{state.fields.body.text || heldReserve(state.fields.body) ? (
<StreamText field={state.fields.body} as="p" />
) : null}
</section>
);
}

island is "hero" or "cta"; there are no other kinds. Every other option is the same as the props above.

Attach ref to the element that has to be visible. It is how impressions are counted. Leave it off and your campaign renders perfectly while reporting zero traffic. See Impressions and clicks.

state.fields.greeting, .headline, .body and .cta, each carrying:

Field property Meaning
text What to render right now. Never a placeholder.
source "generated", "cached" or "static" (your own copy).
typing Live text is currently landing.
settled This text is final for the page view.
mode The delivery mode the server declared.

state.rewrites counts reader-visible copy changes this page view: 0 until adapted copy lands, 1 after, never 2. The phase values are tabled in When things go wrong. Also on state: phase, theme, layout, backgroundImage, videoUrl, ctaAction, ctaStyle, sequence, contentSource, degraded, variantId, error, rewrites, meta, and held. held is the copy the hold pulled off the canvas per field, whatever was actually painted there, /content copy or your fallback. useIsland attaches it back onto each holding field so a blank field keeps occupying its exact space; heldReserve(field) reads the reservation when your markup needs to know.

An island carries four copy fields today. For copy of your own naming, any keys you like anywhere in your UI, reach for fragments.

<StreamText field={state.fields.headline} as="h1" className="display" />
Prop What it does
field The field object from state.fields.*. Not field.text.
as Element to render. Defaults to span.
smooth Live typing at a steady pace, one fade for whole landings and deliberate replacements. Defaults to true; pass false for text verbatim.
className, style Passed through.

It stamps data-typing, data-settled, data-holding and data-content-source on the element for styling and auditing. While a field holds it draws the reservation (invisibly, in the server HTML too) and sets aria-busy, so screen readers announce the copy once, when it lands. It never wipes and retypes visible text.

CopyCard and ProductCard render copy you already have, from your own CMS, catalogue or API call, and route it through the same provenance logic.

<ProductCard
product={{ id: "sku_1", name: "Trail Runner", price: "$120", imageUrl }}
contentSource="generated" // applies to the DESCRIPTION only
href="/products/trail-runner"
/>

Set contentSource from where the copy actually came from, never a guess; it defaults to static. Product names and prices are always treated as authored facts and never claim adapted provenance.

Style it with class hooks and custom properties

Section titled “Style it with class hooks and custom properties”

No CSS ships with the package. Campaign colours arrive as --st-* custom properties on the rendered element, so your stylesheet can consume them:

.st-hero { background: var(--st-background, #fff); }
.st-cta { background: var(--st-primary); color: var(--st-cta-text); }
.st-mark { color: var(--st-primary); opacity: 0.7; }
.st-stream-text[data-typing="true"]::after { content: "▍"; }

Outside React, themeStyle(state.theme) returns the same properties as a style object and applyTheme(element, state.theme) sets them on a node, returning a function that undoes it.