Skip to content

Embed the designer

The designer is a widget you mount on your own product page. It loads a published template, shows your customer a live preview, and lets them fill in what the template allows. When they are done, it asks Pixonaut for a proof — the image of what will actually be printed — for the customer to approve.

It runs in the customer’s browser with your publishable key. Create one under API Keys in the studio, and list your storefront in its Allowed origins.

Pixonaut serves every release of the SDK from its CDN, at an address of its own that never changes: https://cdn.pixonaut.app/sdk/<version>/. Load it from there, at the version you have tested against, with the integrity the releases page lists for it:

<script
src="https://cdn.pixonaut.app/sdk/<version>/pixonaut.iife.js"
integrity="sha384-…"
crossorigin="anonymous"
></script>

The classic script sets window.Pixonaut, which carries every export of the module: Pixonaut.mount, and Pixonaut.createDesigner with Pixonaut.designerUi for a server-rendered mount. To load it as an ES module instead, preload pixonaut.esm.js with its integrity, then import it from the same address:

<link
rel="modulepreload"
href="https://cdn.pixonaut.app/sdk/<version>/pixonaut.esm.js"
integrity="sha384-…"
crossorigin="anonymous"
/>
<script type="module">
import Pixonaut from 'https://cdn.pixonaut.app/sdk/<version>/pixonaut.esm.js';
</script>

An import takes no integrity of its own: the preload checks the file, and the import then takes the module the preload checked. A page that reads its data on a server loads only the part of the SDK it still needs: see Load only what a page uses. Without the preload, nothing checks the module before it runs; with it, a changed file is never run, and the import fails.

The CDN is the way to load the SDK: it is not published to npm. You may serve a release’s files from your own site instead, or bundle the whole SDK, the core or the UI into it. You may not copy them into a theme, plugin or package you distribute: the SDK’s licence allows them only on your own site. The SDK finds its renderer beside its own script and checks the renderer is the one it was released with before it runs any of it, so a changed renderer is refused with a WasmIntegrityError naming the file. A script served under its own name starts fetching the renderer while it is still being run, before you mount anything: see Shop themes and load time.

Each release stays served at its address, and keeps working against the live api, for as long as the releases page states: a release is served for longer than it works, and works for a while after the next one is out, so a store updates on its own schedule.

The SDK’s licence is split by part:

  • The client (pixonaut-client.js) and the TypeScript declarations are MIT-licensed (LICENSE-MIT). Keep a copy of the client from the CDN in your own code and run it anywhere, a server included; code of any licence can type-check against the declarations.
  • The core, the UI, the whole bundle and the WebAssembly renderer are proprietary (LICENSE): you may use them only together with a Pixonaut account, and may load them from the CDN or bundle them into your own site. You may not redistribute them beyond your own site, or modify, reverse engineer, decompile or disassemble them or the WebAssembly.

Both licences are served beside the SDK and are in the tarball attached to each release, with the third-party notices (THIRD_PARTY_NOTICES.md) for the open-source code it contains.

The declarations are served beside the SDK as pixonaut.d.ts, each part’s beside it (pixonaut-client.d.ts, pixonaut-core.d.ts, pixonaut-ui.d.ts), with schema.d.ts, and are in the tarball attached to each release.

Pixonaut.version is the release you loaded. The renderer must be from that same release: mount rejects with a WasmVersionMismatchError naming both versions and where the renderer came from, since a renderer from another release disagrees with the script about what it can do.

try {
await Pixonaut.mount('#pixonaut', { publishableKey: 'pk_…', templateId: 'classic-tshirt@2' });
} catch (err) {
if (err instanceof Pixonaut.WasmVersionMismatchError) {
console.error(`designer ${err.expected}, renderer ${err.found ?? 'unknown'}`);
}
}

A page with a Content Security Policy needs these directives for the designer, besides whatever it allows already:

Directive Why
script-src 'wasm-unsafe-eval' blob:, https://cdn.pixonaut.app, and the wasmUrl origin if you pass one The renderer is WebAssembly, compiled in the page. Its script is checked before it runs and then loaded from a blob: address holding exactly the checked bytes.
worker-src blob: The preview is drawn in a worker the designer starts from a blob: URL.
connect-src the apiBase origin, the fontBase origin, https://cdn.pixonaut.app, and the wasmUrl origin if you pass one The api’s calls and uploads, the template’s font files, and the renderer’s files, fetched to be checked.
img-src blob: The preview and the picture thumbnails are blob: images. The designer shows no data: image.
img-src and connect-src the origins of your mockups photos The photo is shown, and read to shade the design against it.

The designer adds no <style> element and no style attribute in markup; it sets its styles from script, and the one stylesheet it adds (see Under your theme’s styles) is built from script too, which a policy without 'unsafe-inline' allows.

The designer is an ES module whose default export is Pixonaut, or the classic script that sets window.Pixonaut. One element is all it needs: the designer draws the preview and a form generated from the template’s groups.

const designer = await Pixonaut.mount('#pixonaut', {
publishableKey: 'pk_…',
templateId: 'classic-tshirt@2',
});

