# Blob Mascot

A friendly blob character in SVG: eight body shapes, sixteen expressions and a twelve-colour palette, plus a Mascot Builder that lets people design one and download it as SVG or PNG. Free; animate it with Blob Mascot Motion.

**Category:** Display  
**Exports:** `MascotBuilder`  
**npm dependencies:** motion, lucide-react (+ clsx, tailwind-merge for `cn`)

## Install

```bash
npx shadcn@latest add https://charcoalui.com/r/blob-mascot.json
```
The file lands at `components/ui/blob-mascot.tsx`; import from `@/components/ui/blob-mascot`.

## API — Blob Mascot
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `shape` | `MascotShape` | `"circle"` | "circle" | "pebble" | "squircle" | "capsule" | "triangle" | "hexagon" | "cloud" | "droplet" |
| `expression` | `MascotExpression` | `"neutral"` | "neutral" | "attentive" | "surprised" | "excited" | "happy" | "laughing" | "angry" | "sad" | "scared" | "suspicious" | "confused" | "curious" | "proud" | "shy" | "unimpressed" | "sleepy" |
| `color` | `MascotColor \| string` | `"ink"` | A palette name or any CSS colour. |
| `eyeColor` | `string` | — | Picked for contrast when left out. |
| `size` | `number` | `160` |  |
| `label` | `string` | — | Accessible name; decorative when left out. |
| `shadow` | `boolean` | `true` |  |
| `className` | `string` | — |  |

## API — Sixteen expressions
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `expression` | `MascotExpression` | — |  |

## API — Mascot Builder
Pick a shape, a face and a colour, then download a PNG or SVG or copy the JSX.

| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| `defaultValue` | `Partial<{ shape; expression; color }>` | — |  |
| `onChange` | `(config) => void` | — |  |
| `fileName` | `string` | `"mascot"` |  |
| `className` | `string` | — |  |

## Example — Blob Mascot

```tsx
import { BlobMascot, type MascotColor, type MascotExpression, type MascotShape } from "@/components/ui/blob-mascot";

export default function BlobMascotDemo({ shape = "circle", expression = "happy", color = "blue", size = 180 }: { shape?: MascotShape; expression?: MascotExpression; color?: MascotColor; size?: number }) {
  return <BlobMascot shape={shape} expression={expression} color={color} size={size} label={`${expression} mascot`} />;
}
```

More examples: “Sixteen expressions”, “Mascot Builder” — call get_component_examples.

## Source — components/ui/blob-mascot.tsx

