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 fill props make this trivial.
  • Body scalingscaleX for body type and scaleY for height are applied via a single SVG transform, 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 parent AvatarRenderer owns 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

  1. Shared viewBox is the foundation — every layer living in the same coordinate space eliminates all positional arithmetic.
  2. Scale from the anchor pointtranslate(cx,cy) scale(sx,sy) translate(-cx,-cy) is the standard SVG pattern for scaling around an arbitrary point.
  3. Outfit layer goes first — it renders below the skin layer so shoulders/neckline are covered correctly by the outfit, not floating under the skin.
  4. 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.
  5. 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

ToolPurpose
React 18 + TypeScriptUI framework
Tailwind CSSStyling
ZustandState management
ViteBuild tool
SVG (inline React)Avatar rendering
Docker + NginxDeployment
Nginx Proxy ManagerReverse proxy + SSL