mount takes a CSS selector or an element, and resolves once the template is loaded and drawn. If the designer cannot start — the template or the designer’s renderer does not load, or the placement you name is refused — mount rejects with the reason and leaves nothing of the designer on the page, so mounting again is safe. Whatever the element held before stays there until the template has loaded, so a loading message survives a template that does not; once the template is in, the designer replaces that content, and a renderer that then fails to load leaves the element empty.

A product page that shows the preview where its photo is, and the form in its summary, mounts into two elements it places itself. The designer replaces each element’s content, so give it two elements of its own, added inside your gallery and your summary, rather than the gallery and the summary themselves. What you print in them stays until the designer’s form is built (the form’s element) and its preview drawn (the preview’s), and for good if the mount fails, so a page rendered on your server can hold the designer’s place at its final size: see Pages rendered on your server. The shown event, and designer.shown(), which resolves then, say when the designer has replaced what you printed: hide a placeholder of your own there.

<div class="product-gallery"><div id="pixonaut-preview"></div></div>
<div class="product-summary">… <div id="pixonaut-form"></div> …</div>
const designer = await Pixonaut.mount({
previewTarget: '#pixonaut-preview',
formTarget: '#pixonaut-form',
publishableKey: 'pk_…',
templateId: 'classic-tshirt@2',
});

Both take a CSS selector or an element, and they must be two elements, neither inside the other. Place the form’s element outside your add-to-cart <form> where you can. If it must sit inside it, pressing Enter in one of the designer’s fields does not submit your form.

It is one designer: an edit in the form redraws the preview, onChange, approve() and commit() work as with one element, and destroy() empties both (with showForm: false, the form’s element is left as it was). The preview takes its element’s full width, at the template’s shape, and follows it as your theme resizes the element. Give that element a height in your CSS and the preview is drawn inside it, as large as fits and centred, so switching side, view, variant or photo never resizes it. The form, with the size picker and the view switcher above it, takes its element’s. Pass viewsTarget too and the view switcher is drawn into that element instead, such as under your preview, as a counted, sideways-scrolling row of square photos, --pixonaut-views-gap (12px) above it; it must be apart from the other two. You place and size both, so neither part switches to the phone layout, and fill is not offered for a split mount.