```tsx
"use client";

import * as React from "react";
import { motion, type MotionStyle, type TargetAndTransition, type Transition } from "motion/react";
import { Check, Copy, Download } from "lucide-react";
import { cn } from "@/lib/utils";

/* ------------------------------------------------------------------------------------------------ data */

export const MASCOT_SHAPES = ["circle", "pebble", "squircle", "capsule", "triangle", "hexagon", "cloud", "droplet"] as const;
export const MASCOT_EXPRESSIONS = ["neutral", "attentive", "surprised", "excited", "happy", "laughing", "angry", "sad", "scared", "suspicious", "confused", "curious", "proud", "shy", "unimpressed", "sleepy"] as const;
export const MASCOT_COLORS = {
  ink: "#16161a",
  brown: "#8b5e3c",
  red: "#e5484d",
  orange: "#f76b15",
  amber: "#ffb224",
  green: "#46a758",
  turquoise: "#12a594",
  blue: "#0285f7",
  purple: "#8e4ec6",
  pink: "#e93d82",
  grey: "#8b8d98",
  cream: "#f5ead6",
} as const;

export type MascotShape = (typeof MASCOT_SHAPES)[number];
export type MascotExpression = (typeof MASCOT_EXPRESSIONS)[number];
export type MascotColor = keyof typeof MASCOT_COLORS;

type Geometry = { d: string; face: [number, number]; spread: number; scale: number; shine: [number, number, number] };

// Bodies are drawn in a 200×210 box resting on y≈186; `face` is where the eyes sit, `shine` a soft highlight.
const SHAPES: Record<MascotShape, Geometry> = {
  circle: { d: "M100 27a78 78 0 1 1 0 156a78 78 0 1 1 0-156z", face: [100, 102], spread: 22, scale: 1, shine: [66, 62, -35] },
  pebble: { d: "M100 34c48 0 82 28 82 72c0 44-36 78-86 78c-46 0-78-30-78-72c0-46 34-78 82-78z", face: [100, 106], spread: 24, scale: 1, shine: [62, 66, -30] },
  squircle: { d: "M100 30c62 0 76 14 76 76s-14 78-76 78s-76-16-76-78s14-76 76-76z", face: [100, 104], spread: 24, scale: 1, shine: [58, 58, -40] },
  capsule: { d: "M68 66h64a52 52 0 0 1 0 104H68a52 52 0 0 1 0-104z", face: [100, 114], spread: 26, scale: 0.92, shine: [52, 90, -20] },
  triangle: { d: "M100 30c8 0 14 4 19 12l64 112c8 15-1 31-18 31H35c-17 0-26-16-18-31L81 42c5-8 11-12 19-12z", face: [100, 132], spread: 18, scale: 0.82, shine: [80, 78, -60] },
  hexagon: { d: "M100 26c5 0 9 1 13 3l55 32c8 5 13 13 13 22v48c0 9-5 17-13 22l-55 32c-8 4-18 4-26 0l-55-32c-8-5-13-13-13-22V83c0-9 5-17 13-22l55-32c4-2 8-3 13-3z", face: [100, 106], spread: 23, scale: 0.96, shine: [62, 64, -30] },
  cloud: { d: "M58 184c-26 0-44-19-44-43c0-20 13-36 32-41c2-30 26-52 56-52c24 0 44 14 52 35c26 2 44 22 44 49c0 29-22 52-51 52z", face: [100, 124], spread: 24, scale: 0.96, shine: [82, 70, -15] },
  droplet: { d: "M100 22s70 70 70 112c0 32-31 52-70 52s-70-20-70-52c0-42 70-112 70-112z", face: [100, 134], spread: 21, scale: 0.9, shine: [76, 104, -40] },
};

type Eye = { kind: "oval" | "happy" | "closed"; rx?: number; ry?: number; dy?: number; lid?: [angle: number, depth: number]; pupil?: number };
type Mouth = "smile" | "grin" | "open" | "o" | "frown" | "flat" | "squiggle" | "smug";
type Face = { left: Eye; right?: Eye; mouth?: Mouth; blush?: number; look?: [number, number] };

// Lids cut the top of an eye along a line: angle > 0 drops toward the nose (angry), < 0 toward the ear (sad).
const FACES: Record<MascotExpression, Face> = {
  neutral: { left: { kind: "oval", rx: 8, ry: 13 } },
  attentive: { left: { kind: "oval", rx: 9, ry: 15, dy: -2 }, look: [0, -2] },
  surprised: { left: { kind: "oval", rx: 11, ry: 15 }, mouth: "o" },
  excited: { left: { kind: "oval", rx: 10, ry: 15 }, mouth: "grin", blush: 0.35 },
  happy: { left: { kind: "happy" }, mouth: "smile", blush: 0.5 },
  laughing: { left: { kind: "happy", rx: 11 }, mouth: "open", blush: 0.55 },
  angry: { left: { kind: "oval", rx: 9, ry: 12, lid: [24, 5] }, mouth: "frown" },
  sad: { left: { kind: "oval", rx: 8, ry: 12, dy: 2, lid: [-20, 4] }, mouth: "frown", look: [0, 3] },
  scared: { left: { kind: "oval", rx: 11, ry: 15, pupil: 3.5 }, mouth: "squiggle" },
  suspicious: { left: { kind: "oval", rx: 9, ry: 13, lid: [0, 9] }, mouth: "flat", look: [5, 0] },
  confused: { left: { kind: "oval", rx: 8, ry: 13 }, right: { kind: "oval", rx: 7, ry: 9, dy: 2 }, mouth: "squiggle", look: [-3, -2] },
  curious: { left: { kind: "oval", rx: 9, ry: 14 }, right: { kind: "oval", rx: 10, ry: 16, dy: -2 }, mouth: "o", look: [4, -3] },
  proud: { left: { kind: "closed" }, mouth: "smug", look: [0, -3] },
  shy: { left: { kind: "oval", rx: 6, ry: 9, dy: 3 }, mouth: "smile", blush: 0.8, look: [-2, 4] },
  unimpressed: { left: { kind: "oval", rx: 9, ry: 13, lid: [0, 13] }, mouth: "flat" },
  sleepy: { left: { kind: "oval", rx: 8, ry: 12, lid: [-8, 15] } },
};

const MOUTHS: Record<Mouth, { d: string; fill?: boolean }> = {
  smile: { d: "M-9 -1Q0 7 9 -1" },
  grin: { d: "M-11 -3Q0 13 11 -3Z", fill: true },
  open: { d: "M-12 -4Q0 18 12 -4Z", fill: true },
  o: { d: "M0 -5a4 5 0 1 0 0.01 0Z", fill: true },
  frown: { d: "M-8 4Q0 -3 8 4" },
  flat: { d: "M-7 1H7" },
  squiggle: { d: "M-11 1q3-4 6 0t6 0t6 0" },
  smug: { d: "M-9 1Q1 6 10 -3" },
};

/** A colour name from the palette, or any CSS colour. */
export const mascotColor = (c: string) => (c in MASCOT_COLORS ? MASCOT_COLORS[c as MascotColor] : c);

/** Ink-coloured eyes on light bodies, white eyes on everything else. */
function eyeColorFor(fill: string) {
  const m = fill.match(/^#([0-9a-f]{6})$/i);
  if (!m) return "#ffffff";
  const n = parseInt(m[1], 16);
  const lum = (0.2126 * ((n >> 16) & 255) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255;
  return lum > 0.62 ? "#16161a" : "#ffffff";
}

/** Whether an expression draws a mouth (talking needs one). */
export const mascotHasMouth = (e: MascotExpression) => !!FACES[e].mouth;

/** Where the face sits for a shape, in the 200×210 viewBox, so overlays can line up with it. */
export const mascotGeometry = (shape: MascotShape) => SHAPES[shape];

/* ------------------------------------------------------------------------------------------------ mascot */

/** Motion props for one layer of the mascot, so an animation layer can move it without redrawing it. */
export type MascotLayer = { animate?: TargetAndTransition; transition?: Transition; style?: MotionStyle; initial?: TargetAndTransition | false };

export type BlobMascotProps = {
  shape?: MascotShape;
  expression?: MascotExpression;
  /** A palette name (ink, blue, pink…) or any CSS colour. */
  color?: MascotColor | (string & {});
  /** Colour of the eyes and mouth; picked for contrast when left out. */
  eyeColor?: string;
  size?: number;
  /** Accessible name; the mascot is decorative when left out. */
  label?: string;
  /** Show the soft ground shadow. */
  shadow?: boolean;
  /** Motion for the body, face, eyes and mouth. Used by Blob Mascot Motion; leave out for a still mascot. */
  layers?: { body?: MascotLayer; face?: MascotLayer; leftEye?: MascotLayer; rightEye?: MascotLayer; mouth?: MascotLayer };
  /** Extra SVG drawn over the mascot in viewBox units (200 × 210). */
  overlay?: React.ReactNode;
  /** Replaces the expression's eyes, for winks and blinks. */
  eyes?: { left?: "open" | "closed"; right?: "open" | "closed" };
  className?: string;
};

/** A friendly blob character drawn in SVG: eight body shapes, sixteen expressions and a twelve-colour palette, with eyes cut by lids for angry, sad and sleepy looks. */
export const BlobMascot = React.forwardRef<SVGSVGElement, BlobMascotProps>(function BlobMascot(
  { shape = "circle", expression = "neutral", color = "ink", eyeColor, size = 160, label, shadow = true, layers, overlay, eyes, className },
  ref,
) {
  const id = React.useId().replace(/:/g, "");
  const g = SHAPES[shape];
  const face = FACES[expression];
  const fill = mascotColor(color);
  const ink = eyeColor ?? eyeColorFor(fill);
  const [cx, cy] = g.face;
  const [lx, ly] = face.look ?? [0, 0];

  const eye = (spec: Eye, side: "l" | "r", forced?: "open" | "closed") => {
    const e: Eye = forced === "closed" ? { kind: "closed" } : spec;
    const rx = e.rx ?? 9, ry = e.ry ?? 13;
    if (e.kind === "happy" || e.kind === "closed") {
      const d = e.kind === "happy" ? `M${-rx} 4Q0 ${-12} ${rx} 4` : `M${-rx} -1Q0 8 ${rx} -1`;
      return <path d={d} fill="none" stroke={ink} strokeWidth={5} strokeLinecap="round" />;
    }
    const clip = e.lid ? `${id}-${side}` : undefined;
    return (
      <>
        {e.lid && (
          <clipPath id={clip}>
            <rect x={-30} y={-ry + e.lid[1]} width={60} height={60} transform={`rotate(${e.lid[0]} 0 ${-ry + e.lid[1]})`} />
          </clipPath>
        )}
        <g clipPath={clip ? `url(#${clip})` : undefined}>
          <ellipse rx={rx} ry={ry} fill={ink} />
          {e.pupil && <circle r={e.pupil} cx={1} cy={2} fill={fill} />}
        </g>
      </>
    );
  };

  const place = (side: "l" | "r") => {
    const spec = (side === "r" ? face.right : undefined) ?? face.left;
    const x = cx + (side === "l" ? -g.spread : g.spread) * g.scale + lx;
    const y = cy + (spec.dy ?? 0) + ly;
    const layer = side === "l" ? layers?.leftEye : layers?.rightEye;
    return (
      <g transform={`translate(${x} ${y}) scale(${side === "l" ? g.scale : -g.scale} ${g.scale})`}>
        <motion.g animate={layer?.animate} initial={layer?.initial} transition={layer?.transition} style={{ transformBox: "fill-box", originX: 0.5, originY: 0.5, ...layer?.style }}>
          {eye(spec, side, eyes?.[side === "l" ? "left" : "right"])}
        </motion.g>
      </g>
    );
  };

  const mouth = face.mouth && MOUTHS[face.mouth];
  return (
    <svg ref={ref} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 210" width={size} height={size * 1.05} role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true} className={cn("shrink-0 overflow-visible", className)}>
      {shadow && <ellipse cx={100} cy={196} rx={58} ry={7} fill="#000" opacity={0.12} />}
      <motion.g animate={layers?.body?.animate} initial={layers?.body?.initial} transition={layers?.body?.transition} style={{ transformBox: "fill-box", originX: 0.5, originY: 1, ...layers?.body?.style }}>
        <path d={g.d} fill={fill} />
        <ellipse cx={g.shine[0]} cy={g.shine[1]} rx={16} ry={9} transform={`rotate(${g.shine[2]} ${g.shine[0]} ${g.shine[1]})`} fill="#fff" opacity={0.16} />
        <motion.g animate={layers?.face?.animate} initial={layers?.face?.initial} transition={layers?.face?.transition} style={layers?.face?.style}>
          {face.blush ? (
            <g fill="#ff7a9a" opacity={face.blush}>
              <ellipse cx={cx - g.spread * g.scale - 9} cy={cy + 17 * g.scale} rx={8 * g.scale} ry={4.5 * g.scale} />
              <ellipse cx={cx + g.spread * g.scale + 9} cy={cy + 17 * g.scale} rx={8 * g.scale} ry={4.5 * g.scale} />
            </g>
          ) : null}
          {place("l")}
          {place("r")}
          {mouth && (
            <g transform={`translate(${cx + lx * 0.6} ${cy + 27 * g.scale + ly * 0.6}) scale(${g.scale})`}>
              <motion.path
                d={mouth.d}
                fill={mouth.fill ? ink : "none"}
                stroke={ink}
                strokeWidth={mouth.fill ? 2 : 4}
                strokeLinecap="round"
                strokeLinejoin="round"
                animate={layers?.mouth?.animate}
                transition={layers?.mouth?.transition}
                style={{ transformBox: "fill-box", originX: 0.5, originY: 0.5, ...layers?.mouth?.style }}
              />
            </g>
          )}
        </motion.g>
      </motion.g>
      {overlay}
    </svg>
  );
});

