{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "inline-testimonials",
  "title": "Inline Testimonials",
  "description": "A flowing quote stream where each avatar reveals its author on hover, with Motion fading and blurring the rest.",
  "dependencies": ["motion"],
  "registryDependencies": ["utils"],
  "files": [
    {
      "path": "registry/primitives/disclosure/inline-testimonials/index.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport {\n  AnimatePresence,\n  type Easing,\n  type MotionStyle,\n  motion,\n  useReducedMotion,\n} from \"motion/react\";\nimport Image from \"next/image\";\nimport type { ReactNode } from \"react\";\nimport { useCallback, useState } from \"react\";\n\nexport interface Testimonial {\n  author: {\n    name: string;\n    role: string;\n    /** Avatar image URL, or any ReactNode to render a custom avatar (skips next/image). */\n    avatar: ReactNode | string;\n  };\n  id: string;\n  text: string;\n}\n\nexport interface InlineTestimonialsClassNames {\n  /** Merged onto the avatar wrapper. */\n  avatar?: string;\n  /** Merged onto each testimonial `<span>`. */\n  item?: string;\n  /** Merged onto the hover label container. */\n  label?: string;\n  /** Merged onto the author name label. */\n  name?: string;\n  /** Merged onto the author role label. */\n  role?: string;\n  /** Merged onto the root element. */\n  root?: string;\n}\n\nexport interface InlineTestimonialsAlternateConfig {\n  /** Opacity applied to every other (odd-indexed) item. @default 0.7 */\n  opacity?: number;\n  /** Toggles the alternating opacity pattern across items. @default true */\n  pattern?: boolean;\n}\n\nexport interface InlineTestimonialsLabelConfig {\n  /** CSS color for the author name text. @default \"#f97316\" */\n  accentColor?: string;\n  /** Easing for the reveal/exit animation. @default \"easeOut\" */\n  ease?: Easing;\n  /** Font size of the author name, in pixels. @default 12 */\n  nameFontSize?: number;\n  /** Horizontal offset from the avatar's left edge, in pixels. @default avatarSize / 2 */\n  offsetX?: number;\n  /** Vertical offset above the avatar. @default \"150%\" */\n  offsetY?: string;\n  /** CSS color for the author role text. @default \"var(--muted-foreground, #6b7280)\" */\n  roleColor?: string;\n  /** Font size of the author role, in pixels. @default 9 */\n  roleFontSize?: number;\n  /** Letter spacing of the author role text. @default \"0.1em\" */\n  roleLetterSpacing?: string;\n  /** Uppercase the author role text. @default true */\n  roleUppercase?: boolean;\n  /** Horizontal slide distance (px) for the reveal/exit animation. @default -6 */\n  slideX?: number;\n  /** Duration of the reveal/exit animation, in seconds. @default 0.16 */\n  transitionDuration?: number;\n}\n\nexport interface InlineTestimonialsProps {\n  /** Alternating opacity pattern applied across items. */\n  alternate?: InlineTestimonialsAlternateConfig;\n  /** Avatar diameter in pixels. @default 32 */\n  avatarSize?: number;\n  /** Blur amount applied to non-hovered items in px. @default 5 */\n  blurAmount?: number;\n  /** Opacity of non-hovered items (0–1). @default 0.25 */\n  blurOpacity?: number;\n  className?: string;\n  /** Class names for individual parts of the component. */\n  classNames?: InlineTestimonialsClassNames;\n  /** Uncontrolled initial hovered/active testimonial id. */\n  defaultHoveredId?: string | null;\n  /** Font size in pixels. @default 30 */\n  fontSize?: number;\n  /** Controlled hovered/active testimonial id. */\n  hoveredId?: string | null;\n  /** Which interactions reveal the author label. @default \"hover\" */\n  interaction?: \"both\" | \"focus\" | \"hover\";\n  /** Styling and motion for the hover-revealed author label. */\n  label?: InlineTestimonialsLabelConfig;\n  /** Called whenever the hovered/active testimonial id changes (controlled or uncontrolled). */\n  onHoveredIdChange?: (id: string | null) => void;\n  /** Overrides avatar rendering entirely; receives the testimonial and resolved size. */\n  renderAvatar?: (testimonial: Testimonial, size: number) => ReactNode;\n  testimonials: Testimonial[];\n}\n\ninterface ResolvedLabelConfig extends Required<InlineTestimonialsLabelConfig> {}\n\nfunction resolveLabelConfig(\n  avatarSize: number,\n  label?: InlineTestimonialsLabelConfig\n): ResolvedLabelConfig {\n  return {\n    accentColor: label?.accentColor ?? \"#f97316\",\n    ease: label?.ease ?? \"easeOut\",\n    nameFontSize: label?.nameFontSize ?? 12,\n    offsetX: label?.offsetX ?? avatarSize / 2,\n    offsetY: label?.offsetY ?? \"150%\",\n    roleColor: label?.roleColor ?? \"var(--muted-foreground, #6b7280)\",\n    roleFontSize: label?.roleFontSize ?? 9,\n    roleLetterSpacing: label?.roleLetterSpacing ?? \"0.1em\",\n    roleUppercase: label?.roleUppercase ?? true,\n    slideX: label?.slideX ?? -6,\n    transitionDuration: label?.transitionDuration ?? 0.16,\n  };\n}\n\ninterface TestimonialAvatarProps {\n  avatarSize: number;\n  renderAvatar?: (testimonial: Testimonial, size: number) => ReactNode;\n  testimonial: Testimonial;\n}\n\nfunction TestimonialAvatar({\n  testimonial,\n  avatarSize,\n  renderAvatar,\n}: TestimonialAvatarProps) {\n  if (renderAvatar) {\n    return renderAvatar(testimonial, avatarSize);\n  }\n\n  if (typeof testimonial.author.avatar !== \"string\") {\n    return testimonial.author.avatar;\n  }\n\n  return (\n    <Image\n      alt={testimonial.author.name}\n      height={avatarSize}\n      src={testimonial.author.avatar}\n      style={{\n        width: avatarSize,\n        height: avatarSize,\n        borderRadius: \"50%\",\n        objectFit: \"cover\",\n        display: \"block\",\n      }}\n      unoptimized\n      width={avatarSize}\n    />\n  );\n}\n\ninterface TestimonialLabelProps {\n  classNames?: InlineTestimonialsClassNames;\n  label: ResolvedLabelConfig;\n  prefersReducedMotion: boolean;\n  testimonial: Testimonial;\n}\n\nfunction TestimonialLabel({\n  testimonial,\n  classNames,\n  label,\n  prefersReducedMotion,\n}: TestimonialLabelProps) {\n  const labelStyle: MotionStyle = {\n    position: \"absolute\",\n    left: label.offsetX,\n    bottom: label.offsetY,\n    transform: \"translateY(-50%)\",\n    display: \"inline-flex\",\n    flexDirection: \"column\",\n    gap: 3,\n    whiteSpace: \"nowrap\",\n    pointerEvents: \"none\",\n    zIndex: 20,\n  };\n  const slideX = prefersReducedMotion ? 0 : label.slideX;\n  const duration = prefersReducedMotion ? 0 : label.transitionDuration;\n\n  return (\n    <motion.span\n      animate={{ opacity: 1, x: 0 }}\n      className={classNames?.label}\n      exit={{ opacity: 0, x: slideX }}\n      initial={{ opacity: 0, x: slideX }}\n      style={labelStyle}\n      transition={{ duration, ease: label.ease }}\n    >\n      <span\n        className={classNames?.name}\n        style={{\n          fontSize: label.nameFontSize,\n          fontWeight: 500,\n          color: label.accentColor,\n          lineHeight: 1.2,\n        }}\n      >\n        {testimonial.author.name}\n      </span>\n      <span\n        className={classNames?.role}\n        style={{\n          fontSize: label.roleFontSize,\n          fontWeight: 500,\n          letterSpacing: label.roleLetterSpacing,\n          textTransform: label.roleUppercase ? \"uppercase\" : \"none\",\n          color: label.roleColor,\n          lineHeight: 1.2,\n        }}\n      >\n        {testimonial.author.role}\n      </span>\n    </motion.span>\n  );\n}\n\ninterface TestimonialItemProps {\n  allowFocus: boolean;\n  allowHover: boolean;\n  alternate: Required<InlineTestimonialsAlternateConfig>;\n  avatarSize: number;\n  blurAmount: number;\n  blurOpacity: number;\n  classNames?: InlineTestimonialsClassNames;\n  index: number;\n  isAnyHovered: boolean;\n  isHovered: boolean;\n  label: ResolvedLabelConfig;\n  prefersReducedMotion: boolean;\n  renderAvatar?: (testimonial: Testimonial, size: number) => ReactNode;\n  setHoveredId: (id: string | null) => void;\n  testimonial: Testimonial;\n}\n\nfunction TestimonialItem({\n  testimonial,\n  index,\n  isHovered,\n  isAnyHovered,\n  allowHover,\n  allowFocus,\n  alternate,\n  blurAmount,\n  blurOpacity,\n  prefersReducedMotion,\n  setHoveredId,\n  classNames,\n  avatarSize,\n  renderAvatar,\n  label,\n}: TestimonialItemProps) {\n  const isDimmed = isAnyHovered && !isHovered;\n  const isAlternate = alternate.pattern && index % 2 !== 0;\n  const baseOpacity = isAlternate ? alternate.opacity : 1;\n\n  let itemOpacity = baseOpacity;\n  if (isDimmed) {\n    itemOpacity = blurOpacity;\n  } else if (isHovered) {\n    itemOpacity = 1;\n  }\n\n  return (\n    // biome-ignore lint/a11y/noStaticElementInteractions: hover/focus reveal is decorative and progressively enhanced (interaction defaults to \"hover\"; tabIndex/onFocus only attach when interaction includes \"focus\")\n    // biome-ignore lint/a11y/noNoninteractiveElementInteractions: same as above\n    <span\n      className={classNames?.item}\n      onBlur={allowFocus ? () => setHoveredId(null) : undefined}\n      onFocus={allowFocus ? () => setHoveredId(testimonial.id) : undefined}\n      onMouseEnter={allowHover ? () => setHoveredId(testimonial.id) : undefined}\n      onMouseLeave={allowHover ? () => setHoveredId(null) : undefined}\n      style={{\n        filter:\n          isDimmed && !prefersReducedMotion ? `blur(${blurAmount}px)` : \"none\",\n        opacity: itemOpacity,\n        transition: prefersReducedMotion\n          ? \"opacity 0.3s ease\"\n          : \"filter 0.3s ease, opacity 0.3s ease\",\n        cursor: \"default\",\n        // Bleeds the hoverable area into the inter-line gap so moving the\n        // cursor between wrapped lines doesn't pass through a dead zone\n        // that belongs to no item and drops the hover state.\n        paddingBlock: 4,\n      }}\n      tabIndex={allowFocus ? 0 : undefined}\n    >\n      <span\n        className={cn(\"relative inline-block\", classNames?.avatar)}\n        style={{\n          width: avatarSize,\n          height: avatarSize,\n          verticalAlign: \"middle\",\n          marginRight: 3,\n        }}\n      >\n        <TestimonialAvatar\n          avatarSize={avatarSize}\n          renderAvatar={renderAvatar}\n          testimonial={testimonial}\n        />\n        <AnimatePresence>\n          {isHovered && (\n            <TestimonialLabel\n              classNames={classNames}\n              label={label}\n              prefersReducedMotion={prefersReducedMotion}\n              testimonial={testimonial}\n            />\n          )}\n        </AnimatePresence>\n      </span>\n      <span>\n        {testimonial.text}{\" \"}\n        <span className=\"sr-only\">\n          — {testimonial.author.name}, {testimonial.author.role}\n        </span>{\" \"}\n      </span>\n    </span>\n  );\n}\n\nexport function InlineTestimonials({\n  testimonials,\n  blurAmount = 5,\n  blurOpacity = 0.25,\n  avatarSize = 32,\n  fontSize = 30,\n  alternate,\n  interaction = \"hover\",\n  hoveredId: controlledHoveredId,\n  defaultHoveredId = null,\n  onHoveredIdChange,\n  renderAvatar,\n  label,\n  className,\n  classNames,\n}: InlineTestimonialsProps) {\n  const [internalHoveredId, setInternalHoveredId] = useState<string | null>(\n    defaultHoveredId\n  );\n  const prefersReducedMotion = useReducedMotion();\n\n  const isControlled = controlledHoveredId !== undefined;\n  const hoveredId = isControlled ? controlledHoveredId : internalHoveredId;\n\n  const setHoveredId = useCallback(\n    (id: string | null) => {\n      if (!isControlled) {\n        setInternalHoveredId(id);\n      }\n      onHoveredIdChange?.(id);\n    },\n    [isControlled, onHoveredIdChange]\n  );\n\n  const allowHover = interaction === \"hover\" || interaction === \"both\";\n  const allowFocus = interaction === \"focus\" || interaction === \"both\";\n\n  const resolvedAlternate = {\n    pattern: alternate?.pattern ?? true,\n    opacity: alternate?.opacity ?? 0.7,\n  };\n  const resolvedLabel = resolveLabelConfig(avatarSize, label);\n\n  return (\n    <div\n      className={cn(\"font-medium tracking-tight\", classNames?.root, className)}\n      style={{ fontSize: `${fontSize}px`, lineHeight: 1.35 }}\n    >\n      {testimonials.map((t, index) => (\n        <TestimonialItem\n          allowFocus={allowFocus}\n          allowHover={allowHover}\n          alternate={resolvedAlternate}\n          avatarSize={avatarSize}\n          blurAmount={blurAmount}\n          blurOpacity={blurOpacity}\n          classNames={classNames}\n          index={index}\n          isAnyHovered={hoveredId !== null}\n          isHovered={hoveredId === t.id}\n          key={t.id}\n          label={resolvedLabel}\n          prefersReducedMotion={!!prefersReducedMotion}\n          renderAvatar={renderAvatar}\n          setHoveredId={setHoveredId}\n          testimonial={t}\n        />\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/sora-ui/disclosure/inline-testimonials.tsx"
    }
  ],
  "type": "registry:ui"
}