Option What it does
publishableKey Your publishable key. A mount takes this, shareLink or transport, never two.
shareLink A designer share link’s token, in place of a key: the designer reaches only the template the link was made for, and the views and variants of the blank it is sold on.
correctionLink A correction link’s token, with design, to correct a design your customer made. See Correcting a design.
design With correctionLink: the id of the design to open.
transport In place of a key: a function every request the designer makes goes through, for your server to send on with its own key. See Sending its requests through your server.
templateId The published template to load.
apiBase The address of the Pixonaut API. Without it, requests go to your page’s own origin.
fontBase Where the template’s font files are fetched (<fontBase>/f/<hash>.ttf), when that is not apiBase. Without it, they are fetched under apiBase. The first preview fetches each one cut to the template’s characters, with a text query a proxy in between must pass on.
wasmUrl Where the designer’s renderer, pixonaut_wasm.js, is loaded from. Without it, the designer loads it from beside its own script on the CDN; wherever it comes from, it must be the renderer the SDK was released with. See Loading the SDK.
showForm false hides the generated form, for headless mode.
showPickers false draws no print size picker, and none of the blank’s options, above the form. { size: false } leaves out the print size picker alone, and { variant: false } the blank’s options alone; a picker it does not name is drawn.
showVariantPicker false draws none of the blank’s options but keeps the print size picker, for a store whose own variation form chooses the item with setVariant(); the same as showPickers: { variant: false }.
showViews false draws no switcher between the product’s views, for one of your own. See Views and variants.
showPrintAreaFrame true draws a thin grey frame around the print area on the product photo. Off by default: the print sits on the photo as it will be printed. A preview that fills its element stands on its stage and has no frame either way.
showPlacementFrame true draws the placement frame, the ring and corner handles around a picture placed on the product photo, at all times. Off by default: the frame shows only while the shopper drags or sizes the picture, which they still do directly on the preview.
accent Your accent colour, #rrggbb: the view, colour or tile chosen is ringed in it, a slider is filled in it up to its thumb, and Next is drawn in it, with black or white text, whichever reads. The selection on the canvas is drawn in it too: the frame around the selected layer, its handles’ white knobs, which fill with it while pressed, and the snap guides. Your page’s own --primary or --accent never reaches the canvas. Any other value is refused when the designer is mounted. Without it, the designer draws in its own ink.
layout "page" or "steps": how the form lays out. See Page or steps.
optionsStep { title, content }: a first step holding the product’s pickers and your own fields. See Steps on your product page.
lastStepContent An element of your page, such as your price, quantity and Add to cart, shown on the last step only. See Steps on your product page.
formGroups { groups }: the template’s groups and fields in your order, renamed for this mount, with your own elements placed among them. See Steps on your product page.
previewTarget With formTarget, in place of the element mount is given: the element the preview is drawn into. See The preview and the form apart.
formTarget The element the form and its pickers are drawn into, apart from the preview.
viewsTarget With previewTarget and formTarget: the element the view switcher is drawn into, as a gallery of square photos. Without it, the switcher is drawn above the form.
markTarget The element the Powered by Pixonaut mark is drawn into, apart from the elements the designer is drawn in: an element right under the card you draw around the preview, as wide as the card. Placed far from the preview, the mark no longer counts as shown. Without it, the mark sits under the preview.
fill true makes the designer fill the one element you mount it in; not offered for the preview and the form apart. See Filling a page.
selectOnClick Clicking a layer on the canvas selects it. On by default.
snapping false turns off the template’s snap targets: a customer’s drag then snaps to nothing.
upscale false offers no customer an upscale of a photo too small for the size they chose, whatever the template’s pictures offer. { belowDpi: 150 } offers one only for a photo below 150 dpi at that size. It narrows what the template offers and never widens it: an upscale still reaches the density the picture upscales to (“Upscale to” in the builder) and no further.
previewDpi Resolution of the live preview, in dots per inch of the print. Without it, the preview is as many pixels wide as it is drawn on screen, at the screen’s pixel density, whatever the print size.
previewMaxEdgePx Cap on the preview’s longest edge, in pixels, to keep large products cheap to draw. Defaults to 1100.
onChange Called with the designer’s state whenever the customer changes something.
onPrintSizeChange Called with { index, size } when another print size is chosen, in the designer’s size picker or by setPrintSize(). See Print sizes.
onVariantChange Called with { ref } when another product variant is shown, in the designer’s pickers or by setVariant(), from the start: a variant picked before mount resolves is heard too, which on('variant') misses. The variant the product opens on is not reported.
onError Called with { action, error } for every error event, and with action: 'mount' when the mount fails, before mount rejects.
onValidation Called with the rules a design breaks when Pixonaut checks it.
messages Your own wording for any of the designer’s strings.
debug true logs to the browser console each request the designer makes and its answer, each event, the failures it otherwise keeps to itself, and hints for setting the link up (more sides than recommended), which a local host such as localhost gets without it. For diagnosing an embed; leave it off on a live store, as the lines carry the customer’s values.
locale The language of the designer’s own words, one of en, sv, de, fr, es, nl, da, nb and fi. A regional tag uses its language ("de-CH" is German, "nb-NO" Norwegian Bokmål). Any other tag, or none, is the workspace’s default language. messages overrides any string on top. The template’s dynamic values, such as {date.long}, are written in the same language, except one whose name fixes its own.
blankId The blank this design prints on. Given with placementId, never alone. See The product it prints on.
placementId The print area on that blank. Given with blankId, never alone.
panelLayout A panel layout of blankId to draw one design across, in place of placementId. See Draw across a panel layout.
placements The placements of the blank the product sells, the sides of the item, each with the template printed there and, with enabled: false, left out until the customer chooses it. See Views and variants and Sides.
sideValues "own" keeps each side’s keyed values apart; left out, a keyed value is on every side. See Sides.
linkedPlacements Which of placements one design drives, such as a sweater’s two sleeves: a preset (suggested, all, each) or groups of your own, and whether a placement may be designed on its own. See Repeat a design and link sides.
views The ids of the blank’s views the product is shown in, and 'design' for the Design view. Empty or left out, every view and the Design view.
initialView The view shown first, one of placementId’s or 'design'. Without it, or naming another, the first view of placementId with a photo.
variants The refs of the blank’s variants the product is offered in. Empty or left out, every one not retired. See Switching to another variant.
initialVariant The variant shown first, one of variants. Without it, or naming one that cannot be shown, the first offered variant with a photo on placementId when there are options to choose; each view’s default photo when there are none.
printSizes The sizes you sell this design at. See Print sizes.
selectedPrintSizeIndex Which size starts selected. Defaults to the first.
drawAtTemplateRatio true or false overrides whether the canvas keeps the template’s own shape at every print size (Templates). Left out, the template decides.
mockups Your own product photos, shown instead of the blank’s. See Show your own product photos.
initialValues The values the design starts from, as getState().values holds them. See Restoring a design in progress.

A small “Powered by Pixonaut” mark, with Pixonaut’s logo and wordmark, sits under the preview, never over it, so it is not taken for part of the print, and links to https://pixonaut.app. Drawing a card around the preview? Give the mark an element of its own under the card with markTarget. It shows on the Free and Starter plans. On Pro and Business it shows until a member of your workspace turns it off in the studio’s branding settings. The api decides it and sends the answer with the template the designer opens, so there is no mount option, class or attribute that hides it, and its own styles take precedence over your stylesheet’s. It shows in headless mode too, because the preview is still drawn. A template you mount from memory (template) has no answer from the api, so it shows the mark.

The template previews, showcases, catalogue images, proof images and print files the api renders never carry it, on any plan: show them as your own product images.

The generated form lays out in one of two ways. As a "page", every group of the template shows at once, each under a numbered heading. As "steps", the form shows one group at a time under “Step 1 of 4” and the group’s name, with Back and a “Next: Message” button naming the step that follows. Back and Next are the form’s footer: when your page gives the designer’s element a height, the step scrolls inside the form and the footer stays on its bottom edge; otherwise the footer ends the step. In a narrow column the stepper drops the step names and the counter reads “1 of 4”.

The layout option chooses. Without it, the template’s own layout decides, as its author set it, and a template with none is a page, so an embed that never passes layout looks as it always has. A group that offers the customer nothing to fill in or place is left out in both layouts, and the steps are numbered as the page numbers its headings.

