{
  "name": "fluid-testimonial-card",
  "type": "registry:ui",
  "title": "Fluid testimonial card",
  "description": "A compact testimonial card that lifts on hover, with a warm, flowing gradient behind its serif quote and author.",
  "author": "Aditya Ojha <https://akoder.xyz>",
  "categories": [
    "card",
    "testimonial",
    "hover",
    "animation"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [],
  "meta": {
    "docs": "https://akoder.xyz/components/fluid-testimonial-card",
    "source": "https://github.com/AdityaKodez/adityaojha/blob/main/components/ui/fluid-testimonial-card.tsx"
  },
  "files": [
    {
      "path": "components/ui/fluid-testimonial-card.tsx",
      "content": "\"use client\";\n\nimport { motion, useReducedMotion } from \"motion/react\";\nimport { useState, type CSSProperties, type ReactNode } from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface FluidTestimonialCardProps {\n  quote: string;\n  author: string;\n  description?: string;\n  role?: string;\n  /** Decorative portrait. Missing or failed images show the author's initials. */\n  avatarSrc?: string;\n  /** Custom decorative avatar, rendered instead of avatarSrc or initials. */\n  avatar?: ReactNode;\n  /** Glow, accent, and highlight colors. Dark mode derives deeper shades. */\n  colors?: readonly [string, string, string];\n  /** Hover lift in pixels, clamped to 0 through 32. */\n  liftAmount?: number;\n  className?: string;\n}\n\nfunction AuthorAvatar({ src, author, children }: { src?: string; author: string; children?: ReactNode }) {\n  const [failed, setFailed] = useState(false);\n  const initials = author.trim().split(/\\s+/).slice(0, 2).map((part) => part[0]).join(\"\");\n\n  return (\n    <span\n      aria-hidden=\"true\"\n      className=\"flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-md bg-background/70 text-[10px] font-medium text-foreground\"\n    >\n      {children ?? (src && !failed ? (\n        // Consumer images do not require a Next.js image loader or domain configuration.\n        // eslint-disable-next-line @next/next/no-img-element\n        <img\n          src={src}\n          alt=\"\"\n          width={32}\n          height={32}\n          className=\"size-full object-cover\"\n          onError={() => setFailed(true)}\n        />\n      ) : initials)}\n    </span>\n  );\n}\n\nexport function FluidTestimonialCard({\n  quote,\n  author,\n  description,\n  role,\n  avatarSrc,\n  avatar,\n  colors,\n  liftAmount = 14,\n  className,\n}: FluidTestimonialCardProps) {\n  const [hovered, setHovered] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const reducedMotion = useReducedMotion();\n  const active = hovered || focused;\n  const flowing = active && !reducedMotion;\n  const lift = Number.isFinite(liftAmount) ? Math.min(32, Math.max(0, liftAmount)) : 14;\n  const paletteStyle = colors ? {\n    \"--fluid-custom-base\": `color-mix(in srgb, ${colors[0]} 35%, white)`,\n    \"--fluid-custom-base-end\": `color-mix(in srgb, ${colors[0]} 65%, white)`,\n    \"--fluid-custom-gold\": colors[0],\n    \"--fluid-custom-gold-soft\": `color-mix(in srgb, ${colors[0]} 70%, white)`,\n    \"--fluid-custom-orange\": colors[1],\n    \"--fluid-custom-orange-soft\": `color-mix(in srgb, ${colors[1]} 70%, ${colors[0]})`,\n    \"--fluid-custom-highlight\": colors[2],\n    \"--fluid-custom-highlight-soft\": `color-mix(in srgb, ${colors[2]} 70%, ${colors[0]})`,\n    \"--fluid-custom-dark-base\": `color-mix(in srgb, ${colors[1]} 12%, #111111)`,\n    \"--fluid-custom-dark-base-end\": `color-mix(in srgb, ${colors[1]} 20%, #111111)`,\n    \"--fluid-custom-dark-gold\": `color-mix(in srgb, color-mix(in srgb, ${colors[0]}, ${colors[1]}) 40%, #111111)`,\n    \"--fluid-custom-dark-gold-soft\": `color-mix(in srgb, ${colors[0]} 25%, #111111)`,\n    \"--fluid-custom-dark-orange\": `color-mix(in srgb, ${colors[1]} 60%, #111111)`,\n    \"--fluid-custom-dark-orange-soft\": `color-mix(in srgb, ${colors[1]} 40%, #111111)`,\n    \"--fluid-custom-dark-highlight\": `color-mix(in srgb, ${colors[2]} 22%, #111111)`,\n    \"--fluid-custom-dark-highlight-soft\": `color-mix(in srgb, ${colors[2]} 15%, #111111)`,\n  } as CSSProperties : undefined;\n\n  return (\n    <motion.div\n      className=\"max-w-full\"\n      onHoverStart={() => setHovered(true)}\n      onHoverEnd={() => setHovered(false)}\n    >\n      <motion.figure\n        tabIndex={0}\n        initial={false}\n        animate={{ y: active && !reducedMotion ? -lift : 0 }}\n        style={paletteStyle}\n        transition={reducedMotion ? { duration: 0 } : { type: \"spring\", stiffness: 220, damping: 24 }}\n        onFocus={() => setFocused(true)}\n        onBlur={() => setFocused(false)}\n        onPointerDown={(event) => {\n          if (event.pointerType !== \"mouse\") event.currentTarget.focus({ preventScroll: true });\n        }}\n        className={cn(\n          \"relative isolate m-0 flex min-h-[360px] w-64 max-w-full flex-col overflow-hidden rounded-3xl bg-muted/60 p-5 text-foreground outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring\",\n          \"[--fluid-base:var(--fluid-custom-base,#ffe6a7)] [--fluid-base-end:var(--fluid-custom-base-end,#ffd16f)] [--fluid-gold:var(--fluid-custom-gold,#ffcf35)] [--fluid-gold-soft:var(--fluid-custom-gold-soft,#ffdc76)] [--fluid-orange:var(--fluid-custom-orange,#ff762b)] [--fluid-orange-soft:var(--fluid-custom-orange-soft,#ff9a3d)] [--fluid-highlight:var(--fluid-custom-highlight,#fff2c4)] [--fluid-highlight-soft:var(--fluid-custom-highlight-soft,#ffe9a1)]\",\n          \"dark:[--fluid-base:var(--fluid-custom-dark-base,#25140f)] dark:[--fluid-base-end:var(--fluid-custom-dark-base-end,#32180f)] dark:[--fluid-gold:var(--fluid-custom-dark-gold,#783317)] dark:[--fluid-gold-soft:var(--fluid-custom-dark-gold-soft,#532715)] dark:[--fluid-orange:var(--fluid-custom-dark-orange,#a7411c)] dark:[--fluid-orange-soft:var(--fluid-custom-dark-orange-soft,#783017)] dark:[--fluid-highlight:var(--fluid-custom-dark-highlight,#482517)] dark:[--fluid-highlight-soft:var(--fluid-custom-dark-highlight-soft,#351c12)]\",\n          className,\n        )}\n      >\n        <motion.div\n          aria-hidden=\"true\"\n          initial={false}\n          animate={{ opacity: active ? 1 : 0 }}\n          transition={{ duration: reducedMotion ? 0 : 0.45, ease: [0.22, 1, 0.36, 1] }}\n          className=\"pointer-events-none absolute inset-0 -z-10 overflow-hidden rounded-[inherit]\"\n        >\n          {/* Soft gradient edges supply the blur; only transforms and opacity animate. */}\n          <div\n            className=\"absolute inset-x-0 bottom-0 h-[72%]\"\n            style={{ maskImage: \"linear-gradient(to bottom, transparent, black 45%)\" }}\n          >\n            <div\n              className=\"absolute inset-0\"\n              style={{ background: \"linear-gradient(to bottom, transparent, var(--fluid-base) 55%, var(--fluid-base-end))\" }}\n            />\n            <motion.div\n              className=\"absolute -bottom-[30%] -left-[45%] h-[115%] w-[150%]\"\n              style={{ background: \"radial-gradient(ellipse at center, var(--fluid-gold) 0%, var(--fluid-gold-soft) 30%, transparent 68%)\" }}\n              animate={flowing\n                ? { x: [0, 35, -15, 0], y: [0, -25, 10, 0], rotate: [0, -12, 8, 0], scale: [1, 1.12, 0.95, 1] }\n                : { x: 0, y: 0, rotate: 0, scale: 1 }}\n              transition={flowing ? { duration: 7, repeat: Infinity, ease: \"easeInOut\" } : { duration: 0 }}\n            />\n            <motion.div\n              className=\"absolute -right-[45%] bottom-[2%] h-[100%] w-[135%]\"\n              style={{ background: \"radial-gradient(ellipse at center, var(--fluid-orange) 0%, var(--fluid-orange-soft) 25%, transparent 65%)\" }}\n              animate={flowing\n                ? { x: [0, -45, 5, 0], y: [0, 30, -15, 0], rotate: [0, 18, -8, 0], scale: [1, 0.9, 1.15, 1] }\n                : { x: 0, y: 0, rotate: 0, scale: 1 }}\n              transition={flowing ? { duration: 8, repeat: Infinity, ease: \"easeInOut\" } : { duration: 0 }}\n            />\n            <motion.div\n              className=\"absolute -bottom-[45%] left-[5%] h-[85%] w-[120%]\"\n              style={{ background: \"radial-gradient(ellipse at center, var(--fluid-highlight) 0%, var(--fluid-highlight-soft) 30%, transparent 68%)\" }}\n              animate={flowing\n                ? { x: [0, -30, 15, 0], y: [0, -18, 8, 0], scale: [1, 1.2, 1, 1] }\n                : { x: 0, y: 0, scale: 1 }}\n              transition={flowing ? { duration: 6, repeat: Infinity, ease: \"easeInOut\" } : { duration: 0 }}\n            />\n          </div>\n        </motion.div>\n\n        <blockquote className=\"m-0 [overflow-wrap:anywhere]\">\n          <p className=\"font-serif text-2xl leading-[1.08] tracking-tight\">{quote}</p>\n          {description && (\n            <p className=\"mt-3 text-xs leading-relaxed text-foreground/65\">{description}</p>\n          )}\n        </blockquote>\n\n        <figcaption className=\"mt-auto flex items-center gap-2.5 pt-16\">\n          <AuthorAvatar key={avatarSrc} src={avatarSrc} author={author}>{avatar}</AuthorAvatar>\n          <div className=\"min-w-0 [overflow-wrap:anywhere]\">\n            <p className=\"text-xs font-medium leading-snug\">{author}</p>\n            {role && <p className=\"mt-0.5 text-[10px] leading-snug text-foreground/65\">{role}</p>}\n          </div>\n        </figcaption>\n      </motion.figure>\n    </motion.div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "## Installation\n\n```bash\nnpx shadcn@latest add @akoder/fluid-testimonial-card\n```\n\n## Usage\n\n```tsx\nimport { FluidTestimonialCard } from \"@/components/ui/fluid-testimonial-card\";\n\nexport function CustomerQuote() {\n  return (\n    <FluidTestimonialCard\n      quote=\"Small details, thoughtfully done.\"\n      description=\"The whole experience feels clear and effortless.\"\n      author=\"Maya Chen\"\n      role=\"Product designer\"\n    />\n  );\n}\n```\n\nHover, focus, or touch the card to reveal the warm fluid background. Reduced motion keeps the gradient static and disables the lift. Missing or failed avatar images show the author's initials."
}