Design editor (Canvas)

An embeddable React design editor for letting shoppers compose artwork — text, images, transforms, effects, and multiple placements. The same editor that powers Edit and Remix on snowcone.app.

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.

The Canvas is a React-only, optional layer. If your shoppers don’t author artwork in-browser — they upload a finished file, or you generate it — you don’t need it. The mockup URL stands on its own in any language.

Install

pnpm add @snowcone-app/canvas

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 }.

Styling — one step, zero config. Import the prebuilt stylesheet once (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.

The editor is browser-only — never server-render it. Next.js SSRs even '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

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

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.

CSS isolation. 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).
Artwork URLs must be public and CORS-enabled. The canvas loads every image with 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).
Text alignment. A text element’s 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.

tsx
<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.

tsx
// 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}
/>
Persist the state JSON, not a flattened export image. Saving a rendered PNG as the design’s state collapses every layer into one bitmap — the shopper (or a remixer) reopens it and can’t edit anything. The export image is for display and print; the CanvasState is the source of truth.
Seeded state auto-renders — no interaction required. Passing 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 + serializeStateForServer sends 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, sendCanvasBlob pushes 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.
tsx
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) (or useImageBinding(name)) — targets every element with that name; setText updates them all.
  • Binding hooks read the editor context, so they must render inside the EditorProvider. With SnowconeCanvas that means putting them in the overlay prop (it’s mounted inside that context) — a hook used as a sibling of <SnowconeCanvas/> reports isConnected: false and no-ops.
  • The editor’s onChange hands you a CanvasState that feeds directly into serializeStateForServer(state) from @snowcone-app/canvas — its output is exactly what RenderSession.renderState(placement, …) takes. No reshaping in between.
tsx
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)
      }
    />
  );
}
Hard rule: your artboard 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.
Author elements with 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.

tsx
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[]
One entry per print area: { name, width, height, clipShape? }. The name is the key used in export results.
activeArtboardstring
Controlled selection — the artboard the shopper is currently editing.
imageConfigImageConfig
Initial image to seed the canvas: { src, alignment?, scale?, scaleMode? }.
initialElementsAnyElementConfig[]
Serialized elements from a previously saved CanvasState — restores an editable design.
onChange(state: CanvasState) => void
Fires on every content change. Persist this JSON to save the design.
exportConfigExportConfig
Auto-export behavior: autoExportConfig (debounce), format (blob | dataUrl), imageFormat, scale.
onExportStatus(event: ExportStatusEvent) => void
Unified export lifecycle: scheduledrendering complete (with result) | error.
layoutConfigLayoutConfig
Appearance: viewPadding, artboardBorderRadius, fixedMargin, maxHeight, showToolbar, showLayers.
kit'pro-studio' | 'compact-customizer' | 'embed-only' | KitDefinition
Preset bundle of layout + capabilities. Three presets: pro-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
Privileged-service wiring for the Image Browser’s stock-image search and AI generation tabs: { 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>
Imperative handle. exportArtboards(opts) returns a promise of print-resolution exports for on-demand saves.
inheritThemeboolean
Make the canvas's ThemeProvider passive so it inherits the host app's theme instead of managing its own.

Props 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.

Use 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: