Skip to content

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.

Each entry needs a kind and a fallback. A bare string is shorthand for { kind: "body", fallback: "…" }.

kindForSwaps
ctaButtonsOnly before your fallback paints
labelLinks, small labelsOnly before your fallback paints
headlineHeadingsOnce, whenever it arrives
bodySentences and paragraphsOnce, whenever it arrives
empty-state“Nothing here yet” copyOnce, 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.

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:

  • cta and label swap 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 a FragmentGroup, 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, body and empty-state get 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.

context describes where the copy sits, never who is reading it.

KeyValue
pageYour own page class, lowercase letters, digits and hyphens, up to 32 characters. Never a URL or path.
intentOne of signup, signin, retain, upsell, reassure, explain, browse, convert.
audienceA placement descriptor in the same token format, never a user identifier.
notesUp to 280 characters of free text describing the placement.
customUp 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:

PropertyMeaning
warmth"warm": adapted copy was served. "warming": your fallbacks were served, a fill is in flight. "cold": your fallbacks were served.
markedWhether the group’s copy counts as adapted for this viewer — feed an opt-in InlineMark from it.
holdingThe group is waiting out the budget. Every field’s text is ""; FragmentText reserves each fallback’s space invisibly.
contextHashThe 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.

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.

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.