Design editor (Canvas)
Applies to @snowcone-app/canvas@0.33.1 · @snowcone-app/sdk@0.17.0
What the Canvas is
A mockup is just a URL — product, artwork, and Shop ID. The Canvas is the optional piece that sits upstream of that URL: a self-contained React editor, SnowconeCanvas from @snowcone-app/canvas, that lets a shopper actually compose the artwork — add text and images, warp text along arcs and waves, apply strokes and masks, and design across several placements at once.
It exports a flat image per artboard, which you hand to the mockup renderer (for a live preview) and persist on add-to-cart. It is the same editor that powers the Edit and Remix flows on the snowcone.app product pages.
Install
The design editor ships as its own package. The package is SSR-import-safe; the editor itself is interactive (browser-only), so render it client-side — in Next.js via a dynamic import with { ssr: false }.
import '@snowcone-app/canvas/style.css'). No wrapper class needed — the components scope their own styles. The CSS is fully namespaced (sc: utilities, --sc-* variables), so it can’t collide with your app’s own Tailwind or class names — no transpilePackages or @source needed.Quickstart
Mount SnowconeCanvas with at least one artboard. Everything else — toolbar, layers, undo/redo, fonts — comes wired in by the default kit. Two files: a thin page that loads the editor client-side, and the editor itself.
'use client' components, and the canvas needs the browser (it touches window at mount), so a plain <SnowconeCanvas/> in a page throws window is not defined on the first render. Load it with dynamic(() => import('./Editor'), { ssr: false }) — shown below — and that never happens. (The package is SSR-import-safe: pure helpers like serializeStateForServer import fine in a Server Component; it’s only rendering the live editor on the server you opt out of.)app/page.tsx
// app/page.tsx — load the editor CLIENT-SIDE ONLY. The canvas is browser-only,
// so it must never be server-rendered: dynamic(..., { ssr: false }) keeps it out
// of Next's SSR pass, so it can't throw `window is not defined` on first paint
// (the single most-reported canvas trap). This file is itself a Client
// Component because { ssr: false } isn't allowed in a Server Component
// (Next 15/16 throws). Importing the package on the server is safe — only
// *rendering* the live editor there is what you opt out of.
'use client';
import dynamic from 'next/dynamic';
const Editor = dynamic(() => import('./Editor'), { ssr: false });
export default function Page() {
return <Editor />;
}app/Editor.tsx
// app/Editor.tsx — the interactive editor. 'use client' is necessary but NOT
// sufficient: Next still SSRs Client Components, which is why app/page.tsx
// above loads this via dynamic(..., { ssr: false }) — never import it directly
// into a Server Component.
'use client';
import { SnowconeCanvas } from '@snowcone-app/canvas';
// The one stylesheet. No Tailwind, no transpilePackages, no config.
import '@snowcone-app/canvas/style.css';
export default function Editor() {
return (
// No wrapper class needed — the component scopes its own styles.
<SnowconeCanvas
// One artboard per print area. The name is the export key.
artboards={[{ name: 'Front', width: 1200, height: 1200 }]}
// Optional: drop the shopper's art in to start from. The src MUST be
// publicly fetchable AND CORS-enabled (loaded with crossOrigin="anonymous").
imageConfig={{ src: 'https://your-shop-id.storage.snowcone.app/art.png', scaleMode: 'contain' }}
// Fires on every edit — persist this JSON to reload the design later.
onChange={(state) => saveDraft(state)}
/>
);
}Wiring the editor to a live mockup? The App Router quickstart extends this same shape with a RenderSession and the grant proxy.
style.css is fully namespaced: every utility it ships is sc:-prefixed, its variables are --sc-*, and all styles are scoped under the .sc-root class that the components apply themselves. It is safe to load in any host app — including one with its own Tailwind setup — with no class-name collisions and no wrapper element required. Do not add the canvas package to your Tailwind @source/content globs; the stylesheet is precompiled and self-contained. To theme the editor, override the design-token variables on .sc-root in your own CSS — e.g. .sc-root { --primary: #6d28d9; --accent: #6d28d9; --background: #fafafa; --radius-md: 8px; } (colors: --primary, --accent, --background, --foreground, --surface, --muted, --danger, --success; shape/space: --radius-sm…--radius-full, --space-xs…--space-4xl; fonts: --font-heading, --font-body).crossOrigin="anonymous", so the host serving your artwork (the imageConfig.src, an image element’s imageUrl, etc.) MUST send permissive CORS headers (Access-Control-Allow-Origin). A URL that is merely public but blocks cross-origin reads fails to load — e.g. upload.wikimedia.org returns a 400 on the canvas. The simplest CORS-friendly origin is your shop’s own your-shop-id.storage.snowcone.app bucket (CORS-enabled by default); host artwork there, or make sure your own CDN sends the header. The same URL is later fetched server-side at render time, so it must also be publicly reachable (no auth-gating).textAlign accepts the full TextAlign union — 'left', 'center', or 'right' (it defaults to 'center'). The example above uses 'center'; 'left' and 'right' are equally valid.Placements (artboards)
Each artboard is one print area. Give them the same names as your product’s placements so exports line up with the mockup URL’s placement= param. The shopper switches between them; onArtboardChange reports the active one.
<SnowconeCanvas
artboards={[
{ name: 'Front', width: 1200, height: 1200 },
{ name: 'Back', width: 1200, height: 1200 },
// clipShape masks content to a shape (e.g. a circular badge).
{ name: 'Pocket', width: 400, height: 400, clipShape: 'circle' },
]}
activeArtboard="Front"
onArtboardChange={(name) => console.log('now editing', name)}
/>Save & reload a design
onChange hands you a CanvasState — the elements, artboards, and active selection. Persist that JSON. To reopen an editable design, feed its elements back in via initialElements.
// SAVE — onChange hands you a CanvasState (elements + artboards).
// Persist the JSON. Do NOT save a flattened PNG as the state.
const [state, setState] = useState<CanvasState | null>(null);
<SnowconeCanvas
artboards={artboards}
onChange={setState}
// RELOAD — feed the saved elements back in to restore an editable design.
initialElements={savedState?.elements}
/>CanvasState is the source of truth.initialElements (or imageConfig) seeds the canvas, which settles and fires onChange on its own once the editor is ready — so the first mockup renders automatically (typically ~3–5 s after mount) before the shopper touches anything. That first paint is expected behavior, not a failed render.Live mockup preview
There are two paths to a live “see it on the product” preview, and they reach the same outcome:
- Ship state (recommended for the live PDP) —
RenderSession+serializeStateForServersends the ~1 KB canvas state and lets the server fetch the artwork itself; no pixels cross the wire. This is the recommended path — see Realtime server-side render, and the Wiring the canvas to realtime section below. - Ship pixels (the alternative below) — when you already have a rasterized canvas blob,
sendCanvasBlobpushes the bytes straight to the renderer. Heavier on the wire, but handy if you’re auto-exporting blobs anyway.
The rest of this section shows the ship-pixels path. Turn on auto-export and stream each blob to useRealtimeMockup for a sub-second preview as the shopper edits. Use webp for ~10× smaller uploads.
NEXT_PUBLIC_MERCH_WS_URL is the realtime WebSocket base URL the wsUrl prop expects — set it to the production endpoint wss://cdn.snowcone.app/realtime (the same value the SDK exposes as the REALTIME_WS_URL constant and uses by default; see the realtime API reference). Defining it as a NEXT_PUBLIC_* env var keeps the URL out of your code and lets you point at a staging endpoint locally.import { SnowconeCanvas } from '@snowcone-app/canvas';
import { useRealtimeMockup } from '@snowcone-app/sdk/react';
function LiveCustomizer() {
const { sendCanvasBlob, mockupResults } = useRealtimeMockup({
wsUrl: process.env.NEXT_PUBLIC_MERCH_WS_URL,
});
return (
<SnowconeCanvas
artboards={[{ name: 'Front', width: 1200, height: 1200 }]}
// Export a blob shortly after each edit settles…
exportConfig={{
autoExportConfig: { enabled: true, debounceMs: 200 },
format: 'blob',
imageFormat: 'webp',
}}
// …and stream it to the renderer for a sub-second mockup preview.
onExportStatus={(event) => {
if (event.status === 'complete') {
const blob = event.result['Front'];
if (blob instanceof Blob) sendCanvasBlob('Front', blob, 1, 200);
}
}}
/>
);
}Wiring the canvas to realtime
The blob path above ships pixels. For the server-side render path you ship state, not pixels — the server fetches the artwork itself. The contract between the editor and that renderer is small and worth getting exactly right:
- Give each editable element a
name. A binding hook —useTextBinding(name)(oruseImageBinding(name)) — targets every element with that name;setTextupdates them all. - Binding hooks read the editor context, so they must render inside the
EditorProvider. WithSnowconeCanvasthat means putting them in theoverlayprop (it’s mounted inside that context) — a hook used as a sibling of<SnowconeCanvas/>reportsisConnected: falseand no-ops. - The editor’s
onChangehands you aCanvasStatethat feeds directly intoserializeStateForServer(state)from@snowcone-app/canvas— its output is exactly whatRenderSession.renderState(placement, …)takes. No reshaping in between.
import { useState } from 'react';
import { SnowconeCanvas, useTextBinding } from '@snowcone-app/canvas';
import { serializeStateForServer } from '@snowcone-app/canvas';
import { RenderSession } from '@snowcone-app/sdk';
// 1. A control bound to an editable element BY NAME. useTextBinding('headline')
// finds every text element whose `name` is 'headline' and edits them. It reads
// canvas context, so it MUST render inside SnowconeCanvas's `overlay` (which is
// mounted inside the EditorProvider) — not as a sibling of <SnowconeCanvas/>.
function HeadlineInput() {
const { text, setText, isConnected } = useTextBinding('headline');
return (
<input
value={text}
onChange={(e) => setText(e.target.value)}
disabled={!isConnected} // false until a matching element exists
/>
);
}
function Customizer({ session }: { session: RenderSession }) {
return (
<SnowconeCanvas
artboards={[{ name: 'Front', width: 1200, height: 1200 }]}
// 2. Name the element so the binding hook can target it.
// (Element configs use transformType — 'custom' is plain text.)
// Add legibility props so the seeded text reads well at print scale.
initialElements={[
{
transformType: 'custom',
name: 'headline',
text: 'YOUR TEXT',
x: 600, y: 400,
fontSize: 96,
color: '#111111',
textAlign: 'center',
bold: true,
},
// An image element seeds artwork into the design. transformType:'image'
// + imageUrl. The URL MUST be publicly fetchable AND served with CORS
// headers (the canvas loads it with crossOrigin="anonymous"); the server
// also fetches it at render time, so a private/auth-gated URL renders blank.
{
transformType: 'image',
imageUrl: 'https://your-shop-id.storage.snowcone.app/logo.png',
x: 600, y: 800,
},
]}
// 3. Binding hooks live in the overlay (inside EditorProvider context).
overlay={<HeadlineInput />}
// 4. onChange's CanvasState feeds straight into serializeStateForServer,
// whose output is exactly what RenderSession.renderState expects.
// artboard 'Front' === the placement arg === catalog placements[].label.
onChange={(state) =>
session.renderState('Front', serializeStateForServer(state), 200)
}
/>
);
}name must equal the renderState placement arg, and both must equal the product’s placements[].label — read it from getProduct('BEEB77').placements[].label. For BEEB77 that label is Front (used above); a stray name like Print isn’t a real placement, so the render comes back as an incomplete_canvas_placements error and a blank mockup. That single string ties the editor, the wire payload, and the catalog together — see the realtime page for the full id-space breakdown.transformType. The element config you author above (initialElements) keys on transformType — e.g. transformType:'image' + imageUrl, or transformType:'custom' for plain text. The payload serializeStateForServer emits for the renderer is a different, internal shape — pass it through to renderState as-is and never hand-author or hand-edit it.Export on add-to-cart
For the final, print-resolution artwork, call exportArtboards() on the ref when the shopper commits. Pass all: true to capture every placement at once, then upload the blobs and attach their URLs to the order.
import { useRef } from 'react';
import { SnowconeCanvas, type SnowconeCanvasHandle } from '@snowcone-app/canvas';
const canvasRef = useRef<SnowconeCanvasHandle>(null);
async function addToCart() {
// On-demand, print-resolution export of every artboard.
const exports = await canvasRef.current?.exportArtboards({
format: 'blob',
all: true,
});
// exports = { Front: Blob, Back: Blob }
}
<SnowconeCanvas ref={canvasRef} artboards={artboards} />SnowconeCanvas — key props
artboardsArtboardConfig[]{ name, width, height, clipShape? }. The name is the key used in export results.activeArtboardstringimageConfigImageConfig{ src, alignment?, scale?, scaleMode? }.initialElementsAnyElementConfig[]CanvasState — restores an editable design.onChange(state: CanvasState) => voidexportConfigExportConfigautoExportConfig (debounce), format (blob | dataUrl), imageFormat, scale.onExportStatus(event: ExportStatusEvent) => voidscheduled → rendering → complete (with result) | error.layoutConfigLayoutConfigviewPadding, artboardBorderRadius, fixedMargin, maxHeight, showToolbar, showLayers.kit'pro-studio' | 'compact-customizer' | 'embed-only' | KitDefinitionpro-studio (default) is full chrome — an add-element menu, layers + effects panels, undo/redo (it needs a roomy container; the add-element menu collapses behind a “Menu” button when narrow). compact-customizer is a contextual toolbar only — it has no add-element menu, so a user can’t add text or art from the canvas UI; pair it with your own “add text”/“upload” controls. embed-only is a chrome-free canvas (auto-export) you wrap with your own UI entirely.servicesCanvasServices{ imageSearchUrl?, generateUrl?, imageSearch?, generate? }. Same-origin proxy URLs, the same pattern as grantUrl — your vendor keys live on your server, never in the browser. Tabs without a configured service don’t render. See Stock images & AI generation.refRef<SnowconeCanvasHandle>exportArtboards(opts) returns a promise of print-resolution exports for on-demand saves.inheritThemebooleanProps can be passed grouped (exportConfig, imageConfig, layoutConfig — recommended) or as flat individual props. The flat variants still work but are deprecated in favor of the config objects.
imageConfig, not the flat initialImage* props. imageConfig ({ src, alignment?, scale?, scaleMode? }) is the canonical grouped API for the seed image. The flat initialImage / initialImageAlignment / initialImageScale / initialImageScaleMode props are @deprecated aliases for the same fields — and if you set both, the flat prop takes precedence. Don’t mix them: pass one or the other so precedence is never in doubt.Where it leads
The Canvas produces the artwork; the rest of the platform turns it into something buyable:

