shadimage
gammaui
uirabbit-mascotsource: ok · refreshed

Rabbit Mascot

An expressive rabbit boy mascot with round glasses and mood-driven face animations.

npx shadcn add "https://www.gammaui.com/r/rabbit-mascot.json"
free

Visual style

glass-blur

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
react

Files4 · source cached 10/8/2026, 5:34:37 AM

registry/gammaui/rabbit-mascot.tsx
"use client"

import { useEffect, useId, useRef, useState } from "react"

import { cn } from "@/lib/utils"
import { applyRabbitArmReach } from "@/registry/gammaui/rabbit-mascot-arm"
import { startRabbitBlinkLoop } from "@/registry/gammaui/rabbit-mascot-blink"
import {
  applyRabbitMood,
  type RabbitMood,
} from "@/registry/gammaui/rabbit-mascot-moods"

interface RabbitMascotProps {
  mood?: RabbitMood
  className?: string
  size?: number
  blink?: boolean
  armReach?: boolean
  onClick?: () => void
  onReady?: () => void
}

export function RabbitMascot({
  mood = "neutral",
  className,
  size = 280,
  blink = true,
  armReach = false,
  onClick,
  onReady,
}: RabbitMascotProps) {
  const containerRef = useRef<HTMLDivElement>(null)
  const moodRef = useRef(mood)
  const onReadyRef = useRef(onReady)
  const isLoadedRef = useRef(false)
  const instanceId = useId().replace(/:/g, "")
  const idPrefix = `rabbit-${instanceId}-`
  const [isLoaded, setIsLoaded] = useState(false)

  moodRef.current = mood
  onReadyRef.current = onReady

  useEffect(() => {
    let cancelled = false

    async function loadMascot() {
      const response = await fetch("/rabbit.svg")
      if (!response.ok || cancelled || !containerRef.current) return

      const markup = (await response.text()).replace(/_r_0_/g, idPrefix)
      containerRef.current.innerHTML = markup

      const svg = containerRef.current.querySelector("svg")
      if (!svg) return

      svg.style.display = "block"
      svg.style.overflow = "visible"
      svg.style.width = "100%"
      svg.style.height = "100%"
      if (onClick) svg.style.cursor = "pointer"

      applyRabbitMood(svg, moodRef.current)
      applyRabbitArmReach(svg, armReach)

      isLoadedRef.current = true
      setIsLoaded(true)
      onReadyRef.current?.()
    }

    loadMascot()

    return () => {
      cancelled = true
      isLoadedRef.current = false
    }
  }, [idPrefix, onClick])

  useEffect(() => {
    if (!isLoaded || !containerRef.current) return
    applyRabbitMood(containerRef.current, mood)
  }, [isLoaded, mood])

  useEffect(() => {
    if (!isLoaded || !containerRef.current) return
    applyRabbitArmReach(containerRef.current, armReach)
  }, [isLoaded, armReach])

  useEffect(() => {
    if (!isLoaded || !blink || !containerRef.current) return

    return startRabbitBlinkLoop(containerRef.current, () => moodRef.current)
  }, [isLoaded, blink])

  return (
    <div
      ref={containerRef}
      role="img"
      aria-live="polite"
      aria-label={`Rabbit mascot, ${mood} mood`}
      className={cn(
        "form-companion-mascot inline-block select-none",
        className
      )}
      style={{ width: size, height: size * (720 / 900) }}
      onClick={onClick}
      onKeyDown={
        onClick
          ? (event) => {
              if (event.key === "Enter" || event.key === " ") {
                event.preventDefault()
                onClick()
              }
            }
          : undefined
      }
      tabIndex={onClick ? 0 : undefined}
    />
  )
}

export {
  RABBIT_MOODS,
  RABBIT_MOOD_LABELS,
  type RabbitMood,
} from "@/registry/gammaui/rabbit-mascot-moods"
registry/gammaui/rabbit-mascot-moods.ts
export const RABBIT_MOODS = [
  "neutral",
  "happy",
  "sad",
  "love",
  "winky",
  "surprised",
  "thinking",
  "side-eye",
  "shy",
] as const

export type RabbitMood = (typeof RABBIT_MOODS)[number]

export const RABBIT_MOOD_LABELS: Record<RabbitMood, string> = {
  neutral: "Neutral",
  happy: "Happy",
  sad: "Sad",
  love: "Love",
  winky: "Winky",
  surprised: "Surprised",
  thinking: "Thinking",
  "side-eye": "Side eye",
  shy: "Shy",
}

