A browser-based side-by-side bride and groom outfit configurator, built as a proof-of-concept to validate layered SVG avatar rendering, outfit switching, and coordinated couple palette application.
Live Demo: outfits.anmious.cloud
🎯 What It Does
The configurator lets you customize two avatars simultaneously:
- Skin tone — light, medium, dark
- Body type — petite to plus-size (bride), lean to stocky (groom)
- Height — short through very tall, with realistic proportions
- Hair style and color — updo, short bob, long straight (bride); multiple buzz cuts and spikes (groom)
- Outfits — Western, Indian, and Casual categories per avatar
- Glasses toggle — overlaid frame layer
- Couple palettes — curated coordinated color sets (Classic White, Blush & Rose, Sage Garden, Midnight Blue, Golden Hour)
- Manual color override — per-avatar primary color picker
- Export to PNG — download the full couple preview as a retina-quality image
🏗️ Architecture: Why Layered SVGs?
The Core Idea
Each avatar is rendered as a stack of independent SVG layer components, all sharing the same viewBox="0 0 200 450" coordinate space:
Layer 1: Outfit (ball gown, tuxedo, lehenga, sherwani, etc.)
Layer 2: Skin (face, neck, hands, body details)
Layer 3: Hair (style + color)
Layer 4: Glasses (optional overlay)
All four layers are composited inside a single <svg> element. Because every layer uses identical viewBox coordinates, they align perfectly with zero offset math.
Why not use pre-made images or sprites?
- Color is dynamic — outfit primary/secondary/accent colors are user-controlled. SVG
fillprops make this trivial. - Body scaling —
scaleXfor body type andscaleYfor height are applied via a single SVGtransform, broadcasting through all layers at once. - Composability — adding a new outfit means writing one new React component. No image editing pipeline.
🗂️ Project Structure
src/
components/
AvatarRenderer/ # Stacks SVG layers into the final avatar
ControlPanel/ # Per-avatar skin, body type, outfit, glasses controls
PalettePicker/ # Curated couple palette selector
OutfitSelector/ # Outfit category + outfit variant picker
config/
outfits.ts # Outfit definitions (layers, color targets)
palettes.ts # Curated couple palette definitions
avatarOptions.ts # Skin tones, body types, heights, hair styles/colors
defaults.ts # Default couple configuration
assets/
bride/ # Bride SVG layer components
groom/ # Groom SVG layer components
types/
avatar.ts # Core TypeScript types
state/
useConfiguratorStore.ts # Zustand store
pages/
Home.tsx # Main single-page layout
🧩 Step 1: Define the Types
Start with a clear TypeScript shape for the avatar configuration:
export type AvatarType = "bride" | "groom";
export interface AvatarColors {
primary: string;
secondary: string;
accent: string;
}
export interface AvatarConfig {
avatarType: AvatarType;
skinToneId: string;
bodyTypeId: string;
heightId: string;
hairStyleId: string;
hairColorId: string;
beardStyleId?: string;
outfitCategory: string;
outfitId: string;
glasses: boolean;
colors: AvatarColors;
}
export interface CoupleConfig {
bride: AvatarConfig;
groom: AvatarConfig;
paletteId?: string;
}
💡 AI Prompt:
I'm building a couple outfit configurator with layered SVG avatars.
Each avatar has: skin tone, body type, height, hair style, hair color,
optional beard, outfit (with primary/secondary/accent colors), and glasses.
Design the TypeScript types for AvatarConfig and CoupleConfig.
🎨 Step 2: Write SVG Layer Components
Each layer is a React component that accepts color props and returns SVG elements (not a full <svg> tag):
// src/assets/bride/BallGownLayer.tsx
export function BallGownLayer({ primary, secondary, accent }: OutfitLayerProps) {
return (
<g>
{/* Bodice */}
<path
d="M 68 114 Q 100 106, 132 114 L 127 182 Q 100 190, 73 182 Z"
fill={primary}
/>
{/* Skirt */}
<path
d="M 73 180 Q 100 190, 127 180 L 180 443 Q 100 452, 20 443 Z"
fill={primary}
/>
{/* Skirt inner layer for depth */}
<path
d="M 80 188 Q 100 196, 120 188 L 162 434 Q 100 444, 38 434 Z"
fill={secondary}
opacity="0.65"
/>
{/* Waist sash */}
<path
d="M 73 180 Q 100 186, 127 180 L 127 188 Q 100 194, 73 188 Z"
fill={accent}
opacity="0.85"
/>
</g>
);
}
Key practices:
- Use
<g>as the root, never<svg>— the parentAvatarRendererowns the root<svg> - All coordinates in 0–200 (x) / 0–450 (y) — consistent coordinate space across all layers
- Semantic comments — group logical parts (
Bodice,Skirt,Hem trim) for maintainability - Shadows via rgba — subtle depth with
fill="rgba(0,0,0,0.06)"overlays
💡 AI Prompt:
Create an SVG React component for a bride's ball gown outfit.
ViewBox is 0 0 200 450. The avatar torso starts around y=114.
The skirt should flare out dramatically to the sides by y=443.
Accept primary, secondary, and accent color props.
Use subtle opacity layers for depth. Return a <g> group, not a full <svg>.
🧱 Step 3: Build the AvatarRenderer
The renderer resolves which layer components to use, calculates the SVG transform, and composites everything:
export function AvatarRenderer({ config, label }: Props) {
const isBride = config.avatarType === "bride";
const OutfitLayer = isBride
? brideOutfitLayerMap[config.outfitId]
: groomOutfitLayerMap[config.outfitId];
const skinColor = skinTones.find(s => s.id === config.skinToneId)?.color ?? "#FDDCB5";
const hairColor = hairColors.find(h => h.id === config.hairColorId)?.color ?? "#3D2512";
const heightScale = heightScales[config.heightId] ?? 1.0;
const bodyScale = (isBride ? brideBodyScales : groomBodyScales)[config.bodyTypeId] ?? 1.0;
// Scales around bottom-center (100, 450) so feet stay grounded
const svgTransform = `translate(100,450) scale(${bodyScale},${heightScale}) translate(-100,-450)`;
return (
<div className="relative rounded-2xl overflow-hidden shadow-lg" style={{ width: 200, height: 450 }}>
<svg viewBox="0 0 200 450" width="200" height="450">
<ellipse cx="100" cy="446" rx="68" ry="7" fill="rgba(0,0,0,0.10)" />
<g transform={svgTransform}>
<OutfitLayer primary={primary} secondary={secondary} accent={accent} />
<SkinLayerComponent skinColor={skinColor} hairColor={hairColor} beardStyleId={config.beardStyleId} />
<HairLayerComponent hairStyleId={config.hairStyleId} hairColor={hairColor} />
{config.glasses && <GlassesLayerComponent />}
</g>
</svg>
</div>
);
}
The scaling trick: By transforming around (100, 450) (bottom-center), the avatar scales vertically from the feet up — so taller avatars grow upward, not downward.
🗃️ Step 4: State Management with Zustand
A single flat Zustand store keeps the entire couple configuration:
export const useConfiguratorStore = create<ConfiguratorStore>((set) => ({
couple: defaultCoupleConfig,
updateAvatar: (avatarType, updates) =>
set((state) => ({
couple: {
...state.couple,
[avatarType]: { ...state.couple[avatarType], ...updates },
},
})),
applyPalette: (paletteId) =>
set((state) => {
const palette = palettes.find(p => p.id === paletteId);
if (!palette) return state;
return {
couple: {
...state.couple,
paletteId,
bride: { ...state.couple.bride, colors: palette.bride },
groom: { ...state.couple.groom, colors: palette.groom },
},
};
}),
}));
Zustand is ideal here: no boilerplate, direct mutations, and the store is consumed with simple selectors:
const config = useConfiguratorStore(s => s.couple[activeTab]);
const updateAvatar = useConfiguratorStore(s => s.updateAvatar);
🎨 Step 5: Curated Couple Palettes
Each palette defines coordinated bride and groom color sets:
export const palettes: PaletteDefinition[] = [
{
id: "classic-white",
label: "Classic White",
bride: { primary: "#FFFFFF", secondary: "#F5F0EB", accent: "#C9A96E" },
groom: { primary: "#1A1A2E", secondary: "#2C2C4A", accent: "#C9A96E" },
},
{
id: "blush-rose",
label: "Blush & Rose",
bride: { primary: "#F2B8C6", secondary: "#FAE0E8", accent: "#C47A8A" },
groom: { primary: "#3D2B2B", secondary: "#5C3D3D", accent: "#C47A8A" },
},
// ...
];
Shared accent values visually tie the couple together — a small but impactful detail.
📤 Step 6: Export to PNG
For the export feature, the SVGs are serialized, rendered into a Canvas at 2x resolution for retina quality, and downloaded:
async function exportAvatarsToImage(container: HTMLDivElement) {
const svgs = container.querySelectorAll<SVGSVGElement>("svg");
const canvas = document.createElement("canvas");
canvas.width = canvasW * 2;
canvas.height = canvasH * 2;
const ctx = canvas.getContext("2d")!;
ctx.scale(2, 2);
for (const svg of svgs) {
const serialized = new XMLSerializer().serializeToString(svg);
const blob = new Blob([serialized], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(blob);
await new Promise<void>(resolve => {
const img = new Image();
img.onload = () => { ctx.drawImage(img, x, y, 200, 450); resolve(); };
img.src = url;
});
}
const link = document.createElement("a");
link.download = "couple-outfit.png";
link.href = canvas.toDataURL("image/png");
link.click();
}
🚀 Deployment
The app is a static Vite build deployed via Docker + Nginx behind Nginx Proxy Manager:
Dockerfile:
FROM node:18-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
docker-compose.yml joins the npm_default network to route through Nginx Proxy Manager:
services:
couple-outfit-configurator:
build: .
restart: unless-stopped
networks:
- npm_default
networks:
npm_default:
external: true
🧠 Key Learnings
- Shared viewBox is the foundation — every layer living in the same coordinate space eliminates all positional arithmetic.
- Scale from the anchor point —
translate(cx,cy) scale(sx,sy) translate(-cx,-cy)is the standard SVG pattern for scaling around an arbitrary point. - Outfit layer goes first — it renders below the skin layer so shoulders/neckline are covered correctly by the outfit, not floating under the skin.
- Subtle shadows with rgba overlays — a
fill="rgba(0,0,0,0.06)"path on face contours and neck gives depth without needing actual shading logic. - Zustand over Context — for a local configurator with multiple sibling controls reading the same state, Zustand avoids the prop-drilling and re-render complexity of Context.
🔮 What’s Next (Beyond POC)
- AI-suggested palette based on uploaded photo
- More outfit categories (beach, formal, casual)
- 3D avatar rendering
- Shareable links with encoded configuration
- Printable invitation card integration
Tech Stack
| Tool | Purpose |
|---|---|
| React 18 + TypeScript | UI framework |
| Tailwind CSS | Styling |
| Zustand | State management |
| Vite | Build tool |
| SVG (inline React) | Avatar rendering |
| Docker + Nginx | Deployment |
| Nginx Proxy Manager | Reverse proxy + SSL |