Next is never blocked: a broken rule shows on its field, as it does on a page, and approve() still checks the whole design. When approve() or commit() is refused, the form moves to the first step holding a broken field, so the customer sees what to fix. The last step has no Next and no finishing button: adding to the cart or approving is your page’s button, outside the designer.

While the form is shown as steps, getStep() says which step it shows and whether it is the last, setStep(index) moves it, and optionsStep and lastStepContent draw your page’s own option fields and cart row inside the steps. See Steps on your product page.

The stepper’s words are messages like any other: form.step-counter (“Step {index} of {count}”), form.step-back (“Back”) and form.step-next (“Next: {label}”). On a form too narrow for the steps’ names the counter is form.step-counter-short (“{index} of {count}”), or your own form.step-counter when you override that one and not the short form.

The designer lays out by the width of the element you mount it in, not the screen’s. When that element is too narrow for the preview and the form side by side, the preview takes the whole width with the form under it, and the element gets data-device="phone" ("desktop" otherwise) for your own styling to follow. In steps, Back and Next stay at the bottom of the screen while the step scrolls, inside the designer, so they never cover your page’s own bars. When your page bounds the element’s height, with a height or only a max-height, the preview takes at most 45% of it, the mark under it included, narrowing for a tall plate, so the step keeps room to be filled in. How long the designer takes to load on a phone, and what of, is in Shop themes and load time.

By default the designer flows in your page like any other content. A page or window that holds nothing but the designer can pass fill: true instead. The element you mount into then needs a height. The designer fills it: the preview is centred on a stage, as large as fits, with a caption under it, and the form sits in a column beside it that scrolls by itself. On a phone the preview stays at the top and the whole form scrolls under it, the steps’ header included: unlike the embedded phone layout, the header is not lifted above the preview. The phone layout starts where the stage and the form column no longer fit side by side: at the same width as without fill, unless you make the column wider.

The stage and the form column are yours to style, through custom properties set on the element:

Property What it sets Default
--pixonaut-stage-background The stage behind the preview. transparent
--pixonaut-stage-padding Space kept clear around the preview on a desktop, as CSS padding. Reserve room here for anything of your own drawn over the stage. 24px
--pixonaut-form-width The form column’s width. 260px
--pixonaut-form-background The form column’s background. transparent

From a script tag, set the properties by the names in this table; module imports can read them from the exported FILL_PROPERTIES.

The caption is the message preview.caption (“Live preview · updates as you fill in the form”), shown on a desktop only.

A text field with a maximum length shows how much is used, as the message form.char-count (“{count} / {max}”), and, when the template gives it no help text, its limit, as form.char-limit (“Up to {max} characters”).

approve() asks Pixonaut to check the design and render its proof:

const { proofUrl, proofToken } = await designer.approve();

Show proofUrl to the customer: it is the picture they are agreeing to. A design breaking a template rule rejects with a PixonautApiError (422, code "constraint_violation") carrying the violations, which onValidation also receives, and the built-in form tells each at its field until it is changed. A required picture left out (form.violation.required) or a default kept that must change (form.violation.unchanged) is refused so before any request is sent. A text too long for its box is told sooner, as the customer types or picks a font or size.

The proof also records the variant the customer saw, and the view of the side shown, for display: your order screens can show the design on the colour and in the photo it was approved in. Neither is sent before the designer has one, and neither is checked: an order names its own variant.

So that the proof shows the design as it stands, setLocked(true) keeps the customer from changing it while the proof is made: the preview takes no gesture, not even a drag already under way, and the form takes no input. setLocked(false) hands it back, as when approve() rejects.

This is the customer approving what they made, not your shop approving the design. Hand the proofToken to your server with the cart, and your server commits the design with its secret key (a connected store, with its integration key), attaching any order details your printshop needs. The design to order guide takes it from there.

commit() checks the design and commits it in one step, with no proof for the customer to approve first, and resolves the new design’s id:

const { designId } = await designer.commit();

It is what the hosted designer page calls when the customer is done and its link has no store to return to. It rejects on a broken rule as approve() does.

To list the customer’s choices in your cart, see Listing the customer’s choices.

When Pixonaut refuses a request the designer makes itself (loading the template in mount, approve() or commit()), it rejects with a PixonautApiError. Its status is the HTTP status, so you can tell a credential that no longer works (401), a template that is gone (404) and too many requests (429) apart, its code is Pixonaut’s error code when the answer names one, and its requestId names the request when the answer carries one. A broken rule carries violations as well as its code, so tell it from any other refusal by violations, not by whether there is a code. A designer share link at its design limit is refused with the code link_design_limit, and reopensAt says when the link makes designs again (epoch milliseconds), or is null for a limit in total.

A customer who wants to change a design they have already made — from the cart, or after asking for changes — corrects that design rather than making a new one. Your server hands out a correction link for it with its secret key, or a connected store with its integration key:

POST /v1/designs/{id}/correction-links
→ 201 { "token": "…", "expiresAt": 1791900000000 }

and your page mounts the design with it:

const designer = await Pixonaut.mount('#pixonaut', {
correctionLink: token,
design: designId,
});