interface RabbitMoodLayers {
  leftDefaultEye: number
  rightDefaultEye: number
  defaultMouth: number
  sadMouth: number
  leftHappyArc: number
  rightHappyArc: number
  passwordFace: number
  shyFace: number
  sideEye: number
  hmmLids: number
  sadBrows: number
  happyOpenMouth: number
  mouthOh: number
  leftEyeStar: number
  rightEyeStar: number
  leftWantTwinkle: number
  rightWantTwinkle: number
  emotionFx: number
  sadTears: number
  happyTwinkle: number
  cheekOpacity: number
  leftWink: number
  rightWink: number
  spectacles: number
}

const NEUTRAL_LAYERS: RabbitMoodLayers = {
  leftDefaultEye: 1,
  rightDefaultEye: 1,
  defaultMouth: 1,
  sadMouth: 0,
  leftHappyArc: 0,
  rightHappyArc: 0,
  passwordFace: 0,
  shyFace: 0,
  sideEye: 0,
  hmmLids: 0,
  sadBrows: 0,
  happyOpenMouth: 0,
  mouthOh: 0,
  leftEyeStar: 0,
  rightEyeStar: 0,
  leftWantTwinkle: 0,
  rightWantTwinkle: 0,
  emotionFx: 0,
  sadTears: 0,
  happyTwinkle: 0,
  cheekOpacity: 0.65,
  leftWink: 0,
  rightWink: 0,
  spectacles: 1,
}

const MOOD_LAYER_MAP: Record<RabbitMood, Partial<RabbitMoodLayers>> = {
  neutral: {},
  happy: {
    leftDefaultEye: 0,
    rightDefaultEye: 0,
    defaultMouth: 0,
    leftHappyArc: 1,
    rightHappyArc: 1,
    happyOpenMouth: 1,
    happyTwinkle: 1,
    emotionFx: 1,
    cheekOpacity: 0.85,
  },
  sad: {
    sadBrows: 1,
    sadTears: 1,
    emotionFx: 1,
    defaultMouth: 0,
    sadMouth: 1,
    cheekOpacity: 0.45,
    spectacles: 0,
  },
  love: {
    leftEyeStar: 1,
    rightEyeStar: 1,
    leftWantTwinkle: 1,
    rightWantTwinkle: 1,
    happyTwinkle: 1,
    emotionFx: 1,
    cheekOpacity: 0.95,
  },
  winky: {
    leftDefaultEye: 0,
    rightDefaultEye: 1,
    leftWink: 1,
    happyTwinkle: 0.8,
    cheekOpacity: 0.75,
    spectacles: 0,
  },
  surprised: {
    defaultMouth: 0,
    mouthOh: 1,
  },
  thinking: {
    hmmLids: 1,
    defaultMouth: 0.6,
  },
  "side-eye": {
    leftDefaultEye: 0,
    rightDefaultEye: 0,
    sideEye: 1,
    defaultMouth: 0.7,
  },
  shy: {
    leftDefaultEye: 0,
    rightDefaultEye: 0,
    defaultMouth: 0,
    passwordFace: 0,
    shyFace: 1,
    spectacles: 0,
    cheekOpacity: 1,
  },
}

export function getRabbitMoodLayers(mood: RabbitMood): RabbitMoodLayers {
  return { ...NEUTRAL_LAYERS, ...MOOD_LAYER_MAP[mood] }
}

function setOpacity(el: Element | null, opacity: number) {
  if (!el) return
  el.setAttribute("opacity", String(opacity))
}

function setStyleTransform(el: Element | null, transform: string) {
  if (!(el instanceof SVGElement)) return
  el.style.transform = transform
  el.style.transformBox = "fill-box"
  el.style.transformOrigin = "50% 50%"
}

function setTransition(el: Element | null) {
  if (!(el instanceof SVGElement)) return
  el.style.transition =
    "opacity 0.28s ease, transform 0.28s ease, stroke-opacity 0.28s ease"
}

