Mascot
Over thirty object mascots, from a coffee mug to a dumbbell, that blink, follow the pointer and flow into each other.
Preview
"use client";
import { useState } from "react";
import type { Mood } from "../pet/Pet";
import { useAutoplay } from "../demo-context";
import { Mascot } from "./Mascot";
import { objects } from "./objects";
const MOODS: Mood[] = ["idle", "happy", "curious", "surprised", "sleepy"];
export default function Demo() {
const [step, setStep] = useState(0);
useAutoplay(() => setStep((s) => s + 1), 1400);
const object = objects[Math.floor(step / 2) % objects.length];
const mood = MOODS[step % MOODS.length];
return (
<button
type="button"
onClick={() => setStep((s) => s + 2)}
aria-label={`${object.name.en}, next mascot`}
style={{ width: 150, height: 150, padding: 0, border: 0, background: "none", cursor: "pointer" }}
>
<Mascot object={object} mood={mood} follow />
</button>
);
}Installation
npx @usefluid/cli add mascotpnpm dlx @usefluid/cli add mascotyarn dlx @usefluid/cli add mascotbunx @usefluid/cli add mascotInstall the engine.
Terminal npm i @usefluid/motionCopy these files into your project.
components/fluid/mascot/Mascot.tsx "use client"; import type { CSSProperties } from "react"; import { type Mood, Pet } from "../pet/Pet"; import { type MascotObject, objects } from "./objects"; import styles from "./Mascot.module.css"; type Props = { object: string | MascotObject; mood?: Mood; color?: string; follow?: boolean; look?: { x: number; y: number }; still?: boolean; label?: string; className?: string; }; export function Mascot({ object, mood = "idle", color, follow = false, look, still = false, label, className }: Props) { const o = typeof object === "string" ? (objects.find((x) => x.id === object) ?? objects[0]) : object; return ( <span className={[styles.mascot, className].filter(Boolean).join(" ")} style={{ "--mascot-color": color ?? o.color } as CSSProperties} > <Pet shape={o.d} eyes={o.eyes} mood={mood} follow={follow} look={look} still={still} label={label} /> </span> ); }components/fluid/mascot/objects.ts import type { Eyes } from "../pet/Pet"; const rr = (x: number, y: number, w: number, h: number, r: number) => { const k = Math.min(r, w / 2, h / 2); return `M${x + k} ${y}H${x + w - k}A${k} ${k} 0 0 1 ${x + w} ${y + k}V${y + h - k}A${k} ${k} 0 0 1 ${x + w - k} ${y + h}H${x + k}A${k} ${k} 0 0 1 ${x} ${y + h - k}V${y + k}A${k} ${k} 0 0 1 ${x + k} ${y}Z`; }; const circle = (cx: number, cy: number, r: number) => `M${cx - r} ${cy}A${r} ${r} 0 1 0 ${cx + r} ${cy}A${r} ${r} 0 1 0 ${cx - r} ${cy}Z`; const ring = (cx: number, cy: number, r: number, inner: number) => `${circle(cx, cy, r)}${circle(cx, cy, inner)}`; function star(cx: number, cy: number, outer: number, inner: number, points = 5) { let d = ""; for (let i = 0; i < points * 2; i++) { const a = (i / (points * 2)) * Math.PI * 2 - Math.PI / 2; const r = i % 2 ? inner : outer; d += `${i ? "L" : "M"}${(cx + r * Math.cos(a)).toFixed(2)} ${(cy + r * Math.sin(a)).toFixed(2)}`; } return `${d}Z`; } function rays(cx: number, cy: number, r: number, size: number, count: number) { let d = ""; for (let i = 0; i < count; i++) { const a = (i / count) * Math.PI * 2; d += circle(cx + r * Math.cos(a), cy + r * Math.sin(a), size); } return d; } export type MascotObject = { id: string; name: { en: string; pt: string }; d: string; eyes: Eyes; color: string; words: string[]; }; export const objects: MascotObject[] = [ { id: "mug", name: { en: "Mug", pt: "Caneca" }, d: rr(24, 38, 52, 60, 12) + ring(90, 66, 13, 6), eyes: { x: 50, y: 64, gap: 11, size: 0.9 }, color: "#14b8a6", words: ["coffee", "cafe", "café", "cafeteria", "tea", "chá", "bakery breakfast", "mug", "caneca", "barista"], }, { id: "dumbbell", name: { en: "Dumbbell", pt: "Haltere" }, d: rr(10, 34, 22, 52, 7) + rr(32, 46, 56, 28, 9) + rr(88, 34, 22, 52, 7), eyes: { x: 60, y: 60, gap: 10, size: 0.8 }, color: "#8b5cf6", words: ["gym", "academia", "fitness", "workout", "treino", "crossfit", "personal trainer", "musculação", "sport", "esporte"], }, { id: "controller", name: { en: "Controller", pt: "Controle" }, d: "M32 36H88C104 36 112 50 110 66L106 88C104 100 90 102 84 92L78 82H42L36 92C30 102 16 100 14 88L10 66C8 50 16 36 32 36Z", eyes: { x: 60, y: 58, gap: 12, size: 0.95 }, color: "#f43f5e", words: ["game", "games", "gaming", "jogo", "jogos", "studio", "estúdio", "esports", "console", "arcade"], }, { id: "plant", name: { en: "Plant", pt: "Planta" }, d: "M32 62H88L80 104H40Z" + "M58 60C40 58 30 44 34 28C50 30 58 42 58 60Z" + "M62 60C62 42 72 28 90 26C92 44 80 58 62 60Z", eyes: { x: 60, y: 80, gap: 10, size: 0.85 }, color: "#22c55e", words: ["plant", "plants", "planta", "plantas", "garden", "jardim", "flower shop", "floricultura", "nature", "natureza", "eco", "green"], }, { id: "book", name: { en: "Book", pt: "Livro" }, d: rr(28, 22, 64, 76, 9) + rr(74, 98, 10, 12, 2), eyes: { x: 60, y: 58, gap: 12, size: 1 }, color: "#f59e0b", words: ["book", "books", "livro", "livros", "bookstore", "livraria", "school", "escola", "course", "curso", "library", "biblioteca", "education", "educação", "study"], }, { id: "camera", name: { en: "Camera", pt: "Câmera" }, d: rr(42, 30, 30, 12, 4) + rr(16, 42, 88, 56, 14), eyes: { x: 60, y: 70, gap: 13, size: 1 }, color: "#0ea5e9", words: ["photo", "photography", "foto", "fotografia", "camera", "câmera", "film", "video", "vídeo", "studio photo", "wedding", "casamento"], }, { id: "bag", name: { en: "Bag", pt: "Sacola" }, d: "M42 44V38C42 24 78 24 78 38V44H71V38C71 31 49 31 49 38V44Z" + rr(24, 44, 72, 58, 10), eyes: { x: 60, y: 72, gap: 13, size: 1 }, color: "#ec4899", words: ["shop", "store", "loja", "ecommerce", "e-commerce", "fashion", "moda", "boutique", "clothes", "roupa", "market", "mercado", "retail", "varejo"], }, { id: "house", name: { en: "House", pt: "Casa" }, d: "M60 18L102 54V98C102 101 100 103 97 103H23C20 103 18 101 18 98V54Z", eyes: { x: 60, y: 70, gap: 13, size: 1 }, color: "#6366f1", words: ["home", "house", "casa", "real estate", "imobiliária", "rent", "aluguel", "architecture", "arquitetura", "interior", "construction", "construção"], }, { id: "tooth", name: { en: "Tooth", pt: "Dente" }, d: "M30 34C30 22 46 20 60 26C74 20 90 22 90 34C90 52 84 62 82 82C80 98 70 100 68 88L64 72H56L52 88C50 100 40 98 38 82C36 62 30 52 30 34Z", eyes: { x: 60, y: 46, gap: 12, size: 0.95 }, color: "#38bdf8", words: ["dentist", "dentista", "dental", "odonto", "odontologia", "tooth", "dente", "clinic", "clínica", "smile", "sorriso"], }, { id: "paw", name: { en: "Paw", pt: "Pata" }, d: "M60 54C82 54 94 72 92 86C90 100 76 100 60 96C44 100 30 100 28 86C26 72 38 54 60 54Z" + circle(32, 42, 9) + circle(50, 28, 9) + circle(70, 28, 9) + circle(88, 42, 9), eyes: { x: 60, y: 78, gap: 11, size: 0.9 }, color: "#f97316", words: ["pet", "pets", "petshop", "pet shop", "dog", "cachorro", "cat", "gato", "vet", "veterinário", "veterinária", "animal", "animals"], }, { id: "note", name: { en: "Note", pt: "Nota" }, d: "M66 18H74L96 28V44L74 36V84C74 96 62 104 48 102C34 100 28 90 34 82C40 74 54 72 66 76Z", eyes: { x: 50, y: 88, gap: 8, size: 0.7 }, color: "#a855f7", words: ["music", "música", "band", "banda", "song", "podcast", "audio", "áudio", "dj", "concert", "show", "instrument", "instrumento"], }, { id: "rocket", name: { en: "Rocket", pt: "Foguete" }, d: "M60 12C78 26 82 50 80 76H40C38 50 42 26 60 12Z" + "M40 60L24 82V96L40 86Z" + "M80 60L96 82V96L80 86Z" + "M48 80H72L66 100H54Z", eyes: { x: 60, y: 48, gap: 9, size: 0.8 }, color: "#ef4444", words: ["startup", "launch", "lançamento", "tech", "tecnologia", "saas", "software", "app", "aplicativo", "space", "espaço", "growth", "crescimento"], }, { id: "cloud", name: { en: "Cloud", pt: "Nuvem" }, d: "M30 90C14 90 10 70 25 65C22 48 42 40 54 48C60 32 88 34 90 54C106 54 110 78 98 87C96 89 93 90 90 90Z", eyes: { x: 60, y: 70, gap: 13, size: 1 }, color: "#60a5fa", words: ["cloud", "nuvem", "hosting", "hospedagem", "server", "servidor", "data", "dados", "weather", "clima", "storage", "backup", "devops"], }, { id: "heart", name: { en: "Heart", pt: "Coração" }, d: "M60 100C40 86 16 70 16 46C16 30 30 22 42 24C50 25 56 30 60 36C64 30 70 25 78 24C90 22 104 30 104 46C104 70 80 86 60 100Z", eyes: { x: 60, y: 56, gap: 13, size: 1 }, color: "#f43f5e", words: ["love", "amor", "dating", "relacionamento", "health", "saúde", "care", "cuidado", "charity", "ong", "donation", "doação", "wedding", "casamento", "cardio"], }, { id: "star", name: { en: "Star", pt: "Estrela" }, d: star(60, 62, 48, 22), eyes: { x: 60, y: 64, gap: 9, size: 0.8 }, color: "#eab308", words: ["star", "estrela", "review", "avaliação", "rating", "awards", "prêmio", "events", "eventos", "kids", "crianças", "party", "festa", "fan"], }, { id: "drop", name: { en: "Drop", pt: "Gota" }, d: "M60 14C76 38 92 56 92 76C92 94 78 106 60 106C42 106 28 94 28 76C28 56 44 38 60 14Z", eyes: { x: 60, y: 78, gap: 12, size: 0.95 }, color: "#06b6d4", words: ["water", "água", "cleaning", "limpeza", "laundry", "lavanderia", "spa", "plumber", "encanador", "pool", "piscina", "beauty", "skincare", "oil"], }, { id: "chat", name: { en: "Chat", pt: "Balão" }, d: rr(18, 24, 84, 60, 18) + "M34 84L28 102L52 84Z", eyes: { x: 60, y: 54, gap: 13, size: 1 }, color: "#3b82f6", words: ["chat", "support", "suporte", "help", "ajuda", "message", "mensagem", "community", "comunidade", "social", "agency", "agência", "marketing", "assistant", "bot"], }, { id: "gift", name: { en: "Gift", pt: "Presente" }, d: rr(26, 54, 68, 46, 7) + rr(20, 40, 80, 14, 5) + "M58 40C48 22 30 26 40 40Z" + "M62 40C72 22 90 26 80 40Z", eyes: { x: 60, y: 76, gap: 12, size: 0.95 }, color: "#d946ef", words: ["gift", "presente", "store", "presentes", "birthday", "aniversário", "toys", "brinquedos", "surprise", "subscription box", "assinatura"], }, { id: "bulb", name: { en: "Bulb", pt: "Lâmpada" }, d: "M60 14C82 14 94 30 94 48C94 62 84 70 80 82H40C36 70 26 62 26 48C26 30 38 14 60 14Z" + rr(42, 86, 36, 8, 3) + rr(47, 97, 26, 8, 3), eyes: { x: 60, y: 46, gap: 12, size: 0.95 }, color: "#facc15", words: ["idea", "ideia", "ideas", "consulting", "consultoria", "innovation", "inovação", "electric", "elétrica", "energy", "energia", "creative", "criativo", "lighting"], }, { id: "pizza", name: { en: "Pizza", pt: "Pizza" }, d: "M18 28C46 12 74 12 102 28L60 108Z", eyes: { x: 60, y: 42, gap: 11, size: 0.9 }, color: "#fb923c", words: ["pizza", "pizzaria", "pizzeria", "pizzas", "food", "comida", "restaurant", "restaurante", "delivery", "fast food", "lanchonete", "snack", "burger", "hamburger", "hamburgueria", "lanches", "grill", "churrascaria", "bistro", "bar"], }, { id: "cupcake", name: { en: "Cupcake", pt: "Cupcake" }, d: "M32 66H88L81 102H39Z" + "M28 64C22 52 32 40 45 44C50 30 70 30 75 44C88 40 98 52 92 64Z", eyes: { x: 60, y: 82, gap: 11, size: 0.85 }, color: "#f472b6", words: ["cake", "bolo", "bakery", "padaria", "confeitaria", "sweets", "doces", "dessert", "sobremesa", "cupcake", "brigadeiro", "candy"], }, { id: "laptop", name: { en: "Laptop", pt: "Notebook" }, d: rr(24, 26, 72, 50, 8) + "M12 80H108L102 92C101 94 99 95 97 95H23C21 95 19 94 18 92Z", eyes: { x: 60, y: 51, gap: 13, size: 1 }, color: "#64748b", words: ["developer", "desenvolvedor", "dev", "code", "código", "it", "ti", "computer", "computador", "work", "trabalho", "freelancer", "remote", "office", "escritório"], }, { id: "phone", name: { en: "Phone", pt: "Celular" }, d: rr(34, 12, 52, 96, 14), eyes: { x: 60, y: 52, gap: 12, size: 1 }, color: "#0f172a", words: ["phone", "celular", "mobile", "app", "telecom", "smartphone", "repair", "conserto", "assistência", "calls", "ligações"], }, { id: "umbrella", name: { en: "Umbrella", pt: "Guarda-chuva" }, d: "M14 60C14 34 36 18 60 18C84 18 106 34 106 60Z" + "M57 60H63V92C63 104 45 104 45 92H51C51 96 57 96 57 92Z", eyes: { x: 60, y: 44, gap: 13, size: 1 }, color: "#0891b2", words: ["insurance", "seguro", "seguros", "protection", "proteção", "security", "segurança", "rain", "chuva", "travel", "viagem", "coverage"], }, { id: "fish", name: { en: "Fish", pt: "Peixe" }, d: "M12 60C28 36 72 34 90 60C72 86 28 84 12 60Z" + "M92 60L108 42V78Z", eyes: { x: 46, y: 58, gap: 9, size: 0.8 }, color: "#0284c7", words: ["fish", "peixe", "sushi", "seafood", "frutos do mar", "aquarium", "aquário", "fishing", "pesca", "ocean", "oceano", "boat"], }, { id: "ghost", name: { en: "Ghost", pt: "Fantasma" }, d: "M28 100V52C28 30 44 18 60 18C76 18 92 30 92 52V100L82 92L71 100L60 92L49 100L38 92Z", eyes: { x: 60, y: 52, gap: 12, size: 1 }, color: "#94a3b8", words: ["ghost", "fantasma", "halloween", "privacy", "privacidade", "anonymous", "anônimo", "horror", "terror", "mystery", "mistério"], }, { id: "cat", name: { en: "Cat", pt: "Gato" }, d: "M24 98V42L38 18L52 36H68L82 18L96 42V98C96 101 94 103 91 103H29C26 103 24 101 24 98Z", eyes: { x: 60, y: 64, gap: 14, size: 1 }, color: "#78716c", words: ["cat", "gato", "gatos", "cats", "cattery", "gatil", "kitten", "filhote", "feline"], }, { id: "sun", name: { en: "Sun", pt: "Sol" }, d: circle(60, 60, 28) + rays(60, 60, 46, 7, 8), eyes: { x: 60, y: 60, gap: 10, size: 0.85 }, color: "#f59e0b", words: ["sun", "sol", "solar", "summer", "verão", "beach", "praia", "tourism", "turismo", "hotel", "pousada", "vacation", "férias", "morning"], }, { id: "leaf", name: { en: "Leaf", pt: "Folha" }, d: "M60 12C94 30 102 72 60 108C18 72 26 30 60 12Z", eyes: { x: 60, y: 58, gap: 11, size: 0.9 }, color: "#16a34a", words: ["organic", "orgânico", "vegan", "vegano", "natural", "sustainable", "sustentável", "farm", "fazenda", "agro", "health food", "salad", "salada", "herbal"], }, { id: "toast", name: { en: "Toast", pt: "Pão" }, d: "M26 56C14 52 16 26 40 24H80C104 26 106 52 94 56V98C94 101 92 103 89 103H31C28 103 26 101 26 98Z", eyes: { x: 60, y: 66, gap: 13, size: 1 }, color: "#d97706", words: ["bread", "pão", "padaria", "bakery", "breakfast", "café da manhã", "sandwich", "sanduíche", "brunch", "toast"], }, { id: "battery", name: { en: "Battery", pt: "Bateria" }, d: rr(16, 36, 78, 48, 12) + rr(94, 50, 10, 20, 4), eyes: { x: 55, y: 60, gap: 13, size: 1 }, color: "#10b981", words: ["battery", "bateria", "energy", "energia", "charge", "carga", "electric car", "carro elétrico", "power", "productivity", "produtividade"], }, { id: "car", name: { en: "Car", pt: "Carro" }, d: "M30 46L40 32C42 29 45 28 48 28H72C75 28 78 29 80 32L90 46H100C104 46 108 50 108 54V78C108 81 106 83 103 83H17C14 83 12 81 12 78V54C12 50 16 46 20 46Z" + circle(34, 94, 10) + circle(86, 94, 10), eyes: { x: 60, y: 60, gap: 13, size: 0.95 }, color: "#dc2626", words: ["car", "carro", "auto", "automotive", "mechanic", "mecânica", "oficina", "taxi", "uber", "transport", "transporte", "driving school", "autoescola", "rental", "locadora"], }, { id: "comb", name: { en: "Comb", pt: "Pente" }, d: rr(18, 36, 84, 26, 10) + [26, 38, 50, 62, 74, 86].map((x) => rr(x, 64, 8, 34, 4)).join(""), eyes: { x: 60, y: 49, gap: 13, size: 0.9 }, color: "#be185d", words: ["salon", "salão", "hair", "cabelo", "barber", "barbearia", "beauty", "beleza", "stylist", "cabeleireiro", "manicure"], }, ]; const normal = (s: string) => s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, ""); const tokens = (s: string) => normal(s).split(/[^a-z0-9]+/).filter(Boolean); const near = (a: string, b: string) => { if (a === b || a === `${b}s` || b === `${a}s`) return 1; const short = Math.min(a.length, b.length); if (short < 4) return 0; let i = 0; while (i < short && a[i] === b[i]) i++; return i >= 4 && i / short >= 0.75 ? 0.6 : 0; }; export function pickObject(description: string): MascotObject { const text = tokens(description); const joined = ` ${text.join(" ")} `; let best: MascotObject | null = null; let score = 0; for (const o of objects) { let s = 0; for (const word of o.words) { const w = tokens(word); if (w.length > 1) { if (joined.includes(` ${w.join(" ")} `)) s += w.join("").length * 1.5; continue; } let hit = 0; for (const t of text) hit = Math.max(hit, near(t, w[0])); s += hit * w[0].length; } if (s > score) { score = s; best = o; } } if (best) return best; let hash = 0; for (const c of text) hash = (hash * 31 + c.charCodeAt(0)) >>> 0; return objects[hash % objects.length]; }components/fluid/mascot/Mascot.module.css .mascot { display: block; width: 100%; height: 100%; color: var(--mascot-color); transition: color 400ms ease; } .mascot svg { color: inherit; }It uses these pieces too, so copy them alongside:
components/fluid/pet/Pet.tsx "use client"; import { springs } from "@usefluid/motion"; import { MorphPath, useReducedMotion, useSpring } from "@usefluid/motion/react"; import { useEffect, useId, useRef, useState } from "react"; import styles from "./Pet.module.css"; export type Mood = "idle" | "happy" | "surprised" | "sleepy" | "curious"; const TAU = Math.PI * 2; function body(w: number, h: number, round: number, cy = 70, lean = 0) { let d = ""; for (let i = 0; i < 120; i++) { const a = (i / 120) * TAU; const r = (Math.abs(Math.cos(a) / w) ** round + Math.abs(Math.sin(a) / h) ** round) ** (-1 / round); const y = cy + r * Math.sin(a); const x = 60 + r * Math.cos(a) + lean * ((cy - y) / h); d += `${i ? "L" : "M"}${x.toFixed(2)} ${y.toFixed(2)}`; } return `${d}Z`; } const BODIES: Record<Mood, string> = { idle: body(38, 36, 4.2), happy: body(42, 32, 4, 72), surprised: body(34, 41, 4.6, 66), sleepy: body(45, 28, 3.4, 78), curious: body(37, 37, 4.2, 70, 7), }; type Eye = { oval: [number, number]; line: [number, number] }; type Face = { left: Eye; right: Eye; eyeY: number; drop: number; tilt: number }; const open = (w: number, h: number): Eye => ({ oval: [w, h], line: [0, 0] }); const line = (w: number, bend: number): Eye => ({ oval: [0, 0], line: [w, bend] }); const FACES: Record<Mood, Face> = { idle: { left: open(4.5, 6.5), right: open(4.5, 6.5), eyeY: 68, drop: 0, tilt: 0 }, happy: { left: line(5.5, -5), right: line(5.5, -5), eyeY: 68, drop: -6, tilt: 0 }, surprised: { left: open(6.5, 8.5), right: open(6.5, 8.5), eyeY: 62, drop: -14, tilt: 0 }, sleepy: { left: line(6, 1.5), right: line(6, 1.5), eyeY: 78, drop: 10, tilt: 0 }, curious: { left: open(3.8, 5.5), right: open(6, 8), eyeY: 67, drop: -4, tilt: 6 }, }; function eyeAttrs(eye: Eye, cx: number, cy: number, size: number) { const lw = eye.line[0] * size; const lb = eye.line[1] * size; return { oval: { cx, cy, rx: eye.oval[0] * size, ry: eye.oval[1] * size }, line: { d: `M${cx - lw} ${cy} Q${cx} ${cy + lb * 1.6} ${cx + lw} ${cy}`, strokeWidth: lw > 0.4 ? 4.4 * size : 0 }, }; } export type Eyes = { x: number; y: number; gap?: number; size?: number }; type Watcher = (x: number, y: number, touch: boolean) => void; const watchers = new Set<Watcher>(); let pointer = { x: 0, y: 0, touch: false }; let frame = 0; const flush = () => { frame = 0; for (const watch of watchers) watch(pointer.x, pointer.y, pointer.touch); }; const onPointer = (e: PointerEvent) => { pointer = { x: e.clientX, y: e.clientY, touch: e.pointerType !== "mouse" }; if (!frame) frame = requestAnimationFrame(flush); }; function watch(fn: Watcher) { if (!watchers.size) { addEventListener("pointermove", onPointer, { passive: true }); addEventListener("pointerdown", onPointer, { passive: true }); } watchers.add(fn); return () => { watchers.delete(fn); if (watchers.size) return; removeEventListener("pointermove", onPointer); removeEventListener("pointerdown", onPointer); cancelAnimationFrame(frame); frame = 0; }; } function useVisible(ref: { current: Element | null }) { const [visible, setVisible] = useState(false); useEffect(() => { const el = ref.current; if (!el || typeof IntersectionObserver === "undefined") return setVisible(true); const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting), { rootMargin: "120px" }); io.observe(el); return () => io.disconnect(); }, [ref]); return visible; } const OBJECT_CURIOUS: Face = { left: open(4.4, 6.2), right: open(5.4, 7.6), eyeY: 66, drop: 0, tilt: 2 }; type Props = { mood?: Mood; shape?: string; eyes?: Eyes; drop?: boolean; still?: boolean; follow?: boolean; look?: { x: number; y: number }; label?: string; color?: string; className?: string; }; export function Pet({ mood = "idle", shape, eyes, drop = !shape, still = false, follow = false, look, label, color, className }: Props) { const reduced = useReducedMotion(); const mask = `pet-${useId().replace(/[^a-zA-Z0-9-]/g, "")}`; const root = useRef<SVGSVGElement>(null); const parts = useRef<Record<string, SVGElement | null>>({}); const [gaze, setGaze] = useState({ x: 0, y: 0 }); const [blink, setBlink] = useState(false); const visible = useVisible(root); useEffect(() => { if (!follow || !visible) return; let back: ReturnType<typeof setTimeout> | undefined; const stop = watch((px, py, touch) => { clearTimeout(back); if (touch) back = setTimeout(() => setGaze({ x: 0, y: 0 }), 1200); const box = root.current?.getBoundingClientRect(); if (!box?.width) return; const dx = (px - (box.left + box.width / 2)) / (box.width * 2); const dy = (py - (box.top + box.height / 2)) / (box.height * 2); const x = Math.round(Math.max(-1, Math.min(1, dx)) * 10) / 10; const y = Math.round(Math.max(-1, Math.min(1, dy)) * 10) / 10; setGaze((g) => (g.x === x && g.y === y ? g : { x, y })); }); return () => { clearTimeout(back); stop(); }; }, [follow, visible]); useEffect(() => { if (reduced || still || !visible || mood === "sleepy") return; let open: ReturnType<typeof setTimeout>; const timer = setInterval(() => { setBlink(true); open = setTimeout(() => setBlink(false), 120); }, 3400); return () => { clearInterval(timer); clearTimeout(open); }; }, [reduced, still, visible, mood]); const f = shape && mood === "curious" ? OBJECT_CURIOUS : FACES[mood]; const g = follow ? gaze : (look ?? { x: 0, y: 0 }); const ex = eyes?.x ?? 60; const size = eyes?.size ?? 1; const gap = eyes?.gap ?? 13; const ey = (eyes?.y ?? 68) + (f.eyeY - 68) * size; const first = useRef({ l: eyeAttrs(f.left, ex + g.x * 6 * size - gap, ey + g.y * 4 * size - f.tilt * 0.4, size), r: eyeAttrs(f.right, ex + g.x * 6 * size + gap, ey + g.y * 4 * size + f.tilt * 0.4, size), }); useSpring( { lx: g.x * 6 * size, ly: g.y * 4 * size, ex, ey, gap, size, low: f.left.oval[0] * size, loh: (blink ? 0.8 : f.left.oval[1]) * size, llw: f.left.line[0] * size, llb: f.left.line[1] * size, row: f.right.oval[0] * size, roh: (blink ? 0.8 : f.right.oval[1]) * size, rlw: f.right.line[0] * size, rlb: f.right.line[1] * size, drop: f.drop, tilt: f.tilt, }, (v) => { const p = parts.current; const x = v.ex + v.lx; const y = v.ey + v.ly; const set = (el: SVGElement | null, attrs: Record<string, number | string>) => { if (el) for (const k in attrs) el.setAttribute(k, String(attrs[k])); }; const eye = (side: "l" | "r", cx: number, cy: number, ow: number, oh: number, lw: number, lb: number) => { set(p[`${side}Oval`], { cx, cy, rx: Math.max(0, ow), ry: Math.max(0, oh) }); set(p[`${side}Line`], { d: `M${cx - lw} ${cy} Q${cx} ${cy + lb * 1.6} ${cx + lw} ${cy}`, "stroke-width": lw > 0.4 ? 4.4 * v.size : 0, }); }; eye("l", x - v.gap, y - v.tilt * 0.4, v.low, v.loh, v.llw, v.llb); eye("r", x + v.gap, y + v.tilt * 0.4, v.row, v.roh, v.rlw, v.rlb); if (p.drop) p.drop.setAttribute("transform", `translate(0 ${v.drop}) rotate(${v.tilt * 2} 88 22)`); }, springs.snappy, ); const ref = (name: string) => (el: SVGElement | null) => { parts.current[name] = el; }; return ( <svg ref={root} viewBox="0 0 120 120" className={[styles.pet, className].filter(Boolean).join(" ")} style={color ? { color } : undefined} data-mood={mood} data-shape={shape ? "" : undefined} data-still={still || undefined} role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true} > <defs> <mask id={mask} maskUnits="userSpaceOnUse" x="0" y="0" width="120" height="120"> <rect width="120" height="120" fill="#fff" /> <ellipse ref={ref("lOval")} fill="#000" {...first.current.l.oval} /> <ellipse ref={ref("rOval")} fill="#000" {...first.current.r.oval} /> <path ref={ref("lLine")} fill="none" stroke="#000" strokeLinecap="round" {...first.current.l.line} /> <path ref={ref("rLine")} fill="none" stroke="#000" strokeLinecap="round" {...first.current.r.line} /> </mask> </defs> <g className={styles.pose}> <g className={styles.body}> <MorphPath d={shape ?? BODIES[mood]} fill="currentColor" mask={`url(#${mask})`} config={{ w: 12, z: 0.72, points: 120, spread: 0.1 }} /> </g> </g> {drop && ( <g ref={ref("drop")}> <circle cx="88" cy="22" r="6" fill="currentColor" className={styles.drop} /> </g> )} </svg> ); }components/fluid/pet/Pet.module.css .pet { display: block; width: 100%; height: 100%; overflow: visible; color: var(--fluid-accent); } .body { transform-origin: 60px 108px; animation: breathe 3.2s ease-in-out infinite; } .pet[data-mood="happy"] .body { animation: hop 0.9s cubic-bezier(0.34, 1.56, 0.64, 1) 2, breathe 3.2s ease-in-out 1.8s infinite; } .pet[data-mood="sleepy"] .body { animation-duration: 4.8s; } .pose { transform-origin: 60px 104px; transition: transform 520ms cubic-bezier(0.34, 1.4, 0.64, 1); } .pet[data-shape][data-mood="sleepy"] .pose { transform: scale(1.05, 0.92); } .pet[data-shape][data-mood="surprised"] .pose { transform: translateY(-3px) scale(0.96, 1.05); } .pet[data-shape][data-mood="curious"] .pose { transform: rotate(-4deg); } .drop { transform-origin: 88px 22px; animation: float 2.4s ease-in-out infinite; } .pet[data-mood="sleepy"] .drop { animation-duration: 4s; } @keyframes breathe { 0%, 100% { transform: scale(1, 1); } 50% { transform: scale(1.03, 0.97); } } @keyframes hop { 0%, 100% { transform: translateY(0) scale(1, 1); } 30% { transform: translateY(-6px) scale(0.96, 1.05); } 60% { transform: translateY(0) scale(1.05, 0.95); } } @keyframes float { 0%, 100% { transform: translateY(0) scale(1); } 50% { transform: translateY(-3px) scale(0.9); } } .pet[data-still] .body, .pet[data-still] .drop { animation: none; } @media (prefers-reduced-motion: reduce) { .body, .drop { animation: none; } .pose { transition: none; } }Import the theme once, then point its variables at your own tokens.
layout.tsx import "@usefluid/motion/theme.css";
Usage
import { Mascot } from "./Mascot";
import { objects, pickObject } from "./objects";
<div style={{ width: 120, height: 120 }}>
<Mascot object="mug" mood={saved ? "happy" : "idle"} follow label="Lume, our helper" />
</div>
const object = pickObject("we run a small coffee shop");
<Mascot object={object} color="var(--fluid-accent)" />Your own object
const kettle = {
id: "kettle",
name: { en: "Kettle", pt: "Chaleira" },
d: "M30 50H90V100H30Z",
eyes: { x: 60, y: 74, gap: 12, size: 1 },
color: "#0d9488",
words: ["tea", "chá"],
};
<Mascot object={kettle} />Draw it filled in a 120 x 120 box. Parts must not overlap: where two parts overlap, the overlap becomes a hole. Put eyes on the largest flat area.
Colours
Each object comes with its own colour. To change it:
<Mascot object="mug" color="#ff5a1f" />
<Mascot object="mug" color="var(--fluid-accent)" />
<Mascot object="mug" color="var(--brand-500)" />Any CSS colour works: hex, rgb(), oklch() or a variable from your theme. The colour change is animated, so you can tie it to state, for example green when a payment succeeds. The eyes are holes cut out of the shape and show the background behind the mascot.
API reference
| Prop | Type | Default | Description |
|---|---|---|---|
object | string | MascotObject | — | An id from objects (mug, dumbbell, controller, plant...) or your own object. Changing it flows one into the other. |
mood | "idle" | "happy" | "surprised" | "sleepy" | "curious" | "idle" | Each mood changes the eyes and the pose. |
color | string | the object's colour | Pass var(--fluid-accent) to wear your brand. |
follow | boolean | false | The eyes follow the pointer, and on touch look at each tap. |
look | { x, y } | — | Where the eyes look, from -1 to 1. |
still | boolean | false | No breathing or blinking. |
label | string | — | Accessible name. Without it the mascot is decorative. |
className | string | — | It fills its box. |
Keyboard and screen readers
Decorative unless labelled; with label it is an image with that name. Reduced motion stops breathing, blinking and the pose changes. Never the only way information is given.
How it is made
Each object is one silhouette in a 120 box with its own eye spot. Changing the object is Metamorph: the outline flows from a mug into a dumbbell. The eyes are cut out of the body by a mask and move on springs, and each mood changes the pose: it stretches when surprised, sinks when sleepy, tilts when curious and hops when happy.
For your agent
For your agent
Add Mascot to my project with the Fluid library. Give my product a mascot with Mascot. Pick the object that matches what we do with pickObject, wear our accent colour and tie its moods to real events. Read my project first. Use my logo, my colours and my font through the --fluid-* variables. Keep 44px touch targets, visible focus and reduced motion. If I have no SVG logo, ask before making one up.