Skip to content

Showcase

Each storefront below is built from the methods and events of Build your own designer, and from nothing else: the page draws its own form, options and buttons around the canvas, and looks as its shop does. Each is run as printed here, in a browser, against Pixonaut.

publishableKey and blankId are yours, and the template ids are your templates’.

A sign sold in two materials, single- or double-sided. The material decides the print size, and double-sided prints the back too, with a tab to design it. The text fields are the page’s own, drawn for the side shown:

<div id="canvas"></div>
<form id="options">
<fieldset>
<legend>Material</legend>
<label><input type="radio" name="material" value="0" checked> Poster paper, 24 × 30 cm</label>
<label><input type="radio" name="material" value="1"> Foam board, 40 × 50 cm</label>
</fieldset>
<fieldset>
<legend>Sides</legend>
<label><input type="radio" name="sides" value="single" checked> Single-sided</label>
<label><input type="radio" name="sides" value="double"> Double-sided</label>
</fieldset>
<div id="side-tabs" hidden>
<button type="button" data-side="front">Front</button>
<button type="button" data-side="back">Back</button>
</div>
<div id="fields"></div>
<button id="approve" type="button">Approve</button>
<p id="error" role="alert"></p>
</form>
<div id="proofs"></div>
const designer = await Pixonaut.mount('#canvas', {
publishableKey,
blankId,
templateId: 'sign-front@1',
placementId: 'front',
placements: [
{ placementId: 'front' },
{ placementId: 'back', templateId: 'sign-back@1', enabled: false },
],
// A size per material, in the order of the material options.
printSizes: [
{ widthMm: 240, heightMm: 300, label: '24 × 30 cm' },
{ widthMm: 400, heightMm: 500, label: '40 × 50 cm' },
],
showForm: false,
showViews: false,
});
const options = document.querySelector('#options');
// The material decides the print size.
for (const material of options.elements.material) {
material.addEventListener('change', () => designer.setPrintSize(Number(material.value)));
}
// Double-sided prints the back too, and offers its tab.
const tabs = document.querySelector('#side-tabs');
for (const sides of options.elements.sides) {
sides.addEventListener('change', async () => {
const double = sides.value === 'double';
designer.setSideEnabled('back', double);
tabs.hidden = !double;
if (!double) await designer.setSide('front');
});
}
for (const tab of tabs.querySelectorAll('[data-side]')) {
tab.addEventListener('click', () => designer.setSide(tab.dataset.side));
}
// The text fields of the side shown, drawn again for the other side.
const fields = document.querySelector('#fields');
const drawFields = () => {
const texts = designer
.getFormSchema()
.steps.flatMap((step) => step.fields)
.filter((field) => field.type === 'text');
fields.replaceChildren(
...texts.map((field) => {
const label = document.createElement('label');
const input = document.createElement('input');
input.name = field.layerId;
input.value = designer.getValue(field.layerId, field.prop) ?? '';
input.addEventListener('input', () =>
designer.setValue(field.layerId, field.prop, input.value));
label.append(field.label, input);
return label;
}),
);
};
designer.on('side', drawFields);
drawFields();
// A proof of each side printed, or the side that cannot be.
const error = document.querySelector('#error');
document.querySelector('#approve').addEventListener('click', async () => {
error.textContent = '';
try {
const { sides } = await designer.approve();
document.querySelector('#proofs').replaceChildren(
...sides.map(({ placementId, proofUrl }) => {
const proof = document.createElement('img');
proof.alt = `Proof of the ${placementId}`;
proof.src = proofUrl;
return proof;
}),
);
} catch (refusal) {
error.textContent = `The ${refusal.placementId ?? 'design'} cannot be printed as it is.`;
}
});

The shop’s own colours, type and buttons, a box per text with what it says, its font, its colour and its size, and the canvas’s handles shown only to a customer who asks to adjust rotation and size:

