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.
What the provider collects on its own
Section titled “What the provider collects on its own”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 renders one button label
Section titled “Cta renders one button label”<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>Props shared by Hero and Cta
Section titled “Props shared by Hero and Cta”| 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. |
Give the campaign a choice of layouts
Section titled “Give the campaign a choice of layouts”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> ), }}/>Build your own markup with useIsland
Section titled “Build your own markup with useIsland”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.
What useIsland gives you back
Section titled “What useIsland gives you back”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 renders one field
Section titled “StreamText renders one field”<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.
Cards for copy that does not stream
Section titled “Cards for copy that does not stream”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.