export function applyRabbitMood(root: ParentNode | null, mood: RabbitMood) {
  if (!root) return

  const layers = getRabbitMoodLayers(mood)

  const leftEye = root.querySelector("#left-eye")
  const rightEye = root.querySelector("#right-eye")
  const leftEyeContent = leftEye?.querySelector(":scope > g > g > g")
  const rightEyeContent = rightEye?.querySelector(":scope > g > g > g")

  if (leftEyeContent instanceof SVGElement) {
    setTransition(leftEyeContent)
    setOpacity(leftEyeContent, layers.leftDefaultEye)
  }

  if (rightEyeContent instanceof SVGElement) {
    setTransition(rightEyeContent)
    setOpacity(rightEyeContent, layers.rightDefaultEye)
  }

  const targets: [string, number][] = [
    ["#password-face", layers.passwordFace],
    ["#shy-face", layers.shyFace],
    ["#side-eye-eyes", layers.sideEye],
    ["#hmm-lids", layers.hmmLids],
    ["#sad-brows", layers.sadBrows],
    ["#happy-open-mouth", layers.happyOpenMouth],
    ["#mouth", layers.defaultMouth],
    ["#mouth-sad", layers.sadMouth],
    ["#mouth-oh", layers.mouthOh],
    ["#emotion-fx", layers.emotionFx],
    ["#sad-tears", layers.sadTears],
    ["#left-wink", layers.leftWink],
    ["#right-wink", layers.rightWink],
    ["#left-cheek", layers.cheekOpacity],
    ["#right-cheek", layers.cheekOpacity],
    ["#spectacles", layers.spectacles],
  ]

  for (const [selector, opacity] of targets) {
    const el = root.querySelector(selector)
    setTransition(el)
    setOpacity(el, opacity)
  }

  const sadTearPaths = root.querySelectorAll("#sad-tears path")
  sadTearPaths.forEach((path) => {
    setTransition(path)
    setOpacity(path, layers.sadTears)
  })

  const emotionSparkles = root.querySelector("#emotion-fx > g:not(#sad-tears)")
  if (emotionSparkles) {
    setTransition(emotionSparkles)
    setOpacity(emotionSparkles, layers.emotionFx)
  }

  const arcTargets: [string, number][] = [
    ["#left-eye-happy-arc", layers.leftHappyArc],
    ["#right-eye-happy-arc", layers.rightHappyArc],
  ]

  for (const [selector, active] of arcTargets) {
    const el = root.querySelector(selector)
    setTransition(el)
    setOpacity(el, active)
    setStyleTransform(el, active ? "scaleY(1)" : "scaleY(0.4)")
  }

  const starTargets: [string, number][] = [
    ["#left-eye-star", layers.leftEyeStar],
    ["#right-eye-star", layers.rightEyeStar],
    ["#left-eye-want-twinkle", layers.leftWantTwinkle],
    ["#right-eye-want-twinkle", layers.rightWantTwinkle],
    ["#happy-twinkle", layers.happyTwinkle],
  ]

  for (const [selector, active] of starTargets) {
    const el = root.querySelector(selector)
    setTransition(el)
    setOpacity(el, active)
    setStyleTransform(el, active ? "scale(1)" : "scale(0.5)")
  }

  setStyleTransform(
    root.querySelector("#mouth-oh"),
    layers.mouthOh ? "scale(1)" : "scaleX(0.72) scaleY(0.35)"
  )

  const ariaLabel = `Cute rabbit boy mascot, ${RABBIT_MOOD_LABELS[mood].toLowerCase()}`
  const svg = root instanceof SVGSVGElement ? root : root.querySelector("svg")
  svg?.setAttribute("aria-label", ariaLabel)

  setOpacity(root.querySelector("#blink-lids"), 0)
  setOpacity(root.querySelector("#left-blink-lid"), 0)
  setOpacity(root.querySelector("#right-blink-lid"), 0)
}
registry/gammaui/rabbit-mascot-blink.ts
import {
  getRabbitMoodLayers,
  type RabbitMood,
} from "@/registry/gammaui/rabbit-mascot-moods"

const BLINK_CLOSE_MS = 140
const BLINK_MIN_INTERVAL_MS = 2600
const BLINK_MAX_INTERVAL_MS = 5200

interface BlinkEyeState {
  left: boolean
  right: boolean
}

/** Moods where both round eyes stay open (not arcs, winks, or alternate face art). */
export function supportsRabbitBlink(mood: RabbitMood): boolean {
  const layers = getRabbitMoodLayers(mood)

  return (
    layers.leftDefaultEye === 1 &&
    layers.rightDefaultEye === 1 &&
    layers.leftHappyArc === 0 &&
    layers.rightHappyArc === 0 &&
    layers.shyFace === 0 &&
    layers.sideEye === 0 &&
    layers.passwordFace === 0 &&
    layers.leftWink === 0 &&
    layers.rightWink === 0
  )
}

