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 | Swaps |
|---|---|---|
cta | Buttons | Only before your fallback paints |
label | Links, small labels | Only before your fallback paints |
headline | Headings | Once, whenever it arrives |
body | Sentences and paragraphs | Once, whenever it arrives |
empty-state | “Nothing here yet” copy | Once, whenever it arrives |
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.
Buttons swap before your fallback paints, or never
Section titled “Buttons swap before your fallback paints, or never”Copy a visitor is about to click is treated more carefully than passive text:
ctaandlabelswap only if the response arrives before your fallback has painted, that is, during the wait above. Cached copy routinely does. Otherwise your fallback stands for the whole page view, with one geometric exception: inside aFragmentGroup, a late response measured in your page to occupy exactly the fallback’s space may still swap. Relabelling a button under a moving cursor produces misclicks, not adaptation. The measurement is what proves the cursor has nothing to mis-hit.headline,bodyandempty-stateget one swap whenever the response lands. A late swap replaces visible text, so it lands complete, in one piece; visible copy is never retyped. Entrances are different: copy landing in the reserved space during the hold types in at the paced rate, like every entrance in the SDK.
Those are the defaults for the common case. Pass swap on any field to choose
another: a button nobody is about to click can take the late swap with
"always", and copy whose exact words are the promise (a reviewed claim, a
guarantee) declines even the measured exception with "never"; painted
copy then stands for the page view, full stop:
fields: { cta: { kind: "cta", fallback: "Start free", swap: "always" }, guarantee: { kind: "label", fallback: "Cancel anytime", swap: "never" },}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. The swap is a separate question with its own
guarantees: click copy is geometrically gated and served copy is written to
your fallback’s length budget. But a passive paragraph that legitimately runs
longer can still wrap an extra line when it lands, so write fallbacks that
reflect the length of real copy and the swap stays put. If nothing
adapted has arrived within 2.5 seconds, your fallback types into the space it
already holds, with zero layout shift and no hard paint. Passive copy still
takes its one swap when the response lands, while buttons and labels keep
your string. One geometric exception, inside a FragmentGroup: a late
button label that is measured in your page to occupy exactly the fallback’s
space may still swap. The SDK verifies the swap cannot move anything before
allowing it, and declines whenever it cannot prove that. Pass holdMs to
change the budget. A response that settles with nothing adapted paints 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 painted while it is hidden, a response landing inside the budget swaps
buttons and labels in groups of any size. The before-paint window is back at
scale.
On the server there are no timers: SSR ships your fallback in the HTML, and a hydrated page never holds at all. Its copy has been visible since the server’s first paint, so the SDK will not hide it; buttons and labels keep your string for the page view, and passive copy takes its one rewrite when the response lands.
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.
The first visitor to a new context sees your fallback
Section titled “The first visitor to a new context sees your fallback”A read is one cacheable request that always answers, warm or cold. On a cold read the SDK queues one background request to write the copy, once per browser session per context, and moves on. Nothing waits for a model, so the second visitor gets the adapted version and the first one loses nothing.
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 types in, 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.
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. Signed minting is how you pin that spend to you, and it is required by default for newer workspaces.