{
	"$schema": "https://ui.shadcn.com/schema/registry-item.json",
	"name": "PeekRating-TS-CSS",
	"title": "PeekRating",
	"description": "Star rating that lifts a wave of stars up to the pointer, then commits with a pop on click.",
	"type": "registry:component",
	"files": [
		{
			"type": "registry:file",
			"path": "PeekRating.css",
			"target": "@components/PeekRating.css",
			"content": ".peek-rating {\n  --pr-active: #f5b400;\n  --pr-idle: #52525b;\n  --pr-tip: #27272a;\n  --pr-tip-text: #f5f5f5;\n  --pr-size: 28px;\n  --pr-gap: 6px;\n  --pr-room: 31px;\n  --pr-rise: 320ms;\n  --pr-ease-out: cubic-bezier(0.23, 1, 0.32, 1);\n\n  display: inline-flex;\n  font-family: inherit;\n  color: inherit;\n}\n\n.peek-rating[aria-disabled='true'] {\n  opacity: 0.5;\n  pointer-events: none;\n}\n\n.peek-rating__row {\n  position: relative;\n  display: inline-flex;\n  align-items: flex-end;\n  padding-top: var(--pr-room);\n  touch-action: pan-y;\n  user-select: none;\n  -webkit-user-select: none;\n  -webkit-touch-callout: none;\n  -webkit-tap-highlight-color: transparent;\n}\n\n.peek-rating__star {\n  display: grid;\n  place-items: center;\n  width: calc(var(--pr-size) + var(--pr-gap));\n  height: calc(var(--pr-size) + 8px);\n  margin: 0;\n  padding: 0;\n  border: 0;\n  background: none;\n  color: inherit;\n  font: inherit;\n  outline: none;\n  touch-action: manipulation;\n}\n\n@media (hover: hover) and (pointer: fine) {\n  .peek-rating__star {\n    cursor: pointer;\n  }\n}\n\n@media (pointer: coarse) {\n  .peek-rating__star {\n    min-width: 44px;\n    min-height: 44px;\n  }\n}\n\n.peek-rating__star:focus-visible {\n  outline: 2px solid color-mix(in srgb, var(--pr-active) 70%, transparent);\n  outline-offset: 2px;\n  border-radius: 6px;\n}\n\n.peek-rating__lift {\n  display: block;\n  transform: translateY(0) scale(1);\n  transform-origin: 50% 100%;\n  transition: transform var(--pr-rise) var(--pr-ease-out);\n}\n\n.peek-rating__glyph {\n  display: block;\n  width: var(--pr-size);\n  height: var(--pr-size);\n  color: var(--pr-idle);\n  transition: color 160ms ease;\n}\n\n.peek-rating__glyph > svg {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n\n.peek-rating__glyph[data-lit='true'] {\n  color: var(--pr-active);\n}\n\n.peek-rating__tip {\n  position: absolute;\n  top: 0;\n  left: 0;\n  display: inline-flex;\n  align-items: center;\n  height: calc(var(--pr-size) * 0.6);\n  padding: 0 calc(var(--pr-size) * 0.3);\n  border-radius: 999px;\n  background: var(--pr-tip);\n  color: var(--pr-tip-text);\n  font-size: calc(var(--pr-size) * 0.38);\n  font-weight: 600;\n  line-height: 1;\n  letter-spacing: 0.01em;\n  white-space: nowrap;\n  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.18);\n  opacity: 0;\n  pointer-events: none;\n  transition:\n    transform var(--pr-rise) var(--pr-ease-out),\n    opacity 180ms ease;\n}\n\n.peek-rating__tip[data-show='true'] {\n  opacity: 1;\n}\n\n.peek-rating[data-instant='true'] .peek-rating__lift,\n.peek-rating[data-instant='true'] .peek-rating__glyph,\n.peek-rating[data-instant='true'] .peek-rating__tip {\n  transition-duration: 0ms;\n}\n\n@media (prefers-reduced-motion: reduce) {\n  .peek-rating__lift {\n    transition: none;\n  }\n\n  .peek-rating__tip {\n    transition: opacity 180ms ease;\n  }\n}\n"
		},
		{
			"type": "registry:component",
			"path": "PeekRating.tsx",
			"content": "'use client';\n\nimport React, { useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';\nimport { HugeiconsIcon, type IconSvgElement } from '@hugeicons/react';\nimport { FavouriteIcon, FlashIcon, StarIcon } from '@hugeicons/core-free-icons';\n\nimport './PeekRating.css';\n\nexport type PeekRatingShape = 'star' | 'heart' | 'bolt';\n\nexport interface PeekRatingProps {\n  value?: number;\n  defaultValue?: number;\n  onChange?: (value: number) => void;\n  onPreview?: (value: number | null) => void;\n  count?: number;\n  shape?: PeekRatingShape;\n  icon?: ReactNode;\n  labels?: string[];\n  activeColor?: string;\n  idleColor?: string;\n  tipColor?: string;\n  tipTextColor?: string;\n  size?: number;\n  lift?: number;\n  magnify?: number;\n  riseDuration?: number;\n  popScale?: number;\n  showTip?: boolean;\n  allowClear?: boolean;\n  readOnly?: boolean;\n  disabled?: boolean;\n  ariaLabel?: string;\n  className?: string;\n}\n\ninterface GestureState {\n  hover: number | null;\n  pressing: boolean;\n  pointerId: number | null;\n  settled: boolean;\n  rect: DOMRect | null;\n  rtl: boolean;\n}\n\nconst EASE_OUT = 'cubic-bezier(0.23, 1, 0.32, 1)';\nconst SHAPES: Record<PeekRatingShape, IconSvgElement> = { star: StarIcon, heart: FavouriteIcon, bolt: FlashIcon };\n\nconst clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value));\nconst reducedMotion = () =>\n  typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;\n\nconst PeekRating: React.FC<PeekRatingProps> = ({\n  value: valueProp,\n  defaultValue = 0,\n  onChange,\n  onPreview,\n  count = 5,\n  shape = 'star',\n  icon,\n  labels = [],\n  activeColor = '#f5b400',\n  idleColor = '#52525b',\n  tipColor = '#27272a',\n  tipTextColor = '#f5f5f5',\n  size = 28,\n  lift = 6,\n  magnify = 1.15,\n  riseDuration = 320,\n  popScale = 1.3,\n  showTip = true,\n  allowClear = true,\n  readOnly = false,\n  disabled = false,\n  ariaLabel = 'Rating',\n  className = ''\n}) => {\n  const [inner, setInner] = useState(defaultValue);\n  const value = clamp(valueProp ?? inner, 0, count);\n  const interactive = !readOnly && !disabled;\n\n  const rootRef = useRef<HTMLDivElement>(null);\n  const rowRef = useRef<HTMLDivElement>(null);\n  const tipEl = useRef<HTMLSpanElement>(null);\n  const starEls = useRef<(HTMLElement | null)[]>([]);\n  const liftEls = useRef<(HTMLSpanElement | null)[]>([]);\n  const glyphEls = useRef<(HTMLSpanElement | null)[]>([]);\n  const st = useRef<GestureState>({\n    hover: null,\n    pressing: false,\n    pointerId: null,\n    settled: false,\n    rect: null,\n    rtl: false\n  });\n\n  const paint = () => {\n    const { hover, settled, rtl } = st.current;\n    const previewing = hover !== null && !settled;\n    const shown = previewing ? hover + 1 : value;\n    const still = reducedMotion();\n\n    for (let i = 0; i < count; i++) {\n      const liftEl = liftEls.current[i];\n      const glyphEl = glyphEls.current[i];\n      if (!liftEl || !glyphEl) continue;\n      const lifted = previewing && !still && i <= hover;\n      liftEl.style.transform = lifted\n        ? `translateY(${-lift}px) scale(${i === hover ? magnify : 1})`\n        : 'translateY(0px) scale(1)';\n      glyphEl.dataset.lit = String(i < shown);\n    }\n\n    const tip = tipEl.current;\n    if (!tip) return;\n    if (previewing && showTip) {\n      const row = rowRef.current;\n      const slot = row ? row.clientWidth / count : size;\n      const visual = rtl ? count - 1 - hover : hover;\n      const wasHidden = tip.dataset.show !== 'true';\n      if (wasHidden) tip.style.transition = 'none';\n      tip.textContent = labels[hover] ?? String(hover + 1);\n      tip.style.transform = `translate(calc(${slot * (visual + 0.5)}px - 50%), 0)`;\n      if (wasHidden) {\n        void tip.offsetWidth;\n        tip.style.transition = '';\n      }\n      tip.dataset.show = 'true';\n    } else {\n      tip.dataset.show = 'false';\n    }\n  };\n  useLayoutEffect(paint);\n\n  const setHover = (index: number | null) => {\n    if (index === st.current.hover) return;\n    st.current.hover = index;\n    if (index !== null) st.current.settled = false;\n    paint();\n    onPreview?.(index === null ? null : index + 1);\n  };\n  const setHoverRef = useRef(setHover);\n  setHoverRef.current = setHover;\n\n  const measure = () => {\n    const row = rowRef.current;\n    if (!row) return;\n    st.current.rect = row.getBoundingClientRect();\n    st.current.rtl = getComputedStyle(row).direction === 'rtl';\n  };\n\n  const indexAt = (x: number, y: number): number | null => {\n    const { rect, pressing, rtl } = st.current;\n    if (!rect || !rect.width) return null;\n    if (pressing && (y < rect.top - size || y > rect.bottom + size)) return null;\n    const index = clamp(Math.floor(((x - rect.left) / rect.width) * count), 0, count - 1);\n    return rtl ? count - 1 - index : index;\n  };\n\n  const commit = (next: number, pop = true) => {\n    if (valueProp === undefined) setInner(next);\n    onChange?.(next);\n    st.current.settled = true;\n    paint();\n    const glyph = glyphEls.current[next - 1];\n    if (pop && next > 0 && popScale > 1 && glyph && typeof glyph.animate === 'function' && !reducedMotion()) {\n      glyph.getAnimations().forEach(animation => animation.cancel());\n      glyph.animate(\n        [\n          { transform: 'scale(1)', easing: EASE_OUT },\n          { transform: `scale(${popScale})`, offset: 0.35, easing: EASE_OUT },\n          { transform: 'scale(1)' }\n        ],\n        { duration: 300 }\n      );\n    }\n  };\n\n  const handlePointerEnter = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!interactive || e.pointerType !== 'mouse') return;\n    rootRef.current?.removeAttribute('data-instant');\n    measure();\n  };\n\n  const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!interactive || e.button !== 0 || st.current.pointerId !== null) return;\n    rootRef.current?.removeAttribute('data-instant');\n    try {\n      e.currentTarget.setPointerCapture(e.pointerId);\n    } catch {}\n    st.current.pointerId = e.pointerId;\n    st.current.pressing = true;\n    measure();\n    setHover(indexAt(e.clientX, e.clientY));\n  };\n\n  const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {\n    if (!interactive) return;\n    const { pressing, pointerId } = st.current;\n    if (e.pointerType !== 'mouse' && !pressing) return;\n    if (pressing && e.pointerId !== pointerId) return;\n    if (!pressing && !st.current.rect) measure();\n    setHover(indexAt(e.clientX, e.clientY));\n  };\n\n  const endPress = (e: React.PointerEvent<HTMLDivElement>) => {\n    const { pressing, pointerId, hover } = st.current;\n    if (!pressing || e.pointerId !== pointerId) return;\n    st.current.pressing = false;\n    st.current.pointerId = null;\n    if (e.type === 'pointerup' && hover !== null) {\n      const next = hover + 1;\n      commit(allowClear && next === value ? 0 : next);\n    }\n    if (e.pointerType !== 'mouse') setHover(null);\n  };\n\n  const handlePointerLeave = () => {\n    if (!st.current.pressing) setHover(null);\n  };\n\n  const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {\n    if (!interactive) return;\n    const min = allowClear ? 0 : 1;\n    let next: number;\n    switch (e.key) {\n      case 'ArrowRight':\n      case 'ArrowUp':\n        next = clamp(value + 1, min, count);\n        break;\n      case 'ArrowLeft':\n      case 'ArrowDown':\n        next = clamp(value - 1, min, count);\n        break;\n      case 'Home':\n        next = 1;\n        break;\n      case 'End':\n        next = count;\n        break;\n      case 'Backspace':\n      case 'Delete':\n        if (!allowClear) return;\n        next = 0;\n        break;\n      case ' ':\n      case 'Enter': {\n        const index = starEls.current.indexOf(e.target as HTMLElement);\n        if (index === -1) return;\n        next = allowClear && index + 1 === value ? 0 : index + 1;\n        break;\n      }\n      default:\n        return;\n    }\n    e.preventDefault();\n    rootRef.current?.setAttribute('data-instant', 'true');\n    st.current.hover = null;\n    commit(next, false);\n    starEls.current[Math.max(next, 1) - 1]?.focus();\n  };\n\n  useEffect(() => {\n    const reset = () => {\n      st.current.pressing = false;\n      st.current.pointerId = null;\n      setHoverRef.current(null);\n    };\n    const onVisibility = () => {\n      if (document.hidden) reset();\n    };\n    document.addEventListener('visibilitychange', onVisibility);\n    window.addEventListener('blur', reset);\n    return () => {\n      document.removeEventListener('visibilitychange', onVisibility);\n      window.removeEventListener('blur', reset);\n    };\n  }, []);\n\n  const Star = (readOnly ? 'span' : 'button') as 'button';\n  const shapeIcon = SHAPES[shape] || SHAPES.star;\n  const tipRoom = interactive && showTip ? Math.round(size * 0.9) : 0;\n\n  const cssVars = {\n    '--pr-active': activeColor,\n    '--pr-idle': idleColor,\n    '--pr-tip': tipColor,\n    '--pr-tip-text': tipTextColor,\n    '--pr-size': `${size}px`,\n    '--pr-gap': `${Math.round(size * 0.22)}px`,\n    '--pr-room': `${lift + tipRoom}px`,\n    '--pr-rise': `${riseDuration}ms`\n  } as CSSProperties;\n\n  return (\n    <div\n      ref={rootRef}\n      role={readOnly ? 'img' : 'radiogroup'}\n      aria-label={readOnly ? `${value} of ${count}` : ariaLabel}\n      aria-disabled={disabled || undefined}\n      className={`peek-rating${className ? ` ${className}` : ''}`}\n      style={cssVars}\n      onKeyDown={readOnly ? undefined : handleKeyDown}\n    >\n      <div\n        ref={rowRef}\n        className=\"peek-rating__row\"\n        onPointerEnter={handlePointerEnter}\n        onPointerDown={handlePointerDown}\n        onPointerMove={handlePointerMove}\n        onPointerUp={endPress}\n        onPointerCancel={endPress}\n        onLostPointerCapture={endPress}\n        onPointerLeave={handlePointerLeave}\n      >\n        {interactive && showTip ? <span ref={tipEl} className=\"peek-rating__tip\" aria-hidden=\"true\" /> : null}\n        {Array.from({ length: count }, (_, i) => {\n          const checked = value === i + 1;\n          const label = labels[i] ? `${i + 1} of ${count}, ${labels[i]}` : `${i + 1} of ${count}`;\n          return (\n            <Star\n              key={i}\n              ref={(el: HTMLElement | null) => {\n                starEls.current[i] = el;\n              }}\n              type={readOnly ? undefined : 'button'}\n              className=\"peek-rating__star\"\n              role={readOnly ? undefined : 'radio'}\n              aria-checked={readOnly ? undefined : checked}\n              aria-label={readOnly ? undefined : label}\n              aria-hidden={readOnly || undefined}\n              tabIndex={!interactive ? -1 : (value === 0 ? i === 0 : checked) ? 0 : -1}\n            >\n              <span\n                ref={el => {\n                  liftEls.current[i] = el;\n                }}\n                className=\"peek-rating__lift\"\n              >\n                <span\n                  ref={el => {\n                    glyphEls.current[i] = el;\n                  }}\n                  className=\"peek-rating__glyph\"\n                >\n                  {icon ?? <HugeiconsIcon icon={shapeIcon} size={size} fill=\"currentColor\" strokeWidth={1.5} />}\n                </span>\n              </span>\n            </Star>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n\nexport default PeekRating;\n"
		}
	],
	"registryDependencies": [],
	"dependencies": [
		"@hugeicons/react@^1.1.10",
		"@hugeicons/core-free-icons@^4.3.3"
	]
}