Embed mode
Applies to @snowcone-app/canvas@0.33.1 · @snowcone-app/sdk@0.17.0
What it is
The Canvas editor has two modes. By default it renders full studio chrome — a toolbar, layers, effects panels. Embed mode (kit="embed-only") renders just the canvas and lets you supply the UI, so the editor drops into your own product page and matches your design.
Embed mode used to mean wiring the canvas, a realtime RenderSession, an auto-export bridge, and a seed render by hand — about 80 lines, with several silent traps. The @snowcone-app/canvas/embed primitives collapse that into one declarative tree where every required piece is a typed prop:
<RealtimeEmbed>— owns the session and holds the required inputs:shop,product,design, and a grant source.<RealtimeCanvas>— the editor. You pass your own controls as children.<RealtimeMockup>— the live preview, linked to the editor through context so it can’t desync.
A single design() seeds both the canvas and the first render — there’s no second “seed state” to keep in sync.
The required pieces
Everything an embed needs is a required prop on <RealtimeEmbed> — there’s no silently-optional prop to forget: shop (your publishable shop id), a grant source (grantUrl or grant), product (the catalog ids to render against), and design (the design() source of truth).
POST https://api.snowcone.app/shops/sandbox returns a shop_id you can render against immediately. See Get a Shop ID.validateEmbed (run automatically) prints a console warning for the classic mistakes: a missing grant, a design whose placement isn’t a real product placement, or two layers sharing a name.1. Mint a grant (server)
The browser never holds a secret key. A same-origin route mints a short-lived grant. For a sandbox shop the publishable shop.id alone authorizes it — no key. For production, pass an sk_… key; see Realtime for the secret and signed rungs. (The scsec_… “shop secret” you get when minting a sandbox shop is for signing image URLs — it is not an sk_ key and doesn’t go here.)
// app/api/realtime/grant/route.ts — your same-origin grant proxy.
// Sandbox: the publishable shop.id alone authorizes the grant (no apiKey).
import { mintRealtimeGrant } from '@snowcone-app/sdk';
export async function POST(req: Request) {
const { shop } = await req.json();
const grant = await mintRealtimeGrant({ shop }); // { token, expiresAt }
return Response.json(grant);
}2. The editor (browser)
The canvas is browser-only. A thin Client Component lazy-loads it with next/dynamic and { ssr: false }:
// app/page.tsx — a thin Client Component that lazy-loads the browser-only editor.
// next/dynamic with { ssr: false } must live in a Client Component.
'use client';
import dynamic from 'next/dynamic';
const Editor = dynamic(() => import('./Editor'), { ssr: false });
export default function Page() {
return <Editor />;
}The editor itself is one tree. Compose the design by intent with design(), give the layers you want to personalize a name, and bind your own controls to them. <RealtimeCanvas> and <RealtimeMockup> can be laid out however you like — they share one session through context.
// app/Editor.tsx — the entire live editor, client-side.
'use client';
import {
RealtimeEmbed,
RealtimeCanvas,
RealtimeMockup,
design,
useTextBinding,
} from '@snowcone-app/canvas/embed';
import '@snowcone-app/canvas/style.css';
// ONE design() seeds the canvas AND the first render — no duplicate seed state.
// Named layers ('art', 'headline') are what the binding hooks target. The
// artboard `name` is the placement; it must match a product placement label.
const d = design({ name: 'Front', width: 1480, height: 2328 })
.image('https://your-shop-id.storage.snowcone.app/demo/art.jpg', {
anchor: 'center',
name: 'art',
})
.text('YOUR NAME', { anchor: 'top', name: 'headline' });
// Bring your own control. embed-only ships no chrome; useTextBinding(name) wires
// an input to a named layer. Every keystroke re-renders the mockup.
function HeadlineInput() {
const { text, setText, isConnected } = useTextBinding('headline');
return (
<input
value={text}
onChange={(e) => setText(e.target.value)}
disabled={!isConnected}
/>
);
}
export default function Editor() {
return (
<RealtimeEmbed
shop="YOUR_SHOP_ID" // publishable shop.id
grantUrl="/api/realtime/grant" // the proxy above
// mockupIds are catalog scene codes (product.mockups[].id) — NOT placement
// names. variantId is REQUIRED for a product with a COLOR axis (BEEB77
// has a Frame color) — omitting it there hangs the render. Read these
// from the catalog with getProduct() (see "Find the ids" below).
product={{ productId: 'BEEB77', mockupIds: ['FV1qjO'], variantId: 'Pv1sLC', placements: ['Front'] }}
design={d}
>
<RealtimeCanvas>
<HeadlineInput />
</RealtimeCanvas>
<RealtimeMockup />
</RealtimeEmbed>
);
}Editing controls: bring your own, or use a kit
<RealtimeCanvas> takes a kit prop that decides how much editor UI it mounts. There are two paths — pick by how much control you want over the look:
- Bring your own controls (
kit="embed-only", the default). A chrome-less canvas — you supply every control, so they match your product page. The binding hooks connect a control to a layer byname:useTextBinding(name)drives a text layer from an<input>;useImageBinding(name)swaps the image on a named image layer. Great for a fill-in-the-blanks personalizer (change this text, swap this photo). Bindings only resolve inside<RealtimeCanvas>, so render your controls as its children.isConnectedisfalseuntil the named layer exists — use it to disable a control until the canvas is ready. - Use a kit (
kit="compact-customizer"or"pro-studio"). A full in-canvas editor with no UI to build — the shopper can select a layer and change its font size, add an element, reorder layers, apply effects. Every edit still streams a fresh mockup through the same session; you wire nothing extra. This is the fastest way to a real editor, and it stays on the turnkey path (no eject).
// Don't want to hand-build controls? Pass a kit to <RealtimeCanvas> and get
// a full in-canvas editor — select a layer, change its font size, add an
// element, reorder layers. Every edit still streams a fresh mockup; you wire
// nothing. (Default is kit="embed-only", the chrome-less bring-your-own path.)
<RealtimeCanvas kit="pro-studio" /> // full studio chrome
// or
<RealtimeCanvas kit="compact-customizer" /> // a trimmed toolbar
// Mixing is fine — pass children to ADD controls on top of a kit:
<RealtimeCanvas kit="compact-customizer">
<HeadlineInput /> {/* your bound control, alongside the kit's toolbar */}
</RealtimeCanvas>useTextBinding, useImageBinding) work the same under any kit, so you can mix: pass a kit and your own bound controls as children when your controls add to — rather than duplicate — the kit’s. Two kits worth of the same toolbar is the one thing to avoid.The upload → bind loop (swap a photo)
The most common customizer control: the shopper picks a photo, you host it, and setImageUrl(url) swaps the named layer — a fresh mockup streams back. Host the photo with POST https://api.snowcone.app/uploads/base64 — it is self-serve: any shop API key with the uploads:write scope works, and a freshly minted sandbox shop’s key already carries it. Keep the key server-side by proxying the call same-origin, exactly like grantUrl:
// app/api/upload/route.ts — same-origin upload proxy; the API key stays
// server-side. POST https://api.snowcone.app/uploads/base64 is self-serve:
// any shop key with the uploads:write scope works (a freshly minted sandbox
// shop's key already carries it).
//
// Request: { base64Image } — a data URL (data:image/png;base64,…) or raw
// base64. Allowed types: png, jpg, jpeg, webp, gif, svg. Max 50 MB.
// Response: { url } — a permanent asset on your shop's
// your-shop-id.storage.snowcone.app origin, which is already on the
// shop's asset-origin allowlist — renderable with zero extra config.
export async function POST(req: Request) {
const { base64Image } = await req.json();
const res = await fetch('https://api.snowcone.app/uploads/base64', {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': process.env.SNOWCONE_API_KEY!, // needs uploads:write
},
body: JSON.stringify({ base64Image }),
});
return Response.json(await res.json(), { status: res.status });
}// PhotoPicker.tsx — render as a <RealtimeCanvas> child, next to HeadlineInput.
// The shopper picks a photo → POST /uploads/base64 (via your proxy above) →
// setImageUrl(url) swaps the named 'art' layer — a fresh mockup streams back.
import { useImageBinding } from '@snowcone-app/canvas/embed';
function PhotoPicker() {
const { setImageUrl, isConnected } = useImageBinding('art');
return (
<input
type="file"
accept="image/png,image/jpeg,image/webp"
disabled={!isConnected}
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
const base64Image = await new Promise<string>((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(file);
});
const { url } = await (
await fetch('/api/upload', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ base64Image }),
})
).json();
setImageUrl(url); // canvas swaps the layer; the mockup re-renders
}}
/>
);
}url lives on your shop’s own your-shop-id.storage.snowcone.app origin, which is already on the shop’s asset-origin allowlist — so the renderer can fetch it with zero extra config. Full endpoint contract on the API reference.Building chrome around the canvas (layout contract)
When you build your own editor chrome — a tool dock, an inspector, a layers rail — around <RealtimeCanvas kit="embed-only">, four layout rules decide whether it works on the first try:
1. Children render inside the canvas’s box. Your <RealtimeCanvas> children mount as an overlay inside the editor’s own position: relative container — not as siblings of it. That’s what makes the binding hooks resolve, and it makes the canvas box the positioning context: a child with absolute top-2 right-2 floats over the artwork, and a child anchored with right-full sits just outside the canvas’s left edge while still living in its DOM.
2. Anchor outside panels with *-full utilities, and reserve their space with wrapper padding. The supported pattern for chrome around the canvas: right-full (panel to the left), left-full (panel to the right), top-full (panel below), bottom-full (panel above) — then pad a wrapper you own so the page layout leaves room for them. These are your app’s own Tailwind classes (the canvas’s internal styles are sc:-prefixed and never collide):
// Chrome around the canvas: anchor each panel just OUTSIDE the canvas box
// (right-full / left-full / top-full / bottom-full) and reserve its space
// with padding on a wrapper YOU own. The class names are your app's own
// Tailwind utilities — the canvas's internal styles are sc:-prefixed and
// never collide with them.
import { RealtimeCanvas } from '@snowcone-app/canvas/embed';
// The wrapper reserves the room the anchored panels occupy: pl-16 for the
// tool dock, pr-44 for the layers rail, pb-28 for the inspector. No
// overflow-hidden here — or on ANY ancestor — it would clip the panels.
function EditorWithChrome() {
return (
<div className="relative pl-16 pr-44 pb-28">
<RealtimeCanvas maxHeight={560}>
{/* Tool dock — anchored LEFT of the canvas */}
<div className="absolute right-full top-0 mr-3 w-12 space-y-2">
{/* …tool buttons… */}
</div>
{/* Layers rail — anchored RIGHT */}
<div className="absolute left-full top-0 ml-3 w-40">
{/* …layer list… */}
</div>
{/* Inspector — anchored BELOW */}
<div className="absolute top-full inset-x-0 mt-3 h-24">
{/* …selected-layer controls… */}
</div>
</RealtimeCanvas>
</div>
);
}3. The overflow: hidden trap. Panels anchored with *-full live inside the canvas’s box but render outside its bounds — so any ancestor with overflow: hidden (or auto/scroll/clip) silently clips them. The usual offender is a card wrapper with overflow-hidden rounded-xl. Don’t put one between the canvas and your page: rounded corners don’t need overflow-hidden on the canvas’s ancestors — round (and clip) an inner element that doesn’t contain the canvas, and reserve panel space with wrapper padding as above.
4. Sizing is width-driven; cap height with maxHeight. The canvas measures only its container’s width, fits the artboard to it, and sets its own height from the artboard’s aspect ratio — it never reads the parent’s height. maxHeight refits rather than clips: the artboard scales down until it fits and the leftover width becomes side margin, so the artwork stays centered and fully visible. The footgun is a height-constrained parent: a fixed-height pane (h-[480px] + overflow-hidden) does not make the canvas shrink — it keeps its width-derived height and the parent silently hides the bottom of the canvas. Give the slot’s height to maxHeight instead, and let the parent grow to fit.
Stock images & AI generation
The kits’ Image Browser has stock-image search and AI-generation tabs that need privileged vendor APIs. The canvas never holds those vendor keys — you host same-origin proxy routes (the same pattern as grantUrl) and pass their URLs via the services prop. A tab whose service isn’t configured doesn’t render at all, so an unconfigured embed simply shows only the URL/upload tab.
// Stock images & AI generation in the Image Browser — same pattern as grantUrl:
// same-origin proxy URLs, your vendor keys stay on your server. A tab whose
// service isn't configured doesn't render at all.
<RealtimeEmbed
services={{
imageSearchUrl: '/api/image-search', // createImageSearchHandler (sdk/server)
generateUrl: '/api/generate-image', // your AI proxy — you pick the model
}}
// … shop, grantUrl, product, design as above
>
<RealtimeCanvas kit="pro-studio" />
<RealtimeMockup />
</RealtimeEmbed>@snowcone-app/sdk/server ships createImageSearchHandler — a drop-in route handler for the image-search side (bring your own Unsplash/Pixabay keys, server-only). The full wiring, the wire contracts, and the AI route recipe live on Stock images & AI generation.
The live preview
<RealtimeMockup> renders the latest mockup and updates as the shopper edits. For a custom preview — a gallery, your own loading treatment — use useRealtimeMockups(), which returns { mockups, isRendering, error }. Pass <RealtimeMockup mockupId="…"> to pick a specific scene, or the default shows the first that has rendered.
Catalog → prop mapping
getProduct(id) reads the public catalog; its fields map onto the product prop. Crossing them is the #1 silent-failure trap:
mockupId≠placement. A mockupId (product.mockups[].id) is an opaque scene code; a placement (product.placements[].label) is a print-area name. Thedesign’s artboardnamemust equal the placement.variantIdis required when the product has a COLOR axis — anoptions.attributesentry whose choices carry ahex. Omit it there and the render hangs; pass the catalogoptionsso the embed fails fast with an actionable error instead. A non-color variant axis (size, accessory, …) renders without one — the server uses the default variant and the SDK warns with the valid ids.- Pass the advisory fields too.
requiredPlacements,mockups, anddefaultGvidare catalog passthroughs (verbatim, never sent to the server) that power dev-time guards.requiredPlacementslets<RealtimeEmbed>flag a product that demands more placements than its single artboard at mount (instead of a ~15s “Timed out waiting for all placements”).mockups({ id, gvids }entries) plusdefaultGvid(orvariantId) warn when a requestedmockupId’s scene photographs a different variant than the one being rendered — such a scene silently composites no artwork (a bare product photo), the classicmockupIds: product.mockups.map(m => m.id)trap on a product whose scenes are split across variants.
Don’t hardcode the ids — read them from the catalog and pass them down:
// Find the ids instead of hardcoding them. getProduct() reads the PUBLIC
// catalog — no key. Do this in a Server Component and pass the result down.
import { getProduct } from '@snowcone-app/sdk';
const product = await getProduct('BEEB77');
const mockupId = product.mockups?.[0]?.id ?? 'FV1qjO';
const placement = product.placements?.[0]?.label ?? 'Front';
// First variant combination. REQUIRED when the product has a COLOR axis.
const variantId = product.options?.combinations?.find((c) => c.variantId)?.variantId;
// Pass the advisory catalog fields too — verbatim, never sent to the server.
// They power dev-time guards: options lets a wrong/missing variantId FAIL FAST,
// requiredPlacements flags a multi-placement product AT MOUNT (not a ~15s
// timeout), and mockups + defaultGvid warn when a requested scene photographs
// a different variant (it would composite no artwork — a bare product photo).
const productProp = {
productId: product.id,
mockupIds: [mockupId],
variantId,
options: product.options,
placements: [placement],
requiredPlacements: product.requiredPlacements,
mockups: product.mockups,
defaultGvid: product.defaultGvid ?? undefined, // catalog may carry null
};Beyond the trio: the Editor.* primitives
RealtimeEmbed / RealtimeCanvas / RealtimeMockup are the turnkey arrangement of a finer, stable public surface: the Editor.* compound parts (Editor.Root, Editor.Canvas, Editor.Preview, Editor.TextField, Editor.Buy, Editor.Layers, Editor.Controls, Editor.History, Editor.Menu, Editor.Add) — same session, same design(), but YOU own the layout. They add what the trio doesn’t have: a smart add-to-cart part with completeness gating and payload assembly, typed layer tokens for personalization fields, host-rendered variant pickers via useProductSelection(), and a no-canvas composition where the design editor never loads at all. The turnkey SnowconeEditor is those same parts in our arrangement — customizing means recomposing, never ejecting. See Editor primitives.
Ejecting to full control
<RealtimeEmbed> renders a single placement. It only ever sends the design’s active artboard (one placement). The server requires a canvas_state for every required placement, and the turnkey embed has no prop to switch placement — so a multi-placement product (a tee with Front + Back + sleeves) can never complete through it. Pass the catalog’s product.requiredPlacements (verbatim) and the embed flags this at mount with an error pointing at the eject path; without it, the failure is the server’s ~15s Timed out waiting for all placements. For Front + Back products, use the eject recipe below.<RealtimeEmbed> is the turnkey path. When you need something it doesn’t cover — multi-placement products, color/variant blobs, your own session lifecycle — drop to the underlying primitives directly: mount a <SnowconeCanvas> with one artboard per placement and drive a RenderSession yourself, calling renderState(placement, state) once per placement. (The same kit presets apply when you eject — pass kit="pro-studio" for the full editor.) The embed components are a thin layer over exactly those, so nothing is lost by ejecting.

