{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "testimonials",
  "type": "registry:ui",
  "registryDependencies": [],
  "dependencies": [],
  "devDependencies": [],
  "files": [
    {
      "path": "testimonials.tsx",
      "content": "\"use client\";\n\nimport {\n  Children,\n  type CSSProperties,\n  cloneElement,\n  isValidElement,\n  type ReactElement,\n  type ReactNode,\n  useState,\n} from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nconst WHITESPACE_REGEX = /\\s+/;\n\nfunction getInitials(name: string) {\n  const parts = name.trim().split(WHITESPACE_REGEX);\n  const first = parts[0]?.[0] ?? \"\";\n  const last = parts.length > 1 ? (parts.at(-1)?.[0] ?? \"\") : \"\";\n\n  return `${first}${last}`.toUpperCase();\n}\n\nexport type TestimonialsProps = {\n  children: ReactNode;\n  /** Blur radius in px applied to the non-hovered quotes. */\n  blur?: number;\n  /** Opacity the non-hovered quotes fade to while one is highlighted. */\n  dimOpacity?: number;\n  /** Called with the highlighted quote index, or null when nothing is highlighted. */\n  onActiveChange?: (index: number | null) => void;\n  className?: string;\n};\n\nexport function Testimonials({\n  children,\n  blur = 4,\n  dimOpacity = 0.2,\n  onActiveChange,\n  className,\n}: TestimonialsProps) {\n  let runningIndex = 0;\n  const items = Children.map(children, (child) => {\n    if (!isValidElement(child)) {\n      return child;\n    }\n\n    const type = child.type as { isTestimonialItem?: boolean };\n\n    if (type !== Testimonial && type.isTestimonialItem !== true) {\n      return child;\n    }\n\n    const index = runningIndex;\n    runningIndex += 1;\n\n    return cloneElement(child as ReactElement<TestimonialProps>, {\n      index,\n      onActiveChange,\n    });\n  });\n\n  return (\n    <p\n      className={cn(\n        // group/wall lets each quote's dim rule below check \"does any quote\n        // in this wall currently have hover or focus\" via :has(). Everything\n        // here is a native CSS pseudo-class transition — the browser applies\n        // it to all quotes in one synchronous style/paint pass, so there is\n        // no per-element timing drift (that drift is what reads as a wave\n        // when the same effect is driven from React state instead).\n        \"group/wall max-w-3xl text-justify font-medium text-base leading-[1.75] tracking-[-0.01em] sm:text-xl motion-reduce:[--testimonial-blur:0px]\",\n        className\n      )}\n      style={\n        {\n          \"--testimonial-blur\": `${blur}px`,\n          \"--testimonial-dim\": dimOpacity,\n        } as CSSProperties\n      }\n    >\n      {items}\n    </p>\n  );\n}\n\nfunction TestimonialAvatar({\n  name,\n  avatar,\n}: {\n  name: string;\n  avatar?: string;\n}) {\n  const [failed, setFailed] = useState(false);\n\n  if (avatar && !failed) {\n    return (\n      // biome-ignore lint/performance/noImgElement: registry components stay framework-agnostic.\n      <img\n        alt=\"\"\n        className=\"mr-[0.35em] inline-block size-[1.15em] select-none rounded-full border border-border/60 object-cover align-[-0.2em]\"\n        height={24}\n        onError={() => setFailed(true)}\n        src={avatar}\n        width={24}\n      />\n    );\n  }\n\n  return (\n    <span\n      aria-hidden\n      className=\"mr-[0.35em] inline-flex size-[1.15em] select-none items-center justify-center rounded-full border border-border bg-muted align-[-0.2em] font-semibold text-[0.45em] text-muted-foreground uppercase tracking-[0.08em]\"\n    >\n      {getInitials(name)}\n    </span>\n  );\n}\n\nexport type TestimonialProps = {\n  /** Author name revealed in the attribution while the quote is highlighted. */\n  name: string;\n  /** Author role or company shown under the name. */\n  title?: ReactNode;\n  /** Avatar image URL. Falls back to initials when missing or broken. */\n  avatar?: string;\n  /**\n   * Text tone at rest. Quotes alternate strong and muted automatically;\n   * set this to pin one.\n   */\n  emphasis?: \"strong\" | \"muted\";\n  children: ReactNode;\n  className?: string;\n  /** Position in the flow. Injected by Testimonials — do not set manually. */\n  index?: number;\n  /** Injected by Testimonials — do not set manually. */\n  onActiveChange?: (index: number | null) => void;\n};\n\nexport function Testimonial({\n  name,\n  title,\n  avatar,\n  emphasis,\n  children,\n  className,\n  index = 0,\n  onActiveChange,\n}: TestimonialProps) {\n  const tone = emphasis ?? (index % 2 === 0 ? \"strong\" : \"muted\");\n\n  return (\n    <span\n      className={cn(\n        // group/quote scopes the attribution reveal below to this exact\n        // quote, independent of the wall-wide dim rule. Padding (canceled\n        // by an equal negative margin, so nothing visually shifts) claims\n        // the surrounding word-gap and inter-line leading as part of this\n        // quote's own hoverable box: browsers size a wrapped line's hit\n        // fragment to the glyph metrics, not the full line-height, so\n        // without this the leading between wrapped lines is dead space\n        // that belongs to no quote. Vertical padding on an inline element\n        // never affects line-box spacing, so this is purely an expanded\n        // hit area, not a layout change.\n        \"group/quote relative -mx-[0.15em] -my-[0.3em] inline cursor-pointer px-[0.15em] py-[0.3em] outline-none [filter:blur(0px)] [transition:filter_0.3s_cubic-bezier(0.32,0.72,0,1),opacity_0.2s_ease-out,color_0.3s_ease] motion-reduce:transition-none\",\n        tone === \"strong\" ? \"text-foreground\" : \"text-muted-foreground\",\n        \"hover:z-[1] hover:text-foreground focus-visible:z-[1] focus-visible:text-foreground\",\n        \"group-has-[[data-testimonial-quote]:is(:hover,:focus-visible)]/wall:not-hover:not-focus-visible:opacity-[var(--testimonial-dim)]\",\n        \"group-has-[[data-testimonial-quote]:is(:hover,:focus-visible)]/wall:not-hover:not-focus-visible:[filter:blur(var(--testimonial-blur))]\",\n        className\n      )}\n      data-testimonial-quote=\"\"\n      onBlur={() => onActiveChange?.(null)}\n      onFocus={() => onActiveChange?.(index)}\n      onMouseEnter={() => onActiveChange?.(index)}\n      onMouseLeave={() => onActiveChange?.(null)}\n      // biome-ignore lint/a11y/noNoninteractiveTabindex: focus is the keyboard path to the highlight + attribution.\n      tabIndex={0}\n    >\n      <TestimonialAvatar avatar={avatar} name={name} />\n      {children}\n      {/* Zero-width anchor so the attribution overlays the neighbors instead of reflowing the paragraph. */}\n      <span className=\"inline-block w-0 overflow-visible align-middle\">\n        <span\n          className={cn(\n            \"pointer-events-none inline-flex translate-y-1 flex-col gap-[0.3em] whitespace-nowrap pl-[0.5em] font-mono text-[0.52em] uppercase leading-none tracking-[0.14em] opacity-0 blur-[2px] transition-[opacity,transform,filter] duration-300 ease-out motion-reduce:translate-y-0 motion-reduce:transition-none\",\n            \"group-hover/quote:translate-y-0 group-hover/quote:opacity-100 group-hover/quote:blur-none\",\n            \"group-focus-visible/quote:translate-y-0 group-focus-visible/quote:opacity-100 group-focus-visible/quote:blur-none\"\n          )}\n        >\n          <span className=\"text-orange-600 dark:text-orange-500\">{name}</span>\n          {title ? (\n            <span className=\"text-muted-foreground\">{title}</span>\n          ) : null}\n        </span>\n      </span>{\" \"}\n    </span>\n  );\n}\n\nTestimonial.isTestimonialItem = true;\n",
      "type": "registry:ui"
    }
  ],
  "title": "Testimonials",
  "description": "Inline testimonial wall where hovering one quote blurs and dims the rest while the author attribution reveals beside the highlighted quote. Pure CSS :has()-driven hover state — no per-frame JS animation."
}
