{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "face-rating",
  "title": "Face Rating",
  "description": "A five-level feedback rating where one large SVG face morphs its mouth, eyes and color between moods as you hover and click, with a joy bounce on commit",
  "dependencies": [
    "framer-motion"
  ],
  "files": [
    {
      "path": "app/registry/face-rating/face-rating.tsx",
      "content": "/**\n * Spectrum UI — FaceRating\n *\n * A five-level feedback rating where one large SVG face morphs between moods.\n * Hovering a segment previews its mood — the mouth path, eye squash, eyebrows\n * and stroke color all spring to the new level — and clicking commits it with\n * a quick joy bounce. Works controlled or uncontrolled, moves with\n * ArrowLeft/ArrowRight as a radio group, and honors prefers-reduced-motion.\n *\n * Dependencies: framer-motion, @/lib/utils\n *\n * @example\n * <FaceRating onValueChange={(value) => save(value)} />\n */\n\n\"use client\"\n\nimport React, { useCallback, useRef, useState } from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"framer-motion\"\nimport { cn } from \"@/lib/utils\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface FaceRatingProps {\n  /** Controlled rating from 1-5; 0 means unset. Leave undefined for uncontrolled usage */\n  value?: number\n  /** Initial rating when uncontrolled. Default 0 (unset) */\n  defaultValue?: number\n  /** Fires with the next rating on every commit */\n  onValueChange?: (value: number) => void\n  /** Mood names for levels 1-5, shown under the widget and read to screen readers */\n  labels?: [string, string, string, string, string]\n  /** Show the mood label under the segments. Default true */\n  showLabel?: boolean\n  /** Visual size of the widget. Default \"md\" */\n  size?: \"sm\" | \"md\" | \"lg\"\n  /** Accessible name of the radio group. Default \"How was your experience?\" */\n  label?: string\n  className?: string\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\nconst LEVELS = [1, 2, 3, 4, 5] as const\n\nconst DEFAULT_LABELS: [string, string, string, string, string] = [\n  \"Terrible\",\n  \"Bad\",\n  \"Okay\",\n  \"Good\",\n  \"Amazing\",\n]\n\n/** rose-500, orange-500, amber-400, lime-500, emerald-500 */\nconst LEVEL_COLORS = [\"#f43f5e\", \"#f97316\", \"#fbbf24\", \"#84cc16\", \"#10b981\"]\n\n/** neutral-400 — face color while nothing is hovered or committed */\nconst UNSET_COLOR = \"#a3a3a3\"\n\n/** Mouth control-point Y per level: deep frown → flat → big smile */\nconst MOUTH_CONTROL_Y = [34, 41, 47, 54, 61]\n\n/** Mouth corner Y per level: corners droop when upset, lift when delighted */\nconst MOUTH_CORNER_Y = [49.5, 48, 47, 46, 44.5]\n\n/** Eye squash per level: narrowed when upset, wide open when amazed */\nconst EYE_SCALE_Y = [0.45, 0.6, 0.9, 1, 1.25]\n\n/** Softer, organic spring shared by the mouth morph and every color change */\nconst MORPH_SPRING = { type: \"spring\", stiffness: 260, damping: 22 } as const\n\n/** Snappy spring for segment press feedback and the checked-ring pop */\nconst SPRING_SNAPPY = { type: \"spring\", stiffness: 500, damping: 30 } as const\n\n/** Eyes and eyebrows begin this long after the mouth, in seconds */\nconst FEATURE_STAGGER = 0.03\n\n/** Ease for the mood label crossfade */\nconst LABEL_TRANSITION = { duration: 0.18, ease: [0.22, 1, 0.36, 1] } as const\n\nconst SIZES = {\n  sm: {\n    face: 56,\n    root: \"gap-2\",\n    row: \"gap-1.5\",\n    segment: \"h-6 w-6 text-[10px]\",\n    labelBox: \"h-4\",\n    labelText: \"text-xs\",\n  },\n  md: {\n    face: 72,\n    root: \"gap-3\",\n    row: \"gap-2\",\n    segment: \"h-7 w-7 text-xs\",\n    labelBox: \"h-5\",\n    labelText: \"text-sm\",\n  },\n  lg: {\n    face: 96,\n    root: \"gap-4\",\n    row: \"gap-2.5\",\n    segment: \"h-9 w-9 text-sm\",\n    labelBox: \"h-6\",\n    labelText: \"text-base\",\n  },\n} as const\n\n/** All five mouths share one command structure (M x y Q x y x y) so\n *  framer-motion interpolates them smoothly */\nfunction mouthPath(level: number) {\n  const corner = MOUTH_CORNER_Y[level - 1]\n  return `M 22 ${corner} Q 36 ${MOUTH_CONTROL_Y[level - 1]} 50 ${corner}`\n}\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function FaceRating({\n  value: valueProp,\n  defaultValue = 0,\n  onValueChange,\n  labels = DEFAULT_LABELS,\n  showLabel = true,\n  size = \"md\",\n  label = \"How was your experience?\",\n  className,\n}: FaceRatingProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const [internalValue, setInternalValue] = useState(defaultValue)\n  const [hovered, setHovered] = useState(0)\n  const [bouncing, setBouncing] = useState(false)\n  const segmentRefs = useRef<(HTMLButtonElement | null)[]>([])\n\n  const value = valueProp ?? internalValue\n  // The face previews the hovered level and falls back to the committed value\n  const active = hovered || value\n  const level = active || 3\n  const faceColor = active ? LEVEL_COLORS[active - 1] : UNSET_COLOR\n  const sizes = SIZES[size]\n\n  const spring = shouldReduceMotion ? { duration: 0 } : MORPH_SPRING\n  // Eyes and eyebrows trail the mouth slightly so the face reads organically;\n  // they share the morph spring, so every feature (and color) moves in step\n  const featureTransition = shouldReduceMotion\n    ? { duration: 0 }\n    : { ...MORPH_SPRING, delay: FEATURE_STAGGER }\n\n  const commit = useCallback(\n    (next: number) => {\n      if (valueProp === undefined) setInternalValue(next)\n      onValueChange?.(next)\n      if (!shouldReduceMotion) setBouncing(true)\n    },\n    [valueProp, onValueChange, shouldReduceMotion],\n  )\n\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent<HTMLButtonElement>) => {\n      let next: number\n      if (event.key === \"ArrowRight\" || event.key === \"ArrowUp\") {\n        next = Math.min(5, value + 1)\n      } else if (event.key === \"ArrowLeft\" || event.key === \"ArrowDown\") {\n        next = value === 0 ? 1 : Math.max(1, value - 1)\n      } else {\n        return\n      }\n      event.preventDefault()\n      segmentRefs.current[next - 1]?.focus()\n      if (next !== value) commit(next)\n    },\n    [value, commit],\n  )\n\n  return (\n    <div\n      className={cn(\"flex flex-col items-center\", sizes.root, className)}\n    >\n      {/* Morphing face — decorative; the radio group below carries the state */}\n      <motion.div\n        aria-hidden=\"true\"\n        initial={false}\n        animate={\n          bouncing\n            ? { y: [0, -6, 0], scale: [1, 1.08, 1] }\n            : { y: 0, scale: 1 }\n        }\n        transition={\n          bouncing\n            ? { duration: 0.45, times: [0, 0.4, 1], ease: \"easeOut\" }\n            : { duration: 0 }\n        }\n        onAnimationComplete={() => setBouncing(false)}\n      >\n        <svg\n          viewBox=\"0 0 72 72\"\n          width={sizes.face}\n          height={sizes.face}\n          fill=\"none\"\n          strokeLinecap=\"round\"\n        >\n          {/* Face outline */}\n          <motion.circle\n            cx={36}\n            cy={36}\n            r={30}\n            strokeWidth={4}\n            initial={false}\n            animate={{ stroke: faceColor }}\n            transition={spring}\n          />\n          {/* Eyebrows — only surface while upset (levels 1-2) */}\n          <motion.line\n            x1={19}\n            y1={19}\n            x2={30}\n            y2={23}\n            strokeWidth={3}\n            initial={false}\n            animate={{ stroke: faceColor, opacity: level <= 2 ? 1 : 0 }}\n            transition={featureTransition}\n          />\n          <motion.line\n            x1={42}\n            y1={23}\n            x2={53}\n            y2={19}\n            strokeWidth={3}\n            initial={false}\n            animate={{ stroke: faceColor, opacity: level <= 2 ? 1 : 0 }}\n            transition={featureTransition}\n          />\n          {/* Eyes */}\n          <motion.ellipse\n            cx={25}\n            cy={30}\n            rx={3.5}\n            ry={4}\n            style={{ originX: 0.5, originY: 0.5 }}\n            initial={false}\n            animate={{ fill: faceColor, scaleY: EYE_SCALE_Y[level - 1] }}\n            transition={featureTransition}\n          />\n          <motion.ellipse\n            cx={47}\n            cy={30}\n            rx={3.5}\n            ry={4}\n            style={{ originX: 0.5, originY: 0.5 }}\n            initial={false}\n            animate={{ fill: faceColor, scaleY: EYE_SCALE_Y[level - 1] }}\n            transition={featureTransition}\n          />\n          {/* Mouth — the core morph between frown, flat and smile */}\n          <motion.path\n            strokeWidth={4}\n            initial={false}\n            animate={{ stroke: faceColor, d: mouthPath(level) }}\n            transition={spring}\n          />\n        </svg>\n      </motion.div>\n\n      {/* Segments */}\n      <div\n        role=\"radiogroup\"\n        aria-label={label}\n        className={cn(\"flex items-center\", sizes.row)}\n        onMouseLeave={() => setHovered(0)}\n      >\n        {LEVELS.map((segmentLevel) => {\n          const isActive = value === segmentLevel\n          const color = LEVEL_COLORS[segmentLevel - 1]\n          return (\n            <motion.button\n              key={segmentLevel}\n              ref={(node) => {\n                segmentRefs.current[segmentLevel - 1] = node\n              }}\n              type=\"button\"\n              role=\"radio\"\n              aria-checked={isActive}\n              aria-label={`Rate ${segmentLevel} of 5 — ${labels[segmentLevel - 1]}`}\n              tabIndex={isActive || (value === 0 && segmentLevel === 1) ? 0 : -1}\n              onClick={() => commit(segmentLevel)}\n              onKeyDown={handleKeyDown}\n              onMouseEnter={() => setHovered(segmentLevel)}\n              whileTap={shouldReduceMotion ? undefined : { scale: 0.88 }}\n              transition={shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY}\n              className={cn(\n                \"relative inline-flex touch-manipulation select-none items-center justify-center rounded-full border font-medium tabular-nums transition-colors\",\n                \"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n                sizes.segment,\n                isActive\n                  ? \"border-transparent\"\n                  : \"border-neutral-200 bg-white text-neutral-500 hover:border-neutral-300 hover:bg-neutral-50 hover:text-neutral-700 active:bg-neutral-100 dark:border-neutral-800 dark:bg-neutral-900 dark:text-neutral-400 dark:hover:border-neutral-700 dark:hover:bg-neutral-800/60 dark:hover:text-neutral-200 dark:active:bg-neutral-800\",\n              )}\n              style={isActive ? { color } : undefined}\n            >\n              <AnimatePresence initial={false}>\n                {isActive && (\n                  <motion.span\n                    aria-hidden=\"true\"\n                    className=\"absolute inset-0 rounded-full\"\n                    style={{\n                      backgroundColor: `${color}1f`,\n                      boxShadow: `inset 0 0 0 1px ${color}`,\n                    }}\n                    initial={\n                      shouldReduceMotion ? false : { scale: 0.4, opacity: 0 }\n                    }\n                    animate={{ scale: 1, opacity: 1 }}\n                    exit={\n                      shouldReduceMotion\n                        ? { opacity: 0, transition: { duration: 0 } }\n                        : { scale: 0.4, opacity: 0 }\n                    }\n                    transition={\n                      shouldReduceMotion ? { duration: 0 } : SPRING_SNAPPY\n                    }\n                  />\n                )}\n              </AnimatePresence>\n              <span className=\"relative\">{segmentLevel}</span>\n            </motion.button>\n          )\n        })}\n      </div>\n\n      {/* Mood label */}\n      {showLabel && (\n        <div\n          className={cn(\n            \"relative flex items-center justify-center overflow-hidden\",\n            sizes.labelBox,\n          )}\n        >\n          {/* popLayout crossfades the labels so hover → leave never flashes\n              an empty frame while settling back to the committed value */}\n          <AnimatePresence mode=\"popLayout\" initial={false}>\n            {active > 0 && (\n              <motion.span\n                key={active}\n                className={cn(\n                  \"font-medium text-neutral-500 dark:text-neutral-400\",\n                  sizes.labelText,\n                )}\n                initial={{ opacity: 0, y: 4 }}\n                animate={{ opacity: 1, y: 0 }}\n                exit={{ opacity: 0, y: -4 }}\n                transition={\n                  shouldReduceMotion ? { duration: 0 } : LABEL_TRANSITION\n                }\n              >\n                {labels[active - 1]}\n              </motion.span>\n            )}\n          </AnimatePresence>\n        </div>\n      )}\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/face-rating.tsx"
    }
  ],
  "type": "registry:component"
}
