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. 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. |
mintToken | A signed token, or a function returning one, 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.
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? }. |
serverRendered | Finished campaign creative the platform already rendered, not your fallback. Omit it unless that is what you have. Passing true makes smooth inert; false only forces the hold back on. |
enabled | false skips the network entirely and renders your fallback. 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: paces every entrance, live typing and composed copy landing on the held canvas alike, so bursts read as writing. Pass false for wire text verbatim. Presentation only. |
holdMs | How long the canvas holds waiting for the stream before your fallback paints. While holding, the fallback occupies its space invisibly, so nothing below the island moves when copy lands. Defaults to 2500, and restarts once when the stream proves it is alive. Your fallback is still the floor whatever you set. |
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 | Paces entrances (live typing and composed landings alike). Defaults to true; pass false for wire text verbatim. |
className, style | Passed through. |
It stamps data-typing, data-settled and data-content-source on the element
for styling and auditing, and announces changes politely to screen readers.
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.