Editable templates
Applies to @snowcone-app/canvas@0.33.1 · @snowcone-app/sdk@0.17.0
What a template is
A plain design is a saved canvas state — elements, artboards, positions (see Canvas). An editable template is that same state document plus a list of TemplateFields describing which parts are buyer-editable:
{ type: 'text', name, label, maxLength? }— a text slot. Binds to canvas elements by theirnameproperty; the buyer’s input replaces the element’s text.maxLengthis the designer’s length limit (your control applies it — long names overflow fixed layouts).{ type: 'image', name, label, fit? }— a swappable image. Also binds byname; the buyer’s image is center-cropped (cover, default) or letterboxed (contain) into the original element’s bounds.{ type: 'color', color, label }— an editable color. Matches by VALUE: every element painted that color (text, shape fill, stroke) repaints together.{ type: 'select', name, label, property, options }— a pick-one choice over designer-authored options: “one of these 3 badges” (property: 'imageUrl'), a curated font list (property: 'fontFamily'), or preset text variants (property: 'text').
Every field also takes an optional group label. Controls render in manifest order; consecutive fields sharing a group belong together — that’s the whole ordering model, so the designer’s tick order IS the buyer’s control order unless you sort by group.
The fields live ON the state document itself, under the personalization key (TEMPLATE_FIELDS_KEY):
{
"elements": [
{ "transformType": "custom", "text": "YOUR NAME", "name": "headline" }
],
"artboards": [{ "name": "Front", "width": 1480, "height": 2328 }],
"activeArtboard": "Front",
"personalization": [
{ "type": "text", "name": "headline", "label": "Your name" }
]
}One document is the whole template: state + manifest travel together through your storage, and readTemplateFields() reads the manifest back off any parsed state JSON — it returns [] for a plain, non-template design, so one code path handles both.
Buyer side: controls generated from the manifest
Your storefront reads the manifest and renders one control per field. Each field type pairs with a binding hook, and every hook reports isConnected — whether a matching element actually exists — so your UI shows controls for exactly what this template exposes, and a renamed/deleted layer degrades to a hidden control instead of a dead one:
| Field type | Hook | Binds by |
|---|---|---|
text | useTextBinding(name) | element name |
image | useImageBinding(name, { fit }) | element name |
color | useColorBinding(color) | color value, across all elements |
select | by property: useTextBinding / useImageBinding / useFontBinding | element name |
Binding hooks need the editor context, so the controls render as <RealtimeCanvas> children (or in SnowconeCanvas’s overlay):
// PersonalizeEditor.tsx — the buyer's page. The saved template document drives
// BOTH the canvas seed and which controls exist.
'use client';
import type {
CanvasState,
TemplateField,
TemplateTextField,
TemplateImageField,
TemplateColorField,
} from '@snowcone-app/canvas';
import {
RealtimeEmbed,
RealtimeCanvas,
RealtimeMockup,
DesignBuilder,
readTemplateFields,
useTextBinding,
useImageBinding,
useColorBinding,
} from '@snowcone-app/canvas/embed';
import '@snowcone-app/canvas/style.css';
function TextControl({ field }: { field: TemplateTextField }) {
const { text, setText, isConnected } = useTextBinding(field.name);
if (!isConnected) return null; // layer missing → no dead control
return (
<label>
{field.label}
<input
value={text}
placeholder={field.placeholder}
maxLength={field.maxLength} // designer's limit — enforce it in the control
onChange={(e) => setText(e.target.value)}
/>
</label>
);
}
function ImageControl({ field }: { field: TemplateImageField }) {
const { setImageUrl, isConnected } = useImageBinding(field.name, { fit: field.fit });
if (!isConnected) return null;
return (
<label>
{field.label}
<input type="url" placeholder={field.placeholder} onChange={(e) => setImageUrl(e.target.value)} />
</label>
);
}
function ColorControl({ field }: { field: TemplateColorField }) {
const { color, setColor, isConnected } = useColorBinding(field.color);
if (!isConnected) return null;
return (
<label>
{field.label}
<input type="color" value={color} onChange={(e) => setColor(e.target.value)} />
</label>
);
}
function TemplateControls({ fields }: { fields: TemplateField[] }) {
return (
<>
{fields.map((f, i) =>
f.type === 'text' ? (
<TextControl key={i} field={f} />
) : f.type === 'image' ? (
<ImageControl key={i} field={f} />
) : f.type === 'color' ? (
<ColorControl key={i} field={f} />
) : (
// select — see "Select fields, fonts, and limits" below
null
),
)}
</>
);
}
export default function PersonalizeEditor({ template }: { template: CanvasState }) {
// The saved template re-enters the turnkey embed path: fromState() lifts the
// state back into a DesignBuilder, and the manifest decides the controls.
const d = DesignBuilder.fromState(template);
const fields = readTemplateFields(template);
return (
<RealtimeEmbed
shop="YOUR_SHOP_ID"
grantUrl="/api/realtime/grant"
product={{ productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC', placements: ['Front'] }}
design={d}
>
<RealtimeCanvas>
<TemplateControls fields={fields} />
</RealtimeCanvas>
<RealtimeMockup />
</RealtimeEmbed>
);
}Every buyer edit streams a fresh server-rendered mockup, exactly like any embed-mode editor — the template just decides which knobs exist. The shop, grant, and product wiring is the standard embed setup; see Embed mode for minting grants and mapping catalog ids.
Two ends of the control spectrum, same template document:
- Fill-in-the-blanks (above):
kit="embed-only"+ manifest-generated controls. The buyer can ONLY touch what the designer exposed. - Full editor with shortcuts: pass
kit="compact-customizer"orkit="pro-studio"and render<TemplateControls>as children on top — the manifest fields become prominent quick-edit controls while the full editor stays available.
How much freedom the buyer gets is your product decision; the manifest is the designer’s intent, not an enforcement mechanism. To HARD-lock the rest of the design, set locked: true on the non-editable elements before saving the template — locked layers can’t be dragged, resized, or rotated even in a chrome’d kit (they can still be selected and inspected).
Select fields, fonts, and limits
A select field is one control shape covering three asks: “pick one of these 3 badges” (property: 'imageUrl'), a designer-curated font list (property: 'fontFamily' — values must be Google Fonts family names; useFontBinding loads the chosen font into the page automatically), and preset text variants (property: 'text'). The field’s property picks which binding applies the chosen value:
// SelectControl.tsx — one control covers badges, curated fonts, and preset
// text: the field's `property` picks which binding applies the chosen value.
import {
useTextBinding,
useImageBinding,
useFontBinding,
} from '@snowcone-app/canvas/embed';
import type { TemplateSelectField } from '@snowcone-app/canvas';
function SelectControl({ field }: { field: TemplateSelectField }) {
const text = useTextBinding(field.name);
const image = useImageBinding(field.name);
const font = useFontBinding(field.name);
const binding =
field.property === 'text'
? { isConnected: text.isConnected, apply: text.setText }
: field.property === 'imageUrl'
? { isConnected: image.isConnected, apply: image.setImageUrl }
: { isConnected: font.isConnected, apply: font.setFontFamily };
if (!binding.isConnected) return null;
return (
<label>
{field.label}
<select onChange={(e) => binding.apply(e.target.value)}>
{field.options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</label>
);
}Text limits ride the text field: maxLength is the designer’s cap (buyer names overflow fixed layouts), applied by your control (<input maxLength={…}>, as in TextControl above) — the canvas itself doesn’t truncate.
Older readers (including snowcone.app surfaces on an older canvas) simply drop field types they don’t recognize — a template carrying select fields still works everywhere, minus those controls. That’s the manifest’s forward-compat model: unknown types are skipped, never an error.
select shipped: select joined the TemplateField union, so a switch/ternary that treated the last else as color (a two-arm text ? … : image ? … : color) no longer type-narrows — the else is now TemplateColorField | TemplateSelectField. Match each type explicitly (f.type === 'color' ? … : …, as the TemplateControls above does) so adding the next field type is a compile error to handle, not a silent miscast.Layout: the editor’s box is your positioning context
Three layout facts save you a debugging session:
- The editor is width-driven. It sizes itself from its container’s WIDTH (times the artboard’s aspect ratio) and ignores the container’s height.
maxHeightCLIPS the canvas — it does not refit it. Don’t mount the embed in a fixed-height box: the bottom of the canvas silently disappears. Give the wrapper a width and let height follow. - Your controls’ positioned ancestor is the editor’s box, not your wrapper. Children of
<RealtimeCanvas>mount inside the editor’s ownposition: relativecontainer — a box as tall as width × artboard aspect, NOT as tall as the element you wrapped the embed in. An absolutely positioned control strip (bottom: 0) anchors to the editor’s box. - Don’t rely on normal flow. The canvas itself is absolutely positioned inside that box, so an unpositioned child does NOT stack below the design — it can land at the top, overlapping the artwork. Position controls deliberately, one of two ways:
Overlay strip — pin the controls over an edge of the canvas. Pick this when the artwork region under the strip is decorative (a background, a pattern):
// Width-driven wrapper + a control strip pinned to the bottom of the EDITOR's
// box (its position:relative container is the positioned ancestor — not the
// wrapper div). The strip COVERS the bottom of the artwork.
<div style={{ maxWidth: 420 }}>
<RealtimeCanvas>
<div
style={{
position: 'absolute',
left: 12,
right: 12,
bottom: 12,
display: 'grid',
gap: 8,
}}
>
<TemplateControls fields={fields} />
</div>
</RealtimeCanvas>
</div>Hang below — anchor the controls at top: 100% so they sit under the canvas, and reserve the space with wrapper padding (the editor’s box doesn’t grow for you). Pick this when buyer-editable layers live in the artwork’s lower region — on a camera-cutout product everything editable is in the bottom half, and an overlaid strip would cover exactly the text the buyer is typing into:
// Controls hang BELOW the canvas, still inside <RealtimeCanvas> so the binding
// hooks keep their editor context. The wrapper's paddingBottom reserves room.
<div style={{ maxWidth: 420, paddingBottom: 180 }}>
<RealtimeCanvas>
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
paddingTop: 12,
display: 'grid',
gap: 8,
}}
>
<TemplateControls fields={fields} />
</div>
</RealtimeCanvas>
</div>Product dead zones are shown automatically
The artboard is the full printable file, but on some products part of it sits under hardware — on a phone case, the top of the Front placement is hidden by the camera module. Artwork placed there prints, but never shows on the product or in the mockup.
In the buyer embed, this is automatic. <RealtimeEmbed> fetches the product’s printable-area guides and the editor draws them — the same boundary / safe-area / dead-zone overlay snowcone.app’s own editor shows. Two conditions, both usually already true:
- The embed needs a variant to look up:
product.variantId, or the catalog’sdefaultGvid(pass the catalog fields through, as the embed-mode docs recommend). - The design’s artboard must match the print file’s dimensions — author templates at the placement’s dims. On a mismatch the overlay is skipped (the coordinates wouldn’t line up) and a dev-console hint says so.
On your authoring surface, wire it once. The overlay is automatic only in the embed. A TemplateDesigner built on a raw SnowconeCanvas (the authoring example above) shows NO overlay by default — which is the worst place to miss it, since that’s where the designer drops a field into the dead zone. Fetch the same guides with the exported helpers and pass them to the canvas’s pieceGuides prop, so the designer sees the cutout while placing fields:
// In TemplateDesigner — fetch the product's guides and gate them on the
// artboard, then feed the canvas. `usePieceGuideBundle` + `pieceGuidesForArtboard`
// are the same fetch + dims-gate <RealtimeEmbed> uses, exposed for raw mounts.
import { SnowconeCanvas } from '@snowcone-app/canvas';
import {
usePieceGuideBundle,
pieceGuidesForArtboard,
} from '@snowcone-app/canvas/advanced';
const ARTBOARD = { name: 'Front', width: 1480, height: 2328 };
function AuthoringCanvas({ gvid }: { gvid: string }) {
const bundle = usePieceGuideBundle(gvid); // null while loading / no guides
const pieceGuides = bundle
? pieceGuidesForArtboard(bundle, ARTBOARD) // undefined on a dims mismatch
: undefined;
return (
<SnowconeCanvas
artboards={[ARTBOARD]}
pieceGuides={pieceGuides}
onChange={/* … */ undefined}
/>
);
}Belt and suspenders: still sanity-check each editable field against the live mockup (type into it, watch the mockup change) before publishing a template.
Saving the personalized result
The buyer’s personalized design is just new canvas state. Capture it the same way the designer’s draft was captured (onChange on the canvas — in embed mode, eject to SnowconeCanvas + RenderSession if you need the raw state stream), and persist it at add-to-cart as its own document. Don’t overwrite the template: a template is the master; each buyer session derives a new design from it.
Round-trip with snowcone.app
personalization is the key snowcone.app’s own editor reads and writes, so the concept is portable across surfaces:
- A design saved on snowcone.app with personalization fields → your app reads the same document with
readTemplateFields()and gets working controls. - A template your app authored → its fields appear on the snowcone.app artwork page and product pages as personalization inputs.
- Legacy documents whose fields lack a
typeare normalized to text fields; malformed entries are dropped rather than throwing.

