Writing
How to Create Virtual Try-On Products with JSON Manifests
A practical guide to TryOnIt asset manifests: lipstick shades, makeup looks, face stickers, variants, CORS hosting, CMS or Shopify loading and CI validation.
Arbab Naseer Published 7 min read
- Virtual try-on
- JSON Schema
- E-commerce
- Shopify
- Validation
One of the first design decisions in TryOnIt was that products are data, not code. A store should be able to add a lipstick, a beauty filter or a pair of glasses without writing rendering code or waiting for a developer. So every try-on product is a small JSON file, the asset manifest, plus one file for the products that need it.
This guide covers how manifests work, recipes for the most common products, and how to host, load and validate them. 3D products have their own rules for models, which I cover in Preparing 3D Models for AR Try-On.
What each kind of product needs
| You want | type |
Files you provide |
|---|---|---|
| Lipstick, gloss, blush, eyeshadow, eyeliner, brows, foundation | makeup.* |
none, only colours |
| A beauty filter (complete makeup look) | makeup.look |
none |
| Hair colour | hair.color |
none |
| Fun filters, stickers, masks, moustaches, crowns | face.overlay2d |
1 PNG with a transparent background |
| Glasses, hats, earrings, watches, rings | glasses, hat, earrings, watch, ring |
1 GLB model in millimetres |
| T-shirts and tops (experimental) | clothing.top |
1 PNG and 4 anchor points |
Makeup, beauty filters and hair colour need no files at all. A colour and a few numbers describe the whole product.
Anatomy of a manifest
Every manifest shares a small set of common fields:
| Field | Required | Description |
|---|---|---|
version |
yes | Always 1, the manifest format version. |
id |
yes | Unique id, used for caching and for selecting the default product. |
type |
yes | Selects the tracker and renderer, and which other fields are valid. |
name |
no | Display name in the product switcher and dialog. |
thumbnail |
no | Image for the product switcher, square and about 128 px. |
variants |
no | Shades, colours or styles. |
defaultVariantId |
no | Must match a variant id. Defaults to the first variant. |
meta |
no | Your own data, such as SKU and price. Passed through, never read. |
The type field is a discriminator: a makeup.lips manifest accepts color, opacity and finish, while a glasses manifest accepts model, transform and occlusion. Unknown top-level keys are stripped and reported in debug mode, which is where typos show up; unknown keys inside variant overrides are rejected outright.
Colours accept #RGB, #RGBA, #RRGGBB, #RRGGBBAA, rgb(r, g, b) and rgba(r, g, b, a). Strengths such as opacity, size, thickness and coverage range from 0 to 1.
Add one line to the top of any manifest to get autocomplete and inline errors in VS Code:
"$schema": "https://unpkg.com/@tryonit/core/dist/manifest.v1.schema.json"
That JSON Schema ships inside @tryonit/core, and a unit test keeps it in sync with the runtime validator, so the editor and the runtime never disagree.
Recipe: a lipstick with shades
{
"$schema": "https://unpkg.com/@tryonit/core/dist/manifest.v1.schema.json",
"version": 1,
"id": "velvet-lipstick",
"type": "makeup.lips",
"name": "Velvet Lipstick",
"color": "#B0123A",
"finish": "satin",
"opacity": 0.65,
"variants": [
{ "id": "ruby", "name": "Ruby", "swatch": "#B0123A" },
{ "id": "nude", "name": "Nude", "swatch": "#C48A7A", "overrides": { "color": "#C48A7A", "finish": "matte" } },
{ "id": "coral", "name": "Coral Gloss", "swatch": "#E2584D", "overrides": { "color": "#E2584D", "finish": "gloss" } }
],
"meta": { "sku": "LIP-001", "price": 19 }
}
Three fields do most of the work:
coloris the pigment. Real lipstick usually looks 10 to 15 percent darker on lips than on the bullet, so start from your swatch and adjust while testing on light, medium and deep skin tones.finishis the texture:matte,satin,glossorshimmer. Gloss adds highlights taken from the real light on the lips; shimmer adds fine sparkle.opacityruns from sheer (around 0.3) to full coverage (around 0.85). The default is 0.6.
Because the product has variants, shoppers get a shade picker automatically. Teeth are never coloured, even when the shopper smiles.
Recipe: a beauty filter
A “beauty filter” is simply a makeup.look: several makeup layers in one product. Layers are always drawn in a natural order (foundation, blush, brows, eyeshadow, eyeliner, lips), whatever order you write them in, so you cannot accidentally put foundation over lipstick.
{
"version": 1,
"id": "filter-soft-glam",
"type": "makeup.look",
"name": "Soft Glam",
"layers": [
{ "type": "makeup.foundation", "color": "#D9A98A", "opacity": 0.3, "coverage": 0.5 },
{ "type": "makeup.blush", "color": "#D9707A", "opacity": 0.3 },
{ "type": "makeup.brows", "color": "#4A3426", "opacity": 0.35 },
{ "type": "makeup.eyeshadow", "color": "#9C6B3C", "opacity": 0.45, "finish": "shimmer" },
{ "type": "makeup.eyeliner", "color": "#1A1A1A", "thickness": 0.3, "wing": true },
{ "type": "makeup.lips", "color": "#B0123A", "finish": "gloss" }
]
}
A few tips from tuning looks:
- For a smooth-skin effect that does not change skin tone, use
makeup.foundationwith acoverageof 0.5 to 0.7 and a lowopacity. - The intensity slider in the UI scales every layer at once, so one look covers both subtle and bold.
- Offer several looks as separate products and let shoppers switch between them:
<TryOn assets={[natural, softGlam, night]} />.
Hair colour works the same way, with hair.color, a color and an opacity. The original strand pattern and shine are preserved; very dark hair needs a higher opacity (0.65 to 0.8) to show light colours.
Recipe: face stickers and fun filters
Stickers (face.overlay2d) are PNG images that follow the face, moving, scaling and tilting with the head. Design the artwork facing the camera, keep the background transparent, crop tightly and export at 512 to 1024 px wide. Draw it exactly as shoppers should see it: TryOnIt handles the mirrored front camera, so text and logos read correctly in the preview and in captured photos.
The anchor decides where the centre of the image goes:
anchor |
Placed at | Good for |
|---|---|---|
forehead |
middle of the forehead | crowns, headbands, tiaras |
eyes |
between the eyes | 2D glasses, eye masks |
noseBridge (default) |
top of the nose | masks, glasses-shaped stickers |
noseTip |
tip of the nose | clown, cat or dog noses |
mouth |
centre of the mouth | moustaches, beards |
chin |
chin | beards, bow ties (with offset) |
leftCheek, rightCheek |
cheeks | face paint, hearts, flags |
Size and position are relative to the face, so a sticker fits every face without per-person tuning. scale is the image width relative to the face width (a moustache is about 0.5, a crown about 1.1), and offset moves it in fractions of the face width:
{
"version": 1,
"id": "filter-moustache",
"type": "face.overlay2d",
"name": "Moustache",
"image": "moustache.png",
"anchor": "mouth",
"scale": 0.55,
"offset": [0, -0.09]
}
One product shows one image. For a multi-part filter such as dog ears and a nose, combine the parts into a single PNG in their correct relative positions, anchor it at noseBridge and use a larger scale (about 1.6).
Variants: shades, colours and styles
Any product can have variants. Each variant has an id, an optional name, a swatch colour for the picker, an optional thumbnail, and overrides containing any fields of that product type, including files:
"variants": [
{ "id": "black", "name": "Matte Black", "swatch": "#111111" },
{ "id": "tortoise", "name": "Tortoise", "swatch": "#7B4A2A", "overrides": { "model": "frames-tortoise.glb" } }
],
"defaultVariantId": "black"
The validator enforces three rules: variant ids are unique, defaultVariantId matches one of them, and overrides only contains fields that exist for the product type.
Hosting your files
- Keep each manifest next to its files and use relative paths such as
"model": "aviator.glb". Paths resolve against the manifest’s URL, including inside variant overrides, so a whole folder can move to a CDN unchanged. - Enable CORS for files on another domain: the server must send
Access-Control-Allow-Originfor.json,.glband.pngfiles, or models and images fail to load. - Serve
.glbasmodel/gltf-binaryand use long cache headers (Cache-Control: public, max-age=31536000, immutable) when file names change between versions.
A layout that scales well:
public/tryon/
lipstick-velvet.json
aviator/
aviator.json
aviator.glb
aviator-black.glb
aviator.svg
filters/
moustache.json
moustache.png
Loading products from a backend, CMS or Shopify
Every component accepts a product in three forms:
<TryOnButton asset={manifestObject} /> // a JavaScript object
<TryOnButton asset="https://cdn.example.com/tryon/aviator.json" /> // a URL
<TryOnButton asset={(signal) => fetch(`/api/tryon/${sku}`, { signal }).then((r) => r.json())} /> // your API
The loader form receives an AbortSignal that cancels the request when the shopper switches products quickly, and every result is validated and cached by id and URL.
- A custom API or headless CMS can store the manifest JSON, or build it from existing product fields, and return it from an endpoint.
- Shopify can store the manifest in a product metafield of type JSON (for example
tryon.manifest), output it in the theme or a Hydrogen loader, and pass the object to the component. In a classic theme without React, usemount()from @tryonit/web andJSON.parsethe metafield value. - React Native apps using @tryonit/react-native read the same manifests. Pass an object, an absolute
https://URL or a loader; relative paths like/assets/x.jsondo not exist in an app.
Type-safe manifests in TypeScript
All manifest types are exported, so building products in code gets full checking:
import type { AssetManifest, LipsAsset } from '@tryonit/core';
export const ruby = {
version: 1,
id: 'ruby',
type: 'makeup.lips',
color: '#B0123A',
finish: 'satin',
} satisfies LipsAsset;
export function lipstickFromCatalog(product: { sku: string; hex: string; name: string }): AssetManifest {
return { version: 1, id: product.sku, type: 'makeup.lips', name: product.name, color: product.hex };
}
Generating manifests from an existing product catalogue is often the fastest way to bring a whole makeup range online.
Validate before you ship
The tryonit-validate command ships inside @tryonit/core. The -p flag lets npx run it even if your app only installed @tryonit/react:
npx -p @tryonit/core tryonit-validate public/tryon # every manifest in a folder
npx -p @tryonit/core tryonit-validate public/tryon/aviator/aviator.json
It checks every field against the schema and, for relative paths, that the GLB and PNG files exist, are real GLB and PNG files, are not too large, and that stickers and garments actually have transparency. It exits with code 1 on errors, so it drops straight into CI.
In code, the non-throwing validator returns readable, path-based issues:
import { safeValidateManifest, formatIssues } from '@tryonit/core';
const result = safeValidateManifest(json);
if (!result.success) console.error(formatIssues(result.issues));
Invalid asset "ruby":
- variants[2].color: Expected a color like #RRGGBB or rgb(r, g, b)
At runtime, an invalid product raises an error with the code ASSET_INVALID and the same messages, which you can log from onError.
Troubleshooting 2D products
| Problem | Fix |
|---|---|
| Lipstick looks too strong or flat | Lower opacity, pick a slightly lighter color, or change finish. |
| A sticker has a white box around it | The PNG has no transparency. Export it with a transparent background. |
| A sticker sits in the wrong place | Pick a closer anchor, then adjust offset in small steps of about 0.05. |
| An image does not load | Check the browser console for CORS errors and add Access-Control-Allow-Origin. |
| Nothing appears | Enable debug to see landmarks and the FPS HUD, and read the error code from onError. |
With manifests in place, a product page needs a single line:
import { TryOnButton } from '@tryonit/react';
import '@tryonit/react/styles.css';
<TryOnButton asset="/tryon/velvet-lipstick.json" />;
The packages are on npm as @tryonit/core and @tryonit/react.