function getBlinkEyeState(mood: RabbitMood): BlinkEyeState {
  if (!supportsRabbitBlink(mood)) return { left: false, right: false }
  return { left: true, right: true }
}

function setBlinkLayerOpacity(el: Element | null | undefined, opacity: number) {
  if (!el) return
  el.setAttribute("opacity", String(opacity))
}

export function triggerRabbitBlink(root: ParentNode | null, mood: RabbitMood) {
  if (!root || !supportsRabbitBlink(mood)) return

  const { left, right } = getBlinkEyeState(mood)
  if (!left && !right) return

  const blinkLids = root.querySelector("#blink-lids")
  const leftBlinkLid = root.querySelector("#left-blink-lid")
  const rightBlinkLid = root.querySelector("#right-blink-lid")
  const leftEye = root.querySelector("#left-eye")
  const rightEye = root.querySelector("#right-eye")
  const leftEyeContent = leftEye?.querySelector(":scope > g > g > g")
  const rightEyeContent = rightEye?.querySelector(":scope > g > g > g")
  const leftEyeStar = root.querySelector("#left-eye-star")
  const rightEyeStar = root.querySelector("#right-eye-star")
  const leftWantTwinkle = root.querySelector("#left-eye-want-twinkle")
  const rightWantTwinkle = root.querySelector("#right-eye-want-twinkle")

  setBlinkLayerOpacity(blinkLids, 1)
  setBlinkLayerOpacity(leftBlinkLid, 1)
  setBlinkLayerOpacity(rightBlinkLid, 1)
  setBlinkLayerOpacity(leftEyeContent, 0)
  setBlinkLayerOpacity(rightEyeContent, 0)
  setBlinkLayerOpacity(leftEyeStar, 0)
  setBlinkLayerOpacity(rightEyeStar, 0)
  setBlinkLayerOpacity(leftWantTwinkle, 0)
  setBlinkLayerOpacity(rightWantTwinkle, 0)

  window.setTimeout(() => {
    const layers = getRabbitMoodLayers(mood)

    setBlinkLayerOpacity(blinkLids, 0)
    setBlinkLayerOpacity(leftBlinkLid, 0)
    setBlinkLayerOpacity(rightBlinkLid, 0)
    setBlinkLayerOpacity(leftEyeContent, layers.leftDefaultEye)
    setBlinkLayerOpacity(rightEyeContent, layers.rightDefaultEye)
    setBlinkLayerOpacity(leftEyeStar, layers.leftEyeStar)
    setBlinkLayerOpacity(rightEyeStar, layers.rightEyeStar)
    setBlinkLayerOpacity(leftWantTwinkle, layers.leftWantTwinkle)
    setBlinkLayerOpacity(rightWantTwinkle, layers.rightWantTwinkle)
  }, BLINK_CLOSE_MS)
}

function randomBlinkDelayMs() {
  return (
    BLINK_MIN_INTERVAL_MS +
    Math.random() * (BLINK_MAX_INTERVAL_MS - BLINK_MIN_INTERVAL_MS)
  )
}

export function startRabbitBlinkLoop(
  root: ParentNode | null,
  getMood: () => RabbitMood
) {
  if (!root) return () => {}

  let timeoutId = window.setTimeout(function scheduleBlink() {
    const mood = getMood()
    if (supportsRabbitBlink(mood)) triggerRabbitBlink(root, mood)
    timeoutId = window.setTimeout(scheduleBlink, randomBlinkDelayMs())
  }, randomBlinkDelayMs())

  return () => window.clearTimeout(timeoutId)
}
registry/gammaui/rabbit-mascot-arm.ts
function setOpacity(el: Element | null, opacity: number) {
  if (!el) return
  el.setAttribute("opacity", String(opacity))
}

export function applyRabbitArmReach(
  root: ParentNode | null,
  reaching: boolean
) {
  if (!root) return

  const rightArm = root.querySelector("#right-arm")
  const reachArm = root.querySelector("#right-arm-reach")

  if (rightArm instanceof SVGElement) {
    rightArm.style.transition = reaching ? "none" : "opacity 0.2s ease"
  }

  setOpacity(rightArm, reaching ? 0 : 1)
  setOpacity(reachArm, 0)
}

More from gammaui

Browse all components in this library →