/* ------------------------------------------------------------------------------------------------ builder */

export type MascotConfig = { shape: MascotShape; expression: MascotExpression; color: MascotColor };

export type MascotBuilderProps = {
  defaultValue?: Partial<MascotConfig>;
  onChange?: (config: MascotConfig) => void;
  /** File name for downloads, without extension. */
  fileName?: string;
  className?: string;
};

const title = (s: string) => s[0].toUpperCase() + s.slice(1);

function download(blob: Blob, name: string) {
  const a = document.createElement("a");
  a.href = URL.createObjectURL(blob);
  a.download = name;
  a.click();
  setTimeout(() => URL.revokeObjectURL(a.href), 1000);
}

/** A mascot maker: pick a shape, an expression and a colour with live previews, then download it as SVG or PNG, or copy the JSX. */
export function MascotBuilder({ defaultValue, onChange, fileName = "mascot", className }: MascotBuilderProps) {
  const [cfg, setCfg] = React.useState<MascotConfig>({ shape: "circle", expression: "happy", color: "blue", ...defaultValue });
  const [tab, setTab] = React.useState<"shape" | "face" | "colour">("shape");
  const [copied, setCopied] = React.useState(false);
  const svg = React.useRef<SVGSVGElement>(null);
  const tabs = React.useRef<HTMLDivElement>(null);

  const set = (patch: Partial<MascotConfig>) => {
    const next = { ...cfg, ...patch };
    setCfg(next);
    onChange?.(next);
  };
  const markup = () => {
    const el = svg.current!.cloneNode(true) as SVGSVGElement;
    el.setAttribute("width", "1024");
    el.setAttribute("height", "1075");
    el.removeAttribute("class");
    return new XMLSerializer().serializeToString(el);
  };
  const png = () => {
    const img = new Image();
    img.onload = () => {
      const c = document.createElement("canvas");
      c.width = 1024;
      c.height = 1075;
      c.getContext("2d")!.drawImage(img, 0, 0, 1024, 1075);
      c.toBlob((b) => b && download(b, `${fileName}.png`), "image/png");
    };
    img.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup())}`;
  };
  const jsx = `<BlobMascot shape="${cfg.shape}" expression="${cfg.expression}" color="${cfg.color}" />`;

  const options: { key: string; label: string; on: boolean; pick: () => void; preview: React.ReactNode }[] =
    tab === "shape"
      ? MASCOT_SHAPES.map((s) => ({ key: s, label: title(s), on: cfg.shape === s, pick: () => set({ shape: s }), preview: <BlobMascot shape={s} expression="neutral" color={cfg.color} size={40} shadow={false} /> }))
      : tab === "face"
        ? MASCOT_EXPRESSIONS.map((e) => ({ key: e, label: title(e), on: cfg.expression === e, pick: () => set({ expression: e }), preview: <BlobMascot shape="circle" expression={e} color={cfg.color} size={40} shadow={false} /> }))
        : (Object.keys(MASCOT_COLORS) as MascotColor[]).map((c) => ({ key: c, label: title(c), on: cfg.color === c, pick: () => set({ color: c }), preview: <span className="block size-8 rounded-full ring-1 ring-inset ring-black/10" style={{ background: MASCOT_COLORS[c] }} /> }));

  // Arrow keys move through the options of the open tab, like a radio group.
  const onKey = (e: React.KeyboardEvent) => {
    const d = { ArrowRight: 1, ArrowDown: 1, ArrowLeft: -1, ArrowUp: -1 }[e.key];
    if (!d) return;
    e.preventDefault();
    const i = options.findIndex((o) => o.on);
    const next = options[(i + d + options.length) % options.length];
    next.pick();
    requestAnimationFrame(() => tabs.current?.querySelector<HTMLElement>(`[data-key="${next.key}"]`)?.focus());
  };

  return (
    <div className={cn("grid w-full max-w-3xl gap-4 rounded-3xl border bg-card p-4 text-card-foreground md:grid-cols-[1fr_1.1fr]", className)}>
      <div className="flex flex-col items-center justify-center gap-5 rounded-2xl bg-muted/50 p-6">
        <BlobMascot ref={svg} {...cfg} size={200} label={`${title(cfg.expression)} ${cfg.color} ${cfg.shape} mascot`} />
        <div className="flex flex-wrap justify-center gap-2">
          <button type="button" onClick={png} className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg bg-foreground px-3 text-sm font-medium text-background outline-none focus-visible:ring-2 focus-visible:ring-ring">
            <Download className="size-4" /> PNG
          </button>
          <button type="button" onClick={() => download(new Blob([markup()], { type: "image/svg+xml" }), `${fileName}.svg`)} className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-sm font-medium outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
            <Download className="size-4" /> SVG
          </button>
          <button
            type="button"
            onClick={() => void navigator.clipboard.writeText(jsx).then(() => (setCopied(true), setTimeout(() => setCopied(false), 1400)))}
            className="inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-sm font-medium outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
          >
            {copied ? <Check className="size-4" /> : <Copy className="size-4" />} {copied ? "Copied" : "JSX"}
          </button>
        </div>
      </div>
      <div>
        <div role="tablist" aria-label="Customise" className="flex gap-1 rounded-xl bg-muted/60 p-1">
          {(["shape", "face", "colour"] as const).map((t) => (
            <button key={t} type="button" role="tab" aria-selected={tab === t} onClick={() => setTab(t)} className={cn("relative h-8 flex-1 cursor-pointer rounded-lg text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring", tab === t ? "text-foreground" : "text-muted-foreground hover:text-foreground")}>
              {tab === t && <motion.span layoutId="mascot-builder-tab" className="absolute inset-0 rounded-lg bg-background shadow-sm" transition={{ type: "spring", stiffness: 500, damping: 38 }} />}
              <span className="relative">{title(t)}</span>
            </button>
          ))}
        </div>
        <div ref={tabs} role="radiogroup" aria-label={title(tab)} onKeyDown={onKey} className="mt-3 grid grid-cols-4 gap-1.5">
          {options.map((o) => (
            <button
              key={o.key}
              type="button"
              role="radio"
              aria-checked={o.on}
              data-key={o.key}
              tabIndex={o.on ? 0 : -1}
              onClick={o.pick}
              className={cn("flex cursor-pointer flex-col items-center gap-1 rounded-xl border px-1 py-2 text-[11px] outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring", o.on ? "border-foreground/40 bg-accent text-foreground" : "border-transparent text-muted-foreground hover:bg-accent/60")}
            >
              <span className="grid h-11 place-items-center">{o.preview}</span>
              {o.label}
            </button>
          ))}
        </div>
      </div>
    </div>
  );
}
```