The link works for a couple of hours, for that design only, from the origins your publishable key allows. The design opens as it is now, at the size it was bought at, so there is no size menu, and the customer may swap in a picture of their own.

To have the page look as the product page did, pass the same product options as there: blankId, placementId, placements, views, initialView, variants, and printSizes with selectedPrintSizeIndex. The design is then drawn on the blank’s photos with the view switcher, in the box it was bought at:

const designer = await Pixonaut.mount('#pixonaut', {
correctionLink: token,
design: designId,
blankId: 'blank_apron',
placementId: 'front',
views: ['flat', 'design'],
});

The blank and placement must be the ones the design was made on, which GET /v1/designs/{id} answers as blankId and placementId; any other rejects mount() with a TypeError. A correction changes that one design, so of placements only its own side is shown. Without these options the design is drawn on its own.

approve() renders the corrected proof and changes nothing:

const { proofUrl, proofToken } = await designer.approve();

Your server makes it the design’s next revision with its secret or integration key, sending { "proofToken": "…" } to PATCH /v1/designs/{id}. The design then waits to be approved again, as after any correction. If the design was corrected elsewhere since your page opened it, that call is refused revision_changed, and the customer should start again from the design as it is now. commit() is refused on a correction.

The hosted correction page does the same without your page: see Sending the customer back to the store.

To draw your own form, mount with showForm: false and build your UI from the same steps the generated form uses. The preview is still followed by the Powered by Pixonaut mark where your workspace shows it. Build your own designer lists every method and event your UI can use:

const designer = await Pixonaut.mount('#canvas', {
publishableKey, templateId, showForm: false,
});
const { steps } = designer.getFormSchema();
for (const step of steps) {
for (const field of step.fields) {
// field.type: 'text' | 'number' | 'color' | 'select' | 'image' | 'crop'
// fieldLabel(field, msg) is the input's heading (see below):
// field.label is the prop's key when the template names none.
// field.description, when the template has one, is help text to show
// under it, as text rather than markup.
// An image field: render an upload control and call
// const uploaded = await designer.uploadAsset(file, field.layerId)
// When uploaded.improveResolution is set, the picture is short of the
// resolution its picture offers an upscale below (the `upscale` option
// may withdraw the offer) and you may offer a sharper copy, paid from
// your credits, at the size it names: the picture's "Upscale to"
// resolution, no larger. It replaces the upload and keeps the crop:
// await designer.improveResolution(uploaded.assetId, field.layerId)
// A crop field: see "Cropping a picture" below.
// Anything else:
// designer.setValue(field.layerId, field.prop, value)
// A colour is stored as the CMYK it prints as, { cmyk: [c, m, y, k] }
// in percent; a screen colour, { rgb: [r, g, b] }, is stored as the
// CMYK the press prints it as.
}
// Each layer the customer may move, rotate or resize, in step order.
for (const { layerId, transform } of step.transforms) {
// designer.selectLayer(layerId) shows its handles on the canvas.
// Each axis (transform.translate, .rotate, .scale, .resize) may carry
// the template's label and description for its control.
}
}

The generated form has no number fields for a layer’s position, rotation or size: the customer drags the layer on the canvas. A layer whose flip the template offers gets a Horizontal and a Vertical button. To offer numbers anyway, build your own inputs over setTransform:

designer.setTransform('name', { xMm: 120, rotationDeg: 10 });

It takes any of xMm, yMm, rotationDeg, scale, wMm and hMm, and flipX and flipY (true mirrors the layer left to right or top to bottom), redraws the preview and calls onChange, whose state carries the layer’s transform under values['<layerId>.transform']. That is also where to read the current numbers from after a drag. Pixonaut checks the transform against the template’s limits when the design is approved, as it does every value. It writes the values as given, with none of the clamping a drag applies, so your inputs should hold to the axes the step’s transforms in getFormSchema() offer. A size control of your own can call scaleLayer(layerId, scale) instead, which holds the scale to the layer’s bounds as its handle does.

A text layer’s offered size is a slider in the generated form, labelled with the form.font-size message unless the template names it. The transform.* messages that labelled the old number fields are gone; drop them from your messages.

A customer places a layer on the preview: a tap selects it and its handles move, rotate and resize it. A step with layers the customer may move ends with one hint saying so (form.drag-hint-one, form.drag-hint, or the author’s name for the move when the step moves one layer) and a Reset (form.drag-reset) that puts back every layer the step places, a turned or sized one too. A step whose layers may only be turned or sized has no hint. A layer whose scale is offered also gets a size slider after its fields, labelled form.scale unless the template names it. The “Place” buttons are gone, and with them form.place-step and form.place-layer; drop them from your messages.

If you translate every message, these keys are new and show in English until you add them: form.drag-hint-one, form.drag-hint, form.drag-reset, form.scale, form.step-counter-short and form.char-limit. upload.button now reads “Upload an image”.

A field the template leaves unlabelled is headed with the form’s word for what it sets, never the attribute’s internal name. Each word’s key is form.label. and the field’s prop: form.label.content, form.label.fill, form.label.color, form.label.imageAssetId, form.label.shape, form.label.src, form.label.crop, form.label.fontFamily, form.label.fontWeight, form.label.align, form.label.valign and form.label.opacity; these form.label. keys are new too. A text size keeps form.font-size. A label the template gives is shown instead. A form of your own over getFormSchema() gets the same heading from fieldLabel(field, msg), where msg answers a message key (FieldWordKey), read from your own messages. Both functions are named exports of the designer’s module:

