{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"review-cards-write","type":"registry:component","title":"Review cards · Write a review","description":"Star radio group with hover preview and spring pop, quick tags, counted text box and a thank-you crossfade.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":[],"files":[{"path":"registry/geturui/examples/review-cards/write.tsx","type":"registry:component","target":"components/examples/review-cards/write.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check, Star } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface ReviewDraft {\n  rating: number;\n  text: string;\n  tags: string[];\n}\n\nexport interface WriteReviewCardProps {\n  /** What is being reviewed. */\n  product?: string;\n  /** Words shown for 1–5 stars. */\n  labels?: [string, string, string, string, string];\n  /** Quick tags the reviewer can toggle. */\n  tags?: string[];\n  maxLength?: number;\n  onSubmit?: (review: ReviewDraft) => void;\n  className?: string;\n}\n\nconst focus = \"outline-none focus-visible:shadow-[var(--focus-ring)]\";\nconst fade = { initial: { opacity: 0, filter: \"blur(8px)\" }, animate: { opacity: 1, filter: \"blur(0px)\" }, exit: { opacity: 0, filter: \"blur(8px)\" } };\n\n/** Review composer: a keyboard-friendly star radio group that pops on select, quick tags, a counted text box and a crossfade to a thank-you state. */\nexport function WriteReviewCard({\n  product = \"Arden Wool Throw\",\n  labels = [\"Not for me\", \"Below expectations\", \"It’s fine\", \"Really good\", \"Love it\"],\n  tags = [\"Soft\", \"True to colour\", \"Warm\", \"Well packaged\", \"Pills easily\"],\n  maxLength = 400,\n  onSubmit,\n  className,\n}: WriteReviewCardProps) {\n  const reduce = useReducedMotion() ?? false;\n  const [rating, setRating] = React.useState(0);\n  const [hover, setHover] = React.useState(0);\n  const [text, setText] = React.useState(\"\");\n  const [picked, setPicked] = React.useState<string[]>([]);\n  const [sent, setSent] = React.useState(false);\n  const refs = React.useRef<(HTMLButtonElement | null)[]>([]);\n  const textId = React.useId();\n  const shown = hover || rating;\n\n  function pick(n: number) {\n    setRating(n);\n    refs.current[n - 1]?.focus();\n  }\n\n  function onKey(e: React.KeyboardEvent) {\n    if (e.key === \"ArrowRight\" || e.key === \"ArrowUp\") {\n      e.preventDefault();\n      pick(Math.min(5, (rating || 0) + 1));\n    } else if (e.key === \"ArrowLeft\" || e.key === \"ArrowDown\") {\n      e.preventDefault();\n      pick(Math.max(1, (rating || 2) - 1));\n    }\n  }\n\n  function submit(e: React.FormEvent) {\n    e.preventDefault();\n    if (!rating) return;\n    onSubmit?.({ rating, text: text.trim(), tags: picked });\n    setSent(true);\n  }\n\n  function reset() {\n    setSent(false);\n    setRating(0);\n    setText(\"\");\n    setPicked([]);\n  }\n\n  return (\n    <section aria-label={`Review ${product}`} className={cn(\"w-full max-w-[400px] overflow-hidden rounded-xl border border-border bg-surface-raised shadow-sm\", className)}>\n      <AnimatePresence mode=\"popLayout\" initial={false}>\n        {sent ? (\n          <motion.div key=\"done\" {...fade} className=\"flex flex-col items-center px-6 py-10 text-center\">\n            <motion.span\n              initial={reduce ? false : { scale: 0.4, rotate: -30 }}\n              animate={{ scale: 1, rotate: 0 }}\n              transition={{ type: \"spring\", stiffness: 420, damping: 16 }}\n              className=\"grid size-12 place-items-center rounded-full bg-volt text-on-volt\"\n            >\n              <Check aria-hidden className=\"size-6\" strokeWidth={2.5} />\n            </motion.span>\n            <h3 className=\"mt-4 text-base font-semibold text-ink\">Thanks, your review is in</h3>\n            <p className=\"mt-1 max-w-[260px] text-sm text-ink-muted\">It will appear on {product} after a quick check, usually within an hour.</p>\n            <button type=\"button\" onClick={reset} className={cn(\"mt-5 h-9 rounded-full border border-border-strong/50 px-4 text-[13px] font-medium text-ink hover:bg-surface-hover\", focus)}>\n              Edit your review\n            </button>\n          </motion.div>\n        ) : (\n          <motion.form key=\"form\" {...fade} onSubmit={submit} className=\"p-5\">\n            <p className=\"text-xs font-medium text-ink-subtle\">Your review</p>\n            <h3 className=\"mt-0.5 text-base font-semibold text-ink\">{product}</h3>\n\n            <div className=\"mt-4 flex items-center gap-3\">\n              <div role=\"radiogroup\" aria-label=\"Rating\" onKeyDown={onKey} onMouseLeave={() => setHover(0)} className=\"flex gap-1\">\n                {[1, 2, 3, 4, 5].map((n) => {\n                  const lit = n <= shown;\n                  return (\n                    <motion.button\n                      key={n}\n                      ref={(el) => {\n                        refs.current[n - 1] = el;\n                      }}\n                      type=\"button\"\n                      role=\"radio\"\n                      aria-checked={rating === n}\n                      aria-label={`${n} star${n > 1 ? \"s\" : \"\"}, ${labels[n - 1]}`}\n                      tabIndex={rating ? (rating === n ? 0 : -1) : n === 1 ? 0 : -1}\n                      onMouseEnter={() => setHover(n)}\n                      onClick={() => setRating(n)}\n                      whileTap={reduce ? undefined : { scale: 0.85 }}\n                      animate={reduce ? undefined : { scale: rating === n ? [1, 1.25, 1] : 1 }}\n                      transition={{ duration: 0.35 }}\n                      className={cn(\"grid size-9 place-items-center rounded-sm\", focus)}\n                    >\n                      <Star aria-hidden className={cn(\"size-7 transition-colors duration-150\", lit ? \"text-volt-ink\" : \"text-ink-subtle/35\")} fill=\"currentColor\" strokeWidth={0} />\n                    </motion.button>\n                  );\n                })}\n              </div>\n              <div className=\"relative h-5 min-w-0 flex-1\">\n                <AnimatePresence mode=\"popLayout\" initial={false}>\n                  <motion.span key={shown} {...fade} transition={{ duration: 0.18 }} className={cn(\"absolute inset-0 truncate text-sm\", shown ? \"font-medium text-ink\" : \"text-ink-subtle\")}>\n                    {shown ? labels[shown - 1] : \"Tap to rate\"}\n                  </motion.span>\n                </AnimatePresence>\n              </div>\n            </div>\n\n            <div role=\"group\" aria-label=\"Quick tags\" className=\"mt-4 flex flex-wrap gap-1.5\">\n              {tags.map((t) => {\n                const on = picked.includes(t);\n                return (\n                  <motion.button\n                    key={t}\n                    type=\"button\"\n                    aria-pressed={on}\n                    whileTap={reduce ? undefined : { scale: 0.94 }}\n                    onClick={() => setPicked((p) => (on ? p.filter((x) => x !== t) : [...p, t]))}\n                    className={cn(\n                      \"inline-flex h-7 items-center gap-1 rounded-full border px-2.5 text-xs transition-colors\",\n                      on ? \"border-ink bg-ink text-bg\" : \"border-border-strong/40 text-ink-muted hover:text-ink\",\n                      focus,\n                    )}\n                  >\n                    {on && <Check aria-hidden className=\"size-3\" strokeWidth={3} />}\n                    {t}\n                  </motion.button>\n                );\n              })}\n            </div>\n\n            <label htmlFor={textId} className=\"mt-4 block text-xs font-medium text-ink-muted\">\n              Tell others what stood out\n            </label>\n            <div className=\"relative mt-1.5\">\n              <textarea\n                id={textId}\n                value={text}\n                maxLength={maxLength}\n                onChange={(e) => setText(e.target.value)}\n                rows={3}\n                placeholder=\"How does it feel after a few weeks of use?\"\n                className=\"w-full resize-none rounded-md border border-border-strong/40 bg-surface px-3 py-2.5 pb-6 text-sm text-ink placeholder:text-ink-subtle outline-none transition-colors focus:border-volt-ink\"\n              />\n              <span aria-live=\"polite\" className={cn(\"pointer-events-none absolute bottom-2 right-3 font-mono text-[10.5px] tabular-nums\", text.length > maxLength * 0.9 ? \"text-warning\" : \"text-ink-subtle\")}>\n                {text.length}/{maxLength}\n              </span>\n            </div>\n\n            <div className=\"mt-4 flex items-center justify-between gap-3\">\n              <p className=\"text-[11.5px] text-ink-subtle\">{rating ? \"Posted with your first name\" : \"Choose a rating to post\"}</p>\n              <motion.button\n                type=\"submit\"\n                disabled={!rating}\n                whileTap={rating && !reduce ? { scale: 0.96 } : undefined}\n                className={cn(\"h-9 shrink-0 rounded-full bg-volt px-4 text-[13px] font-semibold text-on-volt transition-opacity disabled:cursor-not-allowed disabled:bg-surface-sunken disabled:text-ink-subtle\", focus)}\n              >\n                Post review\n              </motion.button>\n            </div>\n          </motion.form>\n        )}\n      </AnimatePresence>\n    </section>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["examples","reviews","rating","stars","testimonial","ecommerce","feedback","histogram","card"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/review-cards#write"}}