Fragments
Fragments adapt copy you already have. Name any piece of it, a signup button, a form heading, a paragraph, an empty state, or a group of them together, say what it says today, and SparkleTree returns a version written for where it sits. The keys are yours to choose.
import { useFragments, FragmentText } from "@sparkletree/react";
function SignupButton() { const f = useFragments({ context: { page: "pricing", intent: "signup" }, fields: { cta: { kind: "cta", fallback: "Start your free trial" } }, });
return ( <button onClick={signup}> <FragmentText field={f.cta} /> </button> );}There is nothing to create in a dashboard first. The keys and the fallback strings you write are the request.
Your fallbacks are the schema
Section titled “Your fallbacks are the schema”Each entry needs a kind and a fallback. A bare string is shorthand for
{ kind: "body", fallback: "…" }.
kind |
For |
|---|---|
cta |
Buttons |
label |
Links, small labels |
headline |
Headings |
body |
Sentences and paragraphs |
empty-state |
“Nothing here yet” copy |
Every kind follows the same rule: served copy shows only if it arrives while the field is still holding. Once your fallback has landed, it stays.
Up to 24 fields per call, counted after array expansion. Keys start with a
letter and are at most 32 characters. Each fallback is 1 to 2,000 characters.
These are the validator’s numbers, exported from @sparkletree/core as
MAX_FRAGMENT_FIELDS and MAX_FALLBACK_CHARS, and development builds warn
the moment a declaration crosses one, because production’s answer is to
quietly serve your fallbacks forever.
Declare one group per unit of copy
Section titled “Declare one group per unit of copy”One useFragments call is one request, one generation, one draw. Put
the fields that belong together in the same call so they are written together.
A form’s heading and its button should agree with each other.
const f = useFragments({ context: { page: "login", audience: "returning-user" }, fields: { title: { kind: "headline", fallback: "Welcome back" }, subtitle: { kind: "body", fallback: "Sign in to continue" }, submit: { kind: "cta", fallback: "Sign in" }, forgot: { kind: "label", fallback: "Forgot your password?" }, empty: { kind: "empty-state", fallback: "No account yet? It takes a minute." }, },});
return ( <FragmentGroup fragments={f}> <h1><FragmentText field={f.title} /></h1> <p><FragmentText field={f.subtitle} /></p> <button><FragmentText field={f.submit} /></button> </FragmentGroup>);FragmentGroup stamps the strongest provenance among its fields on the
container (data-content-source), and f.$group.marked reports whether the
draw counts as adapted — the natural unit for an opt-in disclosure mark, since
one call is one draw.
FragmentText renders static copy as bare text with no wrapper element at all.
A call site serving fallbacks produces exactly the same DOM as a plain string.
Generated copy gets a <span data-content-source="generated">. A visible
disclosure mark is opt-in — render InlineMark where you want one, using
$group.marked for the group decision. What your visitors
see covers it.
Declare a whole section with an array fallback
Section titled “Declare a whole section with an array fallback”A field whose fallback is an array declares one field per paragraph:
intro with three elements expands to intro1, intro2, intro3 before
anything is hashed or sent. Each paragraph keeps its own layout budget, which
is what lets a whole section adapt without ever shifting your layout, and the
whole group is still one draw, so the paragraphs, the heading and the button
agree with each other. Read them back in order with fragmentList:
import { useFragments, FragmentText, FragmentGroup, fragmentList } from "@sparkletree/react";
const f = useFragments({ context: { page: "features", intent: "explain" }, fields: { title: { kind: "headline", fallback: "Built for the whole funnel" }, intro: { kind: "body", fallback: [ "First paragraph, written as shipping copy.", "Second paragraph. Your strings are still the schema, per paragraph.", ]}, cta: { kind: "cta", fallback: "Start free" }, },});
return ( <FragmentGroup fragments={f}> <h2><FragmentText field={f.title} /></h2> {fragmentList(f, "intro").map((p, i) => <p key={i}><FragmentText field={p} /></p>)} <button><FragmentText field={f.cta} /></button> </FragmentGroup>);Two rules keep this honest:
- The array is a declaration, not data. Keep it literal in your source. Its length is part of the schema hash, so a different paragraph count is a different construction, and an array fed from a CMS or an API would hash differently between server and client renders.
- The model fills your shape; it never chooses one. Three paragraphs in, three paragraphs out, each within its budget. Letting the model pick the paragraph count would mean reserving space for the largest answer: a skeleton with extra steps, and the end of layout stability. The fallback’s shape is the schema exactly as its length is the budget.
Served copy lands while the field holds, or not at all
Section titled “Served copy lands while the field holds, or not at all”Once your fallback has landed, that is the field’s copy for the page view. A
response that arrives afterwards is not shown: replacing words a visitor can
see is never done, whether they are a button label or a paragraph. This is
swap: "never", the behaviour of every field.
Two older values, "before-paint" and "always", are deprecated. They are
still accepted, so existing code keeps compiling, and they now behave exactly
like "never": no late response replaces a landed fallback.
Copy landing in the reserved space during the hold lands whole, with one short fade, and only if it fits the space your fallback holds. Before it is painted the field measures it in place. Shorter copy lands and keeps the space your fallback held, so the element never shrinks. Copy that would make your element larger (or, for copy inside a sentence, move the words after it) is not shown: your fallback lands instead, final for the page view.
A new declaration (other fields, another context) after copy has been
visible changes nothing on screen unless the visitor caused it: call
markVisitorChange() in the interaction handler before the state update,
and the new copy replaces the old with one cross-fade once it is ready.
Otherwise the group keeps its copy until it is off screen.
swap is presentation only: it is never sent, and it never changes which copy
gets written. Editing it does re-run the read, exactly as editing a fallback
string does, but the request is byte-identical, so it is a cache hit and costs
no new generation.
Your fallback waits behind the served copy
Section titled “Your fallback waits behind the served copy”The served copy is the first text a reader sees. While the request is in flight
your fallback renders invisibly. It occupies its exact space, hidden, so
the wait itself never moves the page, and no text is shown that a moment later
would be swapped mid-read. While holding, that invisible fallback is a span
inside your element, so CSS keyed on :empty or child selectors will see it
for up to the budget. Served copy is written to your fallback’s length
budget; copy that would still change your element’s size is not shown, so
write fallbacks that reflect the length of the copy you want there. If nothing adapted has arrived within 2.5 seconds of the group coming into
view, your fallback lands in the space it already holds, with zero layout
shift and one short fade, and that is then what the visitor reads for the
page view. Pass holdMs to change the budget (0 turns the hold off: your
fallback shows at once and stays). A response that settles with nothing
adapted lands the fallback immediately.
The reservation works at every size: a paragraph section holds its full
height for the whole wait, so there is no large-group exception anymore, and
no section that materializes late. The one trade to know: invisible copy is
not a Largest Contentful Paint candidate, so a group that waits out the full
budget defers LCP by up to holdMs. And because the fallback genuinely has
not been seen while it is hidden, a response landing inside the budget shows
in groups of any size, buttons and labels included.
On the server there are no timers: the HTML carries the same invisible reservation, never your fallback as visible text, so a server-rendered page holds exactly like a client-rendered one. The reservation includes a CSS-only failsafe: if the page’s JavaScript has not taken over within 5 seconds your fallback becomes visible on its own (at once with scripting off), and if that happened before hydration, the fallback is final for the page view.
Layout never shifts, because your fallback sets the budget
Section titled “Layout never shifts, because your fallback sets the budget”Each field carries a length budget derived from your own fallback:
max(length × 1.3, floor for the kind), where the floors are 24 characters for
cta, 32 for label, 64 for headline, and 160 for body and empty-state.
Generated copy is written to fit inside it, and the server recomputes the budget
rather than trusting the browser.
Write a fallback that reflects the space you actually have and the adapted version will fit the same box.
The context vocabulary is fixed
Section titled “The context vocabulary is fixed”context describes where the copy sits, never who is reading it.
| Key | Value |
|---|---|
page |
Your own page class, lowercase letters, digits and hyphens, up to 32 characters. Never a URL or path. |
intent |
One of signup, signin, retain, upsell, reassure, explain, browse, convert. |
audience |
A placement descriptor in the same token format, never a user identifier. |
notes |
Up to 280 characters of free text describing the placement. |
custom |
Up to four of your own key/value pairs, both in the token format. |
Unknown keys are dropped, and an unrecognised value for a known key is dropped
rather than becoming a new bucket. Free text in notes is scrubbed of markup
and URLs, and the whole field is discarded if it looks like an email address, a
phone number or a token. This copy is shared between every visitor in the same
context, so nothing about an individual belongs in it.
Set page-wide defaults on the provider and override them per call site:
<SparkletreeProvider publishableKey={KEY} context={{ page: "pricing", intent: "signup" }}>Every distinct context is a distinct piece of copy to generate, including every
distinct notes string. Declare the placement, not the visit.
Sign-in and checkout page classes may take one extra step: your workspace can require an operator to switch them on and approve the copy before it serves. Until then those call sites render your own strings, exactly as written.
A cold context is written on the visitor’s request
Section titled “A cold context is written on the visitor’s request”A read is one cacheable request that always answers, warm or cold. On a cold read the SDK sends one request to write the copy, once per browser session per context, and the platform generates it right then and streams it back into the hold. Inside the budget, the first visitor sees the generated words; too slow, and their fallback lands and stays (never one and then the other). Either way the read is warm for every view after.
f.$group reports what happened:
| Property | Meaning |
|---|---|
warmth |
"warm": adapted copy was served. "warming": your fallbacks were served, a fill is in flight. "cold": your fallbacks were served. |
marked |
Whether the group’s copy counts as adapted for this viewer — feed an opt-in InlineMark from it. |
holding |
The group is waiting out the budget. Every field’s text is ""; FragmentText reserves each fallback’s space invisibly. |
contextHash |
The identity of this context, if you want it in your own logs. |
holding is there so the holding window is yours to fill if you want it:
{f.$group.holding ? <YourSkeleton /> : <FragmentText field={f.headline} />}Take that branch and you take the layout with it: FragmentText is what draws
the invisible reservation, so whatever you render instead has to hold the space
itself. Letting FragmentText render throughout is still the default and still
what the fields give you on their own. The floor does not move either way:
when the budget expires your fallback lands, so holding never stays
true.
Every failure renders your own copy
Section titled “Every failure renders your own copy”Network down, API down, cold cache, credit cap reached, a malformed response:
all of them resolve to your fallbacks with source: "static", and
FragmentText renders them as bare text. The resolver never rejects and never
throws.
Hitting your credit cap stops new copy being written. Copy already written keeps serving, and your fallbacks keep rendering. A limit never takes a page down.
Use it without React
Section titled “Use it without React”import { createClient } from "@sparkletree/core";
const client = createClient({ publishableKey: KEY });
const { fields, warmth } = await client.fragments({ context: { page: "pricing", intent: "signup" }, fields: { cta: { kind: "cta", fallback: "Start your free trial" } },});
button.textContent = fields.cta.text;If you show a disclosure label, decide it from fields.cta.source — render
one for anything that is not static. See
Using it without React.
Brand pages and other sites without a backend
Section titled “Brand pages and other sites without a backend”A static page, such as a SparkleTree brand page, has no server to sign a
mint token, so it writes copy with your live publishable key. Pass the key and
the SDK sends it on the write request as Authorization: Bearer <key>. The
platform writes the copy when the request comes from an origin registered to
your organization. Your verified brand host counts as registered.
The key is public: it is in your page source. The platform accepts it only from your registered origins, and a browser cannot fake its origin, but a script outside a browser can claim any origin. So anyone who copies the key can spend up to your daily budget for new copy (50 new copies in 24 hours, 1 credit each). That budget is shared by all your sites, including those that sign their writes, so once it is used up your pages show their fallbacks for up to 24 hours. If you have a backend, use a signed mint token.
<style> /* Held: invisible, but the fallback keeps its space. Shown after 5 s if the script never runs. */ [data-st-hold] { visibility: hidden; animation: st-show 0s 5s forwards; } @keyframes st-show { to { visibility: visible; } }</style><h2 id="offer" data-st-hold>Fresh bread every morning</h2><script src="/api/embed/v1/sdk/core.js"></script><script> const key = "st_pk_live_…"; const offer = document.getElementById("offer"); const fallback = offer.textContent; // Already shown by the 5 s reveal (a very late script): the fallback stays. let done = getComputedStyle(offer).visibility === "visible";
function land(text, whole) { if (done) return; done = true; // Keep the box the fallback held, so nothing below moves. Object.assign(offer.style, { boxSizing: "border-box", height: offer.offsetHeight + "px", overflow: "hidden", }); offer.removeAttribute("data-st-hold"); offer.textContent = text; // Copy that would not fit the fallback's box keeps the fallback. if (whole && offer.scrollHeight > offer.clientHeight + 1) offer.textContent = fallback; }
// One hold, counted from the page's start: 2.5 s, then the fallback is final. setTimeout(() => land(fallback), 2500 - performance.now()); SparkleTree.fetchFragments({ organizationId: "YOUR_ORGANIZATION_ID", apiBase: SparkleTree.decodePublishableKey(key).apiBase, publishableKey: key, context: { page: "home" }, fields: { offer: { kind: "headline", fallback } }, }).then((result) => land(result.fields.offer.text, true));</script>Text a visitor can see is never replaced. The element starts with your
fallback, invisible but holding its space. Copy that arrives within 2.5
seconds of the page starting lands once; otherwise your fallback shows and
stays, and a late response is ignored. Your fallback sets the size: copy that
would not fit its box keeps the fallback. Use a block element (h1 to h6,
p, div) for the text. useFragments in React does all of this for you.
createClient({ publishableKey }) and useFragments under
<SparkletreeProvider publishableKey={…}> send the key the same way. The key
goes on the write request only, never on the read.
What the key does not do:
- A test key (
st_pk_test_) writes copy only fromlocalhost, outside production. On a real site use the live key. - A key for another organization, or a live key from an origin your organization has not registered, is refused. Your fallback stays.
- The key does not stop someone who copies it from your page source (see above). What they can spend is bounded by your daily budget for new copy, your quota and your credits.
A signed mint token is still supported and
is still the only credential that resists a spoofed Origin. Send both and the
token is used. An organization whose admin turned Signed mints only on
accepts signed tokens only from its other sites, and the key is refused there.
Its own brand host, the brand page SparkleTree serves for it, keeps minting
with the live key, and that copy counts against the same daily budget.
Before you ship: pin the spend to your account
Section titled “Before you ship: pin the spend to your account”Writing copy costs credits, and a browser cannot prove it belongs to your account. A live publishable key from a registered origin lets a site with no backend write copy, but the key is public (see above). Signed minting pins the spend to your backend instead, and is required by default for newer workspaces that do not send a key.