const m = resolveMessages(myMessages);
const heading = fieldLabel(field, (key) => m[key]);

A text’s outline fields are drawn together under the heading form.group.outline (“Outline”), and its shadow fields under form.group.shadow (“Shadow”). Each heading appears only when the template offers one of its fields.

A picture whose template offers its crop has a crop field: an image layer’s, a shape layer’s picture fill’s, or the image background’s, whose field’s layerId is the artwork step’s $artwork. Its value is { xFrac, yFrac, zoom }: zoom scales the picture inside its frame, within the field’s constraints.minZoom and constraints.maxZoom (1 is the picture just covering the frame), and xFrac and yFrac move the picture’s centre right and down, in fractions of the frame’s width and height. The frame is the layer’s box, and for the background the print box without its bleed; a print adds the bleed on every side. The field’s default is the template’s crop.

designer.setValue('photo', 'crop', { xFrac: 0.1, yFrac: -0.05, zoom: 1.5 });
designer.setValue('photo', 'crop', null); // back to the template's crop
designer.setValue('$artwork', 'crop', { xFrac: 0, yFrac: 0.2, zoom: 1.2 });

A shift past the picture’s edge is drawn at the edge, so the picture always covers its frame. A zoom outside the offered range is refused when the design is approved. A new picture starts from the template’s crop: choosing a preset, uploading a picture, or setting an image’s src, a picture fill or the background’s imageAssetId sets a crop the customer made back to null.

The generated form draws the crop as a zoom slider, a control that lets the customer drag the picture inside its frame on the preview (for the background, a drag anywhere on the preview), and a reset. Your own control turns that drag on with setCropMode({ kind: 'layer', layerId }) or setCropMode({ kind: 'background' }), off with setCropMode(null), and hears it change with on('crop', …). While that control is on, the frame takes focus and the arrow keys move the picture too (Shift for a larger step). When a zoom spreads an uploaded picture’s pixels too thinly for the minimum print resolution at the chosen size (the template read’s printFloorDpi: the template’s own floor, else the workspace’s), the crop field repeats the warning.asset.low-resolution message. Its words are the form.crop-zoom, form.crop-zoom-value, form.crop-move, form.crop-done, form.crop-reset and form.crop-surface messages; the last names the frame for assistive technology.

getFormSchema() returns the same schema the API serves for the template, with the author’s layout when the template sets one. getState() returns the template id, the customer’s values and the chosen print size, so you can keep a design in progress. A picture still uploading writes its value when the upload ends: await designer.settled() first when you keep the values as the customer leaves. destroy() removes the designer from the page.

To mount the designer again with what the customer entered, after a page reload or for a product you mount anew, pass the values you kept from getState() as initialValues:

const kept = designer.getState().values;
// …later, or on the next page…
const again = await Pixonaut.mount('#designer', { ...options, initialValues: kept });

Once mount() resolves, the values are on the form, in the preview and in getState(), as they were when you kept them, a picture’s crop included; onChange is not called for them. A value for a layer the template no longer has is left out, with a line in the console naming it.

values holds the template’s values by layer id. A value the customer set on a setting with a field key is under values.$fields, by its key, instead; the values of other templates the customer looked at with setTemplate() are under values.$templates, by template id. Keep values whole, and both come back with it.

Handles can reach a little past the canvas edge. If the canvas sits inside a scrolling container, give that container enough padding for the rotate handle above the canvas, or the handles are cut off at its edge.

Name the blank and the placement on it that the design is for:

blankId: 'blank_tee',
placementId: 'front',

The proof is then fitted to that placement, and is the print the customer gets: its size, bleed, safe area and print method. approve() and commit() both send the pair, and a design committed from the proof token records it without your server naming it again.

The live preview is fitted to the same placement: it is drawn at the size the proof prints at, with the placement’s bleed and safe area, and the checks it runs as the customer types and drags use that box too. What the customer sees while designing is framed as the proof they approve.

Give both or neither. A placement that does not exist, or a blank you do not hold, is refused when the designer mounts: mount() rejects with an PixonautApiError whose code is unknown_placement, before the customer has entered anything. Without either, the design is fitted to the template’s own shape, with no product.

A blank’s photos are grouped into views, each one way of photographing one placement (“Flat”, “On model”), with a photo per colour. The designer shows the design on them: it reads the blank’s views and variants when it mounts and draws the photo of the view shown behind the design.

Say what your product sells: the placements, each with the template printed there, and, if not all of them, the views:

const designer = await Pixonaut.mount('#designer', {
publishableKey: 'pk_…',
templateId: 'logo-front@3',
blankId: 'blank_tee',
placementId: 'front',
placements: [
{ placementId: 'front' },
{ placementId: 'back', templateId: 'logo-back@1' },
],
views: ['front-flat', 'back-flat'],
initialView: 'front-flat',
});
  • placementId is the one the designer opens on, with templateId, and must be one of placements. A placement that names no template prints the mount’s templateId. Without placements, the product sells placementId alone.
  • views lists the views shown, by id, as GET /v1/blanks/{id}/mockups answers them. Empty or left out, every view of the placements the product sells, and the Design view. A placement it does not sell gets no view.
  • initialView is the view shown first, one of placementId’s: the designer opens on that placement. Without it, or naming a view of another placement, the first view of placementId with a photo. To open on the back, name the back as placementId.

