Skip to content

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.

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:

  • color is 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.
  • finish is the texture: matte, satin, gloss or shimmer. Gloss adds highlights taken from the real light on the lips; shimmer adds fine sparkle.
  • opacity runs 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.foundation with a coverage of 0.5 to 0.7 and a low opacity.
  • 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-Origin for .json, .glb and .png files, or models and images fail to load.
  • Serve .glb as model/gltf-binary and 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, use mount() from @tryonit/web and JSON.parse the 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.json do 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.

More articles

Have an app in mind?

Tell me what you're building. I reply to every serious enquiry within two working days.