<style>
.boutique { font-family: Georgia, serif; background: #f6f1e9; color: #3d2b1f; padding: 24px; border-radius: 12px; }
.boutique fieldset { border: 1px solid #c9b8a0; border-radius: 8px; margin: 12px 0; }
.boutique button { background: #3d2b1f; color: #f6f1e9; border: 0; border-radius: 999px; padding: 8px 24px; }
</style>
<div class="boutique">
<div id="canvas"></div>
<label><input id="adjust" type="checkbox"> Adjust rotation and size</label>
<div id="texts"></div>
<button id="approve" type="button">Looks good</button>
<p id="error" role="alert"></p>
<img id="proof" alt="Your proof" hidden>
</div>
const designer = await Pixonaut.mount('#canvas', {
publishableKey,
templateId: 'boutique-card@1',
showForm: false,
showViews: false,
});
const { steps } = designer.getFormSchema();
// Handles only behind the checkbox.
const adjust = document.querySelector('#adjust');
const layout = () => {
for (const { layerId } of steps.flatMap((step) => step.transforms)) {
designer.setInteractive(layerId, adjust.checked);
}
};
adjust.addEventListener('change', layout);
layout();
// One control per setting of a text: its words, font, colour or size.
const control = (field) => {
const set = (value) => designer.setValue(field.layerId, field.prop, value);
if (field.prop === 'fontFamily') {
const fonts = document.createElement('select');
fonts.append(...field.constraints.oneOf.map((font) => new Option(font, font)));
fonts.value = field.default;
fonts.addEventListener('change', () => set(fonts.value));
return fonts;
}
const input = document.createElement('input');
if (field.prop === 'fill') {
input.type = 'color';
input.addEventListener('change', () => {
const hex = input.value.slice(1);
set({ rgb: [0, 2, 4].map((at) => Number.parseInt(hex.slice(at, at + 2), 16)) });
});
} else if (field.prop === 'fontSizeFrac') {
const { min, max, step } = field.constraints;
Object.assign(input, { type: 'range', min, max, step, value: field.default });
input.addEventListener('input', () => set(Number(input.value)));
} else {
input.value = field.default;
input.addEventListener('input', () => set(input.value));
}
return input;
};
// A box per text, its settings inside.
const fields = steps.flatMap((step) => step.fields);
document.querySelector('#texts').replaceChildren(
...fields
.filter((field) => field.prop === 'content')
.map((text) => {
const box = document.createElement('fieldset');
const legend = document.createElement('legend');
legend.textContent = text.label;
box.append(legend);
for (const field of fields.filter((f) => f.layerId === text.layerId)) {
const label = document.createElement('label');
const input = control(field);
input.name = `${field.layerId}.${field.prop}`;
label.append(field.label, input);
box.append(label);
}
return box;
}),
);
document.querySelector('#approve').addEventListener('click', async () => {
const proof = document.querySelector('#proof');
const error = document.querySelector('#error');
error.textContent = '';
designer.setMode('preview');
try {
proof.src = (await designer.approve()).proofUrl;
proof.hidden = false;
} catch {
designer.setMode('edit');
error.textContent = 'This card cannot be printed as it is. Check its texts.';
}
});

Nothing but the browser’s own controls, one step at a time: the customer’s photo with a progress bar and a word on its quality, a strip of templates drawn with that photo and the name typed, and a table of the texts, each with its colour, an outline and a shadow, a rotation and a zoom slider and a reset:

<div id="canvas"></div>
<section data-step="photo">
<h2>1. Your photo</h2>
<input id="photo" type="file" accept="image/*">
<progress id="upload" max="1" value="0"></progress>
<p id="quality"></p>
<p id="error" role="alert"></p>
</section>
<section data-step="design" hidden>
<h2>2. Choose a design</h2>
<div id="templates"></div>
</section>
<section data-step="texts" hidden>
<h2>3. Your texts</h2>
<table>
<thead><tr><th>Text</th><th>Colour</th><th>Outline</th><th>Shadow</th><th>Rotate</th><th>Zoom</th><th></th></tr></thead>
<tbody id="texts"></tbody>
</table>
<button id="approve" type="button">Approve</button>
<p id="refused" role="alert"></p>
<img id="proof" alt="Your proof" hidden>
</section>
<button id="back" type="button">Back</button>
<button id="next" type="button">Next</button>
const templates = [
{ id: 'plain-portrait@1', label: 'Portrait' },
{ id: 'plain-banner@1', label: 'Banner' },
];
const designer = await Pixonaut.mount('#canvas', {
publishableKey,
templateId: templates[0].id,
printSizes: [{ widthMm: 400, heightMm: 500, label: '40 × 50 cm' }],
showForm: false,
showViews: false,
});
const fields = () => designer.getFormSchema().steps.flatMap((step) => step.fields);
// Step 1: the photo, its upload's progress, and whether it prints sharp.
const bar = document.querySelector('#upload');
const photo = document.querySelector('#photo');
const quality = document.querySelector('#quality');
const error = document.querySelector('#error');
photo.addEventListener('change', async () => {
const { layerId, prop } = fields().find((field) => field.type === 'image');
quality.textContent = '';
error.textContent = '';
bar.value = 0;
try {
await designer.uploadAsset(photo.files[0], layerId, prop);
const resolution = designer.getResolution(layerId);
quality.textContent = resolution?.low
? `Low quality: ${resolution.effectiveDpi} dpi at this size.`
: 'Good quality.';
} catch {
error.textContent = 'This photo could not be uploaded. Try another one.';
}
});
designer.on('progress', ({ sentBytes, totalBytes }) => {
bar.value = sentBytes / totalBytes;
});
// Step 2: each template drawn with what the customer gave so far.
const strip = document.querySelector('#templates');
const drawStrip = () => {
const { templateId, values } = designer.getState();
strip.replaceChildren(
...templates.map(({ id, label }) => {
const button = document.createElement('button');
const picture = document.createElement('img');
button.type = 'button';
picture.alt = '';
button.append(picture, label);
button.setAttribute('aria-pressed', String(id === templateId));
button.addEventListener('click', async () => {
await designer.setTemplate(id);
drawStrip();
});
designer.renderTemplate(id, { maxEdgePx: 120, values }).then((png) => {
picture.src = URL.createObjectURL(png);
picture.addEventListener('load', () => URL.revokeObjectURL(picture.src), { once: true });
});
return button;
}),
);
};
// Step 3: a row per text of the template chosen: its words, its colour, its
// outline's colour and width, its shadow's colour and softness, each where
// the template offers it. The colours given are kept by the page, to show
// them again when the table is drawn.
const table = document.querySelector('#texts');
const colours = new Map();
const drawTable = () => {
const transforms = designer.getFormSchema().steps.flatMap((step) => step.transforms);
const offered = (layerId, prop) =>
fields().find((f) => f.layerId === layerId && f.prop === prop);
table.replaceChildren(
...fields()
.filter((field) => field.prop === 'content')
.map((text) => {
const { layerId } = text;
const axes = transforms.find((t) => t.layerId === layerId)?.transform ?? {};
const placed = designer.getState().values[`${layerId}.transform`] ?? {};
const cell = (...inputs) => {
const td = document.createElement('td');
for (const input of inputs.filter(Boolean)) {
input.name = `${layerId}.${input.name}`;
td.append(input);
}
return td;
};
const words = Object.assign(document.createElement('input'), {
name: 'content',
value: designer.getValue(layerId, 'content') ?? '',
});
words.addEventListener('input', () => designer.setValue(layerId, 'content', words.value));
// A colour the template offers, sent as RGB; the designer stores the
// CMYK it prints as.
const colour = (prop) => {
if (!offered(layerId, prop)) return null;
const key = `${layerId}.${prop}`;
const input = Object.assign(document.createElement('input'), {
name: prop, type: 'color', value: colours.get(key) ?? '#000000',
});
input.addEventListener('change', () => {
colours.set(key, input.value);
const hex = input.value.slice(1);
designer.setValue(layerId, prop, {
rgb: [0, 2, 4].map((at) => Number.parseInt(hex.slice(at, at + 2), 16)),
});
});
return input;
};
// A length the template offers, within the range it offers.
const length = (prop) => {
const field = offered(layerId, prop);
if (!field) return null;
const { min, max, step } = field.constraints;
const input = Object.assign(document.createElement('input'), {
name: prop, type: 'range', min, max, step: step ?? 'any',
value: designer.getValue(layerId, prop),
});
input.addEventListener('input', () =>
designer.setValue(layerId, prop, Number(input.value)));
return input;
};
const slider = (name, axis, value) =>
axis
? Object.assign(document.createElement('input'), {
name, type: 'range', min: axis.min, max: axis.max, step: 'any', value,
})
: null;
const rotate = slider('rotate', axes.rotate, placed.rotationDeg ?? 0);
rotate?.addEventListener('input', () =>
designer.setTransform(layerId, { rotationDeg: Number(rotate.value) }));
const zoom = slider('zoom', axes.scale, placed.scale ?? 1);
zoom?.addEventListener('input', () => designer.scaleLayer(layerId, Number(zoom.value)));
const reset = Object.assign(document.createElement('button'), {
name: 'reset', type: 'button', textContent: 'Reset',
});
reset.addEventListener('click', () => {
designer.resetLayer(layerId);
for (const key of colours.keys()) {
if (key.startsWith(`${layerId}.`)) colours.delete(key);
}
drawTable();
});
const row = document.createElement('tr');
row.append(
cell(words),
cell(colour('fill')),
cell(colour('outlineColor'), length('outlineWidthEm')),
cell(colour('shadowColor'), length('shadowBlurEm')),
cell(rotate),
cell(zoom),
cell(reset),
);
return row;
}),
);
};
// One step shown at a time; each is drawn as it is entered.
const steps = [...document.querySelectorAll('[data-step]')];
const draw = { design: drawStrip, texts: drawTable };
let shown = 0;
const show = (by) => {
shown = Math.min(Math.max(shown + by, 0), steps.length - 1);
steps.forEach((step, at) => {
step.hidden = at !== shown;
});
draw[steps[shown].dataset.step]?.();
};
document.querySelector('#back').addEventListener('click', () => show(-1));
document.querySelector('#next').addEventListener('click', () => show(1));
document.querySelector('#approve').addEventListener('click', async () => {
const proof = document.querySelector('#proof');
const refused = document.querySelector('#refused');
refused.textContent = '';
try {
proof.src = (await designer.approve()).proofUrl;
proof.hidden = false;
} catch {
refused.textContent = 'This design cannot be printed as it is. Check your texts.';
}
});

A tee printed on both sides, with a tab for each side, a strip of templates under the tabs drawn with what the customer typed, arrows that step through the product’s views, a double-sided option and an Add to cart button. The canvas and the generated form are the designer’s; everything around them is the page’s own:

<div role="tablist">
<button role="tab" data-side="front">Front</button>
<button role="tab" data-side="back">Back</button>
</div>
<div id="gallery"></div>
<div id="canvas"></div>
<button id="previous">Previous view</button>
<button id="next">Next view</button>
<label><input id="double-sided" type="checkbox" checked> Print the back too</label>
<button id="add-to-cart">Add to cart</button>
const templates = {
front: [
{ id: 'tee-front-name@1', label: 'Big name' },
{ id: 'tee-front-badge@1', label: 'Badge' },
],
back: [
{ id: 'tee-back-number@1', label: 'Number' },
{ id: 'tee-back-team@1', label: 'Name and team' },
],
};
const designer = await Pixonaut.mount('#canvas', {
publishableKey,
blankId,
templateId: templates.front[0].id,
placementId: 'front',
placements: [
{ placementId: 'front' },
{ placementId: 'back', templateId: templates.back[0].id },
],
showViews: false,
});
// The templates of the side shown, each drawn with what the customer typed;
// a click redraws the canvas in the one chosen, keeping it.
const gallery = document.querySelector('#gallery');
const drawGallery = () => {
const { placementId, templateId, values } = designer.getState();
gallery.replaceChildren(
...templates[placementId].map(({ id, label }) => {
const button = document.createElement('button');
const picture = document.createElement('img');
picture.alt = '';
button.append(picture, label);
button.setAttribute('aria-pressed', String(id === templateId));
button.addEventListener('click', () => designer.setTemplate(id));
designer.renderTemplate(id, { maxEdgePx: 120, values }).then((png) => {
picture.src = URL.createObjectURL(png);
picture.addEventListener('load', () => URL.revokeObjectURL(picture.src), { once: true });
});
return button;
}),
);
};
// A tab per side, marked when that side's design breaks a rule.
const tabs = document.querySelectorAll('[data-side]');
const markTabs = () => {
const shown = designer.getState().placementId;
for (const tab of tabs) {
const side = designer.getSides().find((s) => s.placementId === tab.dataset.side);
tab.setAttribute('aria-selected', String(tab.dataset.side === shown));
tab.classList.toggle('invalid', !side.valid);
}
};
for (const tab of tabs) {
tab.addEventListener('click', () => designer.setSide(tab.dataset.side));
}
// The strip is drawn again once the customer's edits settle, not on every
// keystroke.
let redraw;
designer.on('change', () => {
markTabs();
clearTimeout(redraw);
redraw = setTimeout(drawGallery, 400);
});
drawGallery();
markTabs();
// The arrows step through every view; a view of the other side shows it.
const step = (by) => {
const views = designer.getViews().filter((view) => view.hasPhoto);
const at = views.findIndex((view) => view.viewId === designer.getView());
designer.setView(views[(at + by + views.length) % views.length].viewId);
};
document.querySelector('#previous').addEventListener('click', () => step(-1));
document.querySelector('#next').addEventListener('click', () => step(1));
document.querySelector('#double-sided').addEventListener('change', (event) => {
designer.setSideEnabled('back', event.target.checked);
markTabs();
});
// One design per side, ordered together as one shirt.
document.querySelector('#add-to-cart').addEventListener('click', async () => {
const { sides } = await designer.commit();
await addToCart(sides.map(({ placementId, designId }) => ({ placementId, designId })));
});

addToCart is your storefront’s: your server makes a print of each design and places one order holding them (POST /v1/orders with every print, the first side’s first), as one item printed on two sides.

Questions? Write to [email protected].

Status · Terms of service · Privacy policy · Subprocessors