The designer draws a strip of thumbnails, one per view, each showing the customer’s design on that view at thumbnail size, drawn again once their edits settle. Clicking one shows that view; the strip is keyboard-operable, and each thumbnail is labelled with its view’s label. With one view, no strip is drawn.

The Design view follows the photographed views: the customer’s artwork alone, on a transparency checker, with no photo behind it. Its id is 'design' (DESIGN_VIEW_ID), its label the message views.design (“Design”). It shows the side the designer is on, and stays as it is when the customer picks another colour. To leave it out, name the views you want in views without it; to keep it beside named views, name 'design' too. When the blank has a view of its own with the id design, that view is shown and there is no Design view.

await designer.setView('back-flat');

A view of another placement switches the designer to that side, with its own template and its own values: what the customer entered in a setting with a field key is on it too, and the rest of what they entered stays on the side they entered it on. setView() resolves once the view is shown, and on("view") reports it. A view the product does not show, or one with no photo of the variant shown, rejects with a TypeError.

An approve() still waiting for its proofs when the side shown switches rejects with a PlacementChangedError, rather than resolving proofs of a design the customer has since moved on from; ask for the proofs again, and drop any proofToken you received before the switch. A designer opened on a design to correct keeps the placement the design was made for.

The placements a product sells are the sides of one item: a tee’s front and back, a sign’s two faces. The designer holds a design for each side, with its own template and its own values, and shows one at a time:

await designer.setSide('back');

setSide() shows the side in its first view, as a view of it does, and on("side") reports { placementId } whichever switched it. setTemplate() switches the template of the side shown; the other sides keep theirs.

A keyed value is the item’s. A name typed on the front is on the back wherever the back’s template has a setting with the same field key, as one shirt carries one name. Pass sideValues: 'own' to keep each side’s keyed values apart instead. A value in a setting without a key stays on its side either way, also when both sides print the same template.

getSides() lists every side, in placements order:

[
{ placementId: 'front', templateId: 'logo-front@3', enabled: true, valid: true, linked: null },
{ placementId: 'back', templateId: 'logo-back@1', enabled: true, valid: false, linked: null },
]

valid judges each side’s design against its own template in the browser, as the form judges the side shown, so a name too long for the back’s box marks the back while the customer types on the front. Every side’s template is read when the designer mounts, and mount() resolves once they are.

A side can be optional. A product sold single- or double-sided leaves the back out with enabled: false on its placements entry, and turns it on and off with the customer’s choice:

designer.setSideEnabled('back', doubleSided.checked);

A side left out keeps its values, is not proofed and is not committed. The last side enabled cannot be left out: setSideEnabled() throws a TypeError, as does a mount whose placements enable none.

approve() and commit() take every enabled side. approve() resolves one proof per side under sides, in placements order, with the first side’s proof on top, so a product with one side reads its proof as it always has:

const { proofUrl, sides } = await designer.approve();
// sides: [{ placementId: 'front', proofUrl, proofToken, … }, { placementId: 'back', … }]

commit() does the same with designs: one per side, for one order of the item, the first side’s first. When any side’s design breaks a rule, they reject with that side’s PixonautApiError, whose placementId names it; with several refused, the first in placements order. onValidation hears only the side shown’s violations, which its form marks; show the side named to let the customer fix the rest. commit() checks every side before it commits any.

The state names the side shown as placementId, and holds the other sides under $sides in its values, each on the template it was last shown in. To mount the designer again where the customer left it, pass both back:

const state = designer.getState();
// later
await Pixonaut.mount('#designer', {
...options,
placementId: state.placementId,
templateId: state.templateId,
initialValues: state.values,
});

When the customer picks another variation of your product (another colour), switch the designer to the blank’s variant for it, by its ref from GET /v1/blanks/{id}/variants:

await designer.setVariant('v_black_m');

Every view then shows that colour’s photo, and on("variant") reports the switch. A view with no photo of the variant is hidden while it is shown; if it was the view shown, the first view with a photo is shown instead. A colour is a variant of the one blank, never another blank, so the design, the placement and the print stay as they were.

On a mount with a blank, the designer draws a row above the form for each of the blank’s options with two values or more, in the blank’s order. How a row looks follows its values’ appearances, which you set on the blank, never the option’s name: swatches when any value has one (a flat colour, two colours half and half, or the value’s picture; a value without one is a plain circle with its initial), with “Colour · Navy” naming the one chosen; text chips otherwise. Each row shows 20 values, then “+N more”. Choosing a value switches the variant as setVariant() does, keeping the other options’ values when a variant has them; a value with no photo in any view is faded and cannot be chosen, unless no variant has one (all are offered then). variants narrows the variants offered; initialVariant names the one the designer opens on (without it, the first offered with a photo), whose values show as chosen. For your own picker, pass showPickers: false (or showVariantPicker: false, to keep the print size menu) and read getOptions(), each option’s values with their appearance ({ kind: "fill", colours }, { kind: "image", url } or null), and getVariants(), each variant with its value on each option, hasPhoto, and printable, false at a size the design can’t be printed at (a cut-and-sewn XL whose panel is another shape), which the default picker disables. A variant’s colour and size are deprecated.

setVariant() rejects with a VariantUnavailableError, and the variant shown stays, for a variant the designer cannot show. Its reason says why:

reason Why
no_photo No view the product shows has a photo of it, though some have a photo of another variant. Treat the variation as unavailable on your page.
variant_retired The variant is retired: no new design or order may name it.
unknown_variant The ref is not one of the blank’s variants.
not_offered The mount’s variants do not name it.

Preloading the other variants, views and sides

Section titled “Preloading the other variants, views and sides”

The designer loads a photo, and a side’s template and pictures, when the customer first switches to it. To make switching instant, it can load them ahead: preload: 'background' one at a time in idle time once the first view is shown, 'eager' all at once, 'off' nothing, and 'auto' (the default) one of those chosen from an estimate and the device. Preloading spends the customer’s memory, bandwidth and CPU, and may slow the page on a low-end device; auto chooses from an estimate, since a page cannot measure its own memory. See Make switching instant for the modes, preload(), cancelPreload(), preloadPlan() and on("preload").

With showViews: false the designer draws no strip, and your page draws its own from the calls the strip is made of:

const views = designer.getViews();
// [{ viewId: 'front-flat', label: 'Flat', placement: 'front', hasPhoto: true,
// photoUrl: 'https://…/front-flat-white.jpg', dye: null, kind: 'photo' }, …,
// { viewId: 'design', label: 'Design', placement: null, hasPhoto: true, photoUrl: null, dye: null, kind: 'design' }]
for (const view of views.filter((v) => v.hasPhoto)) {
const png = await designer.renderView(view.viewId, { maxEdgePx: 200 });
thumbnail(view).src = URL.createObjectURL(png);
}
designer.on('view', ({ viewId }) => markShown(viewId));
designer.on('variant', () => redrawThumbnails());
  • getViews() returns each view the product shows, for the variant shown: its id, label and placement, whether it has a photo of the variant, that photo’s url (photoUrl, null without one), the colour a view drawn from a 3D render dyes its garment in (dye, null for none), and its kind: 'photo', or 'design' for the Design view, which needs no photo and is always hasPhoto: true. Hide one with hasPhoto: false. A variant leaving a view’s photoUrl and dye the same (another size) changes nothing it shows.
  • getView() is the view shown, and getVariant() the variant setVariant() named last (undefined before).
  • renderView(viewId, { variant, maxEdgePx }) resolves with a PNG of that view with the customer’s design on it: in variant’s photo (the variant shown when left out), its longest edge maxEdgePx pixels (240 when left out). The design is the preview as it is drawn at that moment, shaded for the photo shown, and is drawn flat onto the view’s photo; draw your thumbnails again once the customer’s edits settle, on change. A picture asked for before the preview’s first frame waits for it. A variant of another tone is drawn unshaded, with its colour’s values. A view of another side is drawn with that side’s design as last shown, and before it has been shown, with its photo alone. The Design view’s PNG is the design alone and unshaded on a transparent ground, the same for every variant: draw a checker behind it yourself. It rejects as setView() does, and when the photo cannot be read: your own photos must be served with CORS headers.

setMockup(), getMockup(), selectedMockupIndex and onMockupChange are gone: a photo is named by its view and the variant, never by its place in a list. So is setPlacement(): a colour is a variant of the one blank, and a placement is reached through its view.

When your store lets the customer try another template, setTemplate() switches the designer to it, carrying what they entered by field key, and renderTemplate() draws any template with their values for a gallery of your own: see Offer several templates.

Pass the sizes you sell, in your own order and with your own labels:

printSizes: [
{ widthMm: 300, heightMm: 400, label: '30 × 40' },
{ widthMm: 500, heightMm: 700, label: '50 × 70' },
],
selectedPrintSizeIndex: 1,

The designer draws a picker, and marks the sizes the template’s aspect ratio and tolerance refuse. Labels are shown exactly as you give them. The chosen size goes with the proof, and is the size the design is printed at.

On the blank’s photos the design is drawn at the chosen size: a size larger than the placement’s usual one reaches past the usual area, as far as it will print. Your own photos (mockups) mark only where the print goes, so the design fills that area at any size.

In headless mode, getPrintSizes() returns each size with legal and, when refused, a reason; setPrintSize(index) chooses one. The browser checks a size against the template only, not against the maximum print area of a product. To check both before a customer starts, ask the API whether the size is legal for the template and placement.

Omit printSizes, or pass a single size, and no picker is drawn; a single size still goes with the proof.

A size picked in the designer’s picker is heard by onPrintSizeChange with { index, size }. A store whose prices differ by size drives the size from its own variation form instead, with setPrintSize(index) and showPickers: { size: false }.

For photos of your own rather than the blank’s, see Show your own product photos.

Questions? Write to [email protected].

Status · Terms of service · Privacy policy · Subprocessors