# Testimonial 16

> Testimonial 16 variant for block.

A shadcn/ui block from the [@uiable](https://uiable.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/uiable/block-testimonial-16).

- Page, with a live preview: https://shadcn.exchange/r/uiable/block-testimonial-16
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/uiable/block-testimonial-16-light-fbd9f1eb9e6f9bbf.png
- Registry item: https://uiable.com/r/block-testimonial-16.json
- Categories: testimonial
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://uiable.com/r/block-testimonial-16.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`
- Registry items: `@uiable/avatar`

## Files

### `src/components/uiable/blocks/testimonial/testimonial-16/testimonial-16.tsx`

```tsx
"use client"
import { useState } from "react"

// shadcn
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"

// third-party
import { AnimatePresence, motion, Variants } from "framer-motion"

// project-imports
import BorderGlow from "@/components/animation/BorderGlow"

const testimonials = [
  {
    name: "Sarah Connor",
    avatar: "https://cdn.uiable.com/block/profile-1.png",
    position: "Lead DevOps Engineer",
    description:
      "This platform has completely transformed how our team manages deployment workflows. The onboarding is incredibly fast and intuitive.",
  },
  {
    name: "Emily Watson",
    avatar: "https://cdn.uiable.com/block/profile-4.png",
    position: "Product Manager at TechFlow",
    description:
      "Automating our repetitive tasks has saved us hours of manual coordination. We can now focus on what truly matters.",
  },
  {
    name: "Marcus Aurelius",
    avatar: "https://cdn.uiable.com/block/profile-5.png",
    position: "Chief Operations Officer",
    description:
      "Having instant access to detailed insights and performance metrics has empowered our team to make data-driven decisions confidently.",
  },
  {
    name: "Aria Montgomery",
    avatar: "https://cdn.uiable.com/block/profile-6.png",
    position: "Senior UX Designer",
    description:
      "Collaboration is seamless here. Real-time updates and shared workspaces have bridged the gap between our design and dev teams.",
  },
  {
    name: "Sophia Martinez",
    avatar: "https://cdn.uiable.com/block/profile-7.png",
    position: "Director of Security",
    description:
      "Security is paramount for us. The advanced encryption and role-based access controls keep our sensitive client data fully protected.",
  },
  {
    name: "James Wilson",
    avatar: "https://cdn.uiable.com/block/profile-8.png",
    position: "Integration Architect",
    description:
      "The pre-built integrations made it incredibly easy to connect all of our existing tools without breaking any workflows.",
  },
]

const fadeUp: Variants = {
  hidden: { opacity: 0, y: 28 },
  show: (delay = 0) => ({
    opacity: 1,
    y: 0,
    transition: { duration: 0.55, delay, ease: [0.22, 1, 0.36, 1] as const },
  }),
}

//  ------------------------------ | TESTIMONIAL 16 | ------------------------------  //

export default function Testimonial16() {
  const [activeIdx, setActiveIdx] = useState<number | null>(0)

  const cardMotion = {
    hidden: { opacity: 0, y: 24, scale: 0.98 },
    visible: { opacity: 1, y: 0, scale: 1 },
  }

  return (
    <section className="bg-blue-100 pb-24 sm:pb-32 dark:bg-blue-900">
      <div className="bg-blue-500 py-24 sm:py-32">
        <div className="container mx-auto px-4 sm:px-6 lg:px-8">
          <motion.div
            initial="hidden"
            whileInView="show"
            viewport={{ once: true }}
            className="flex flex-col items-center gap-8 text-center"
          >
            <motion.div
              variants={fadeUp}
              custom={0.1}
              whileHover={{ scale: 1.04 }}
              className="flex cursor-default items-center gap-2 rounded-full bg-blue-200/20 px-5 py-2 text-blue-50"
            >
              <motion.span
                animate={{ opacity: [1, 0.6, 1] }}
                transition={{ duration: 2, repeat: Infinity }}
                className="size-2 rounded-full bg-blue-200"
              />
              <span className="text-md font-semibold">Testimonial</span>
            </motion.div>
            <div className="flex flex-col items-center gap-4">
              <div className="flex flex-col items-center gap-2">
                <motion.h2
                  variants={fadeUp}
                  custom={0.18}
                  className="text-xl font-medium text-blue-50 sm:text-3xl"
                >
                  Hear from our satisfied users
                </motion.h2>
                <motion.p
                  variants={fadeUp}
                  custom={0.24}
                  className="max-w-200 text-lg font-semibold text-blue-200"
                >
                  Tailored to your needs
                </motion.p>
              </div>
              <motion.p
                variants={fadeUp}
                custom={0.3}
                className="max-w-140 text-blue-100"
              >
                Discover why users love our platform and how it's making a
                positive impact on their work and businesses.
              </motion.p>
            </div>
          </motion.div>
        </div>
      </div>
      <div className="relative z-30 container mx-auto -mt-12 px-4 sm:-mt-16 sm:px-6 lg:px-8">
        <div className="container mx-auto px-4 sm:px-6 lg:px-8">
          <div className="grid grid-cols-12 gap-4">
            <AnimatePresence>
              {testimonials.map((testimonial, idx) => (
                <motion.div
                  key={idx}
                  onMouseEnter={() => setActiveIdx(idx)}
                  className="col-span-12 md:col-span-6 lg:col-span-4"
                  initial="hidden"
                  animate="visible"
                  exit="hidden"
                  variants={cardMotion}
                  transition={{
                    duration: 0.55,
                    ease: [0.22, 1, 0.36, 1],
                    delay: idx * 0.05,
                  }}
                >
                  <BorderGlow
                    className="h-full rounded-lg"
                    fillOpacity={1}
                    backgroundColor="bg-sky-500"
                  >
                    <div className="group relative h-full rounded-lg border-2 border-blue-500/5 bg-white p-5 shadow-[0_0_40px_-8px_#4680ff38] transition-all duration-500 ease-in-out md:p-6 dark:bg-slate-800 dark:shadow-[0_0_40px_-8px_#00000087]">
                      <div className="flex h-full flex-col justify-center gap-6">
                        <div className="flex flex-row gap-4">
                          <Avatar className="size-14! shrink-0 after:border-slate-500/10">
                            <AvatarImage
                              src={testimonial.avatar}
                              alt={testimonial.name}
                            />
                            <AvatarFallback>
                              {testimonial.name.split(" ")[0].charAt(0) +
                                testimonial.name.split(" ")[1].charAt(0)}
                            </AvatarFallback>
                          </Avatar>
                          <div className="grow">
                            <div className="flex flex-col gap-0.5">
                              <div className="text-lg font-medium text-slate-800 dark:text-slate-50">
                                {testimonial.name}
                              </div>
                              <p className="text-sm font-normal text-slate-400 dark:text-slate-500">
                                {testimonial.position}
                              </p>
                            </div>
                          </div>
                        </div>
                        <div
                          className={`${activeIdx === idx ? "grid-rows-[1fr]" : "grid-rows-[0fr]"} grid overflow-hidden transition-[grid-template-rows] duration-500 ease-in-out`}
                        >
                          <motion.p
                            className="text-slate-600 dark:text-slate-100"
                            initial={{ opacity: 0, y: 12 }}
                            animate={{ opacity: 1, y: 0 }}
                            transition={{ duration: 0.45, ease: "easeOut" }}
                          >
                            <svg
                              className={`-mt-3 inline size-5 ${
                                activeIdx === idx
                                  ? "fill-blue-500"
                                  : "fill-blue-500/10"
                              } transition-all duration-500 ease-in-out`}
                              viewBox="0 0 24 24"
                              xmlns="http://www.w3.org/2000/svg"
                            >
                              <path d="M6.5 10c-.223 0-.437.034-.65.065.069-.232.14-.468.254-.68.114-.308.292-.575.469-.844.148-.291.409-.488.601-.737.201-.242.475-.403.692-.604.213-.21.492-.315.714-.463.232-.133.434-.28.65-.35l.539-.222.474-.197-.485-1.938-.597.144c-.191.048-.424.104-.689.171-.271.05-.56.187-.882.312-.318.142-.686.238-1.028.466-.344.218-.741.4-1.091.692-.339.301-.748.562-1.05.945-.33.358-.656.734-.909 1.162-.293.408-.492.856-.702 1.299-.19.443-.343.896-.468 1.336-.237.882-.343 1.72-.384 2.437-.034.718-.014 1.315.028 1.747.015.204.043.402.063.539l.025.168.026-.006A4.5 4.5 0 1 0 6.5 10zm11 0c-.223 0-.437.034-.65.065.069-.232.14-.468.254-.68.114-.308.292-.575.469-.844.148-.291.409-.488.601-.737.201-.242.475-.403.692-.604.213-.21.492-.315.714-.463.232-.133.434-.28.65-.35l.539-.222.474-.197-.485-1.938-.597.144c-.191.048-.424.104-.689.171-.271.05-.56.187-.882.312-.317.143-.686.238-1.028.467-.344.218-.741.4-1.091.692-.339.301-.748.562-1.05.944-.33.358-.656.734-.909 1.162-.293.408-.492.856-.702 1.299-.19.443-.343.896-.468 1.336-.237.882-.343 1.72-.384 2.437-.034.718-.014 1.315.028 1.747.015.204.043.402.063.539l.025.168.026-.006A4.5 4.5 0 1 0 17.5 10z" />
                            </svg>
                            {testimonial.description}
                          </motion.p>
                        </div>
                      </div>
                    </div>
                  </BorderGlow>
                </motion.div>
              ))}
            </AnimatePresence>
          </div>
        </div>
      </div>
    </section>
  )
}
```

### `src/components/animation/BorderGlow.tsx`

```tsx
"use client"

import {
  ReactNode,
  useCallback,
  useEffect,
  useRef,
  useState,
  type CSSProperties,
  type PointerEvent,
} from "react"

// types
interface BorderGlowProps {
  children?: ReactNode
  className?: string
  edgeSensitivity?: number
  glowColor?: string
  backgroundColor?: string
  glowRadius?: number
  glowIntensity?: number
  coneSpread?: number
  animated?: boolean
  colors?: string[]
  fillOpacity?: number
}

function parseHSL(hslStr: string): { h: number; s: number; l: number } {
  const match = hslStr.match(/([\d.]+)\s*([\d.]+)%?\s*([\d.]+)%?/)
  if (!match) return { h: 221, s: 100, l: 64 }
  return {
    h: parseFloat(match[1]),
    s: parseFloat(match[2]),
    l: parseFloat(match[3]),
  }
}

function rgbToHsl(
  r: number,
  g: number,
  b: number
): { h: number; s: number; l: number } {
  r /= 255
  g /= 255
  b /= 255
  const max = Math.max(r, g, b),
    min = Math.min(r, g, b)
  const l = (max + min) / 2
  if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) }
  const d = max - min
  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
  let h = 0
  if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6
  else if (max === g) h = ((b - r) / d + 2) / 6
  else h = ((r - g) / d + 4) / 6
  return {
    h: Math.round(h * 360),
    s: Math.round(s * 100),
    l: Math.round(l * 100),
  }
}

function readPrimaryAsHSL(): string | null {
  if (typeof window === "undefined") return null
  const style = getComputedStyle(document.body)
  const primary = style.getPropertyValue("--primary").trim()
  if (!primary) return null

  const rgbMatch = primary.match(
    /rgb\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)\s*\)/
  )
  if (rgbMatch) {
    const { h, s, l } = rgbToHsl(+rgbMatch[1], +rgbMatch[2], +rgbMatch[3])
    return `${h} ${s} ${l}`
  }

  const hexMatch = primary.match(/^#([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i)
  if (hexMatch) {
    const { h, s, l } = rgbToHsl(
      parseInt(hexMatch[1], 16),
      parseInt(hexMatch[2], 16),
      parseInt(hexMatch[3], 16)
    )
    return `${h} ${s} ${l}`
  }

  return null
}

function buildBoxShadow(glowColor: string, intensity: number): string {
  const { h, s, l } = parseHSL(glowColor)
  const base = `${h}deg ${s}% ${l}%`
  const layers: [number, number, number, number, number, boolean][] = [
    [0, 0, 0, 1, 100, true],
    [0, 0, 1, 0, 60, true],
    [0, 0, 3, 0, 50, true],
    [0, 0, 6, 0, 40, true],
    [0, 0, 15, 0, 30, true],
    [0, 0, 25, 2, 20, true],
    [0, 0, 50, 2, 10, true],
    [0, 0, 1, 0, 60, false],
    [0, 0, 3, 0, 50, false],
    [0, 0, 6, 0, 40, false],
    [0, 0, 15, 0, 30, false],
    [0, 0, 25, 2, 20, false],
    [0, 0, 50, 2, 10, false],
  ]
  return layers
    .map(([x, y, blur, spread, alpha, inset]) => {
      const a = Math.min(alpha * intensity, 100)
      return `${inset ? "inset " : ""}${x}px ${y}px ${blur}px ${spread}px hsl(${base} / ${a}%)`
    })
    .join(", ")
}

function easeOutCubic(x: number) {
  return 1 - Math.pow(1 - x, 3)
}
function easeInCubic(x: number) {
  return x * x * x
}

interface AnimateOpts {
  start?: number
  end?: number
  duration?: number
  delay?: number
  ease?: (t: number) => number
  onUpdate: (v: number) => void
  onEnd?: () => void
}

function animateValue({
  start = 0,
  end = 100,
  duration = 1000,
  delay = 0,
  ease = easeOutCubic,
  onUpdate,
  onEnd,
}: AnimateOpts) {
  const t0 = performance.now() + delay
  function tick() {
    const elapsed = performance.now() - t0
    const t = Math.min(elapsed / duration, 1)
    onUpdate(start + (end - start) * ease(t))
    if (t < 1) requestAnimationFrame(tick)
    else if (onEnd) onEnd()
  }
  setTimeout(() => requestAnimationFrame(tick), delay)
}

const GRADIENT_POSITIONS = [
  "80% 55%",
  "69% 34%",
  "8% 6%",
  "41% 38%",
  "86% 85%",
  "82% 18%",
  "51% 4%",
]
const COLOR_MAP = [0, 1, 2, 0, 1, 2, 1]

function buildMeshGradients(colors: string[]): string[] {
  const gradients: string[] = []
  for (let i = 0; i < 7; i++) {
    const c = colors[Math.min(COLOR_MAP[i], colors.length - 1)]
    gradients.push(
      `radial-gradient(at ${GRADIENT_POSITIONS[i]}, ${c} 0px, transparent 50%)`
    )
  }
  gradients.push(`linear-gradient(${colors[0]} 0 100%)`)
  return gradients
}

export default function BorderGlow({
  children,
  className = "",
  edgeSensitivity = 30,
  glowColor,
  backgroundColor = "var(--primary)",
  glowRadius = 40,
  glowIntensity = 1.0,
  coneSpread = 25,
  animated = false,
  colors = ["var(--primary)", "var(--primary)", "var(--primary)"],
  fillOpacity = 0.35,
}: BorderGlowProps) {
  const cardRef = useRef<HTMLDivElement>(null)
  const [isHovered, setIsHovered] = useState(false)
  const [cursorAngle, setCursorAngle] = useState(45)
  const [edgeProximity, setEdgeProximity] = useState(0)
  const [sweepActive, setSweepActive] = useState(false)
  const [internalGlowColor, setInternalGlowColor] = useState("221 100 64")
  const resolvedGlowColor = glowColor || internalGlowColor

  useEffect(() => {
    if (glowColor) return

    const update = () => {
      requestAnimationFrame(() => {
        const hsl = readPrimaryAsHSL()
        if (hsl) setInternalGlowColor(hsl)
      })
    }

    update()

    const observer = new MutationObserver((mutations) => {
      for (const m of mutations) {
        if (
          m.type === "attributes" &&
          (m.attributeName === "class" || m.attributeName === "style")
        ) {
          update()
        }
      }
      for (const m of mutations) {
        if (m.type === "childList") {
          update()
        }
      }
    })

    observer.observe(document.body, {
      attributes: true,
      attributeFilter: ["class"],
    })
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "style"],
    })
    observer.observe(document.head, { childList: true, subtree: true })

    return () => observer.disconnect()
  }, [glowColor])

  const getCenterOfElement = useCallback((el: HTMLElement) => {
    const { width, height } = el.getBoundingClientRect()
    return [width / 2, height / 2]
  }, [])

  const getEdgeProximity = useCallback(
    (el: HTMLElement, x: number, y: number) => {
      const [cx, cy] = getCenterOfElement(el)
      const dx = x - cx
      const dy = y - cy
      let kx = Infinity
      let ky = Infinity
      if (dx !== 0) kx = cx / Math.abs(dx)
      if (dy !== 0) ky = cy / Math.abs(dy)
      return Math.min(Math.max(1 / Math.min(kx, ky), 0), 1)
    },
    [getCenterOfElement]
  )

  const getCursorAngle = useCallback(
    (el: HTMLElement, x: number, y: number) => {
      const [cx, cy] = getCenterOfElement(el)
      const dx = x - cx
      const dy = y - cy
      if (dx === 0 && dy === 0) return 0
      const radians = Math.atan2(dy, dx)
      let degrees = radians * (180 / Math.PI) + 90
      if (degrees < 0) degrees += 360
      return degrees
    },
    [getCenterOfElement]
  )

  const handlePointerMove = useCallback(
    (e: PointerEvent<HTMLDivElement>) => {
      const card = cardRef.current
      if (!card) return
      const rect = card.getBoundingClientRect()
      const x = e.clientX - rect.left
      const y = e.clientY - rect.top
      setEdgeProximity(getEdgeProximity(card, x, y))
      setCursorAngle(getCursorAngle(card, x, y))
    },
    [getEdgeProximity, getCursorAngle]
  )

  useEffect(() => {
    if (!animated) return
    const angleStart = 110
    const angleEnd = 465
    setTimeout(() => setSweepActive(true), 0)
    setTimeout(() => setCursorAngle(angleStart), 0)

    animateValue({ duration: 500, onUpdate: (v) => setEdgeProximity(v / 100) })
    animateValue({
      ease: easeInCubic,
      duration: 1500,
      end: 50,
      onUpdate: (v) => {
        setCursorAngle((angleEnd - angleStart) * (v / 100) + angleStart)
      },
    })
    animateValue({
      ease: easeOutCubic,
      delay: 1500,
      duration: 2250,
      start: 50,
      end: 100,
      onUpdate: (v) => {
        setCursorAngle((angleEnd - angleStart) * (v / 100) + angleStart)
      },
    })
    animateValue({
      ease: easeInCubic,
      delay: 2500,
      duration: 1500,
      start: 100,
      end: 0,
      onUpdate: (v) => setEdgeProximity(v / 100),
      onEnd: () => setSweepActive(false),
    })
  }, [animated])

  const colorSensitivity = edgeSensitivity + 20
  const isVisible = isHovered || sweepActive
  const borderOpacity = Math.max(
    isVisible
      ? Math.max(
          0,
          (edgeProximity * 100 - colorSensitivity) / (100 - colorSensitivity)
        )
      : 0.12,
    0.12
  )
  const glowOpacity = isVisible
    ? Math.max(
        0,
        (edgeProximity * 100 - edgeSensitivity) / (100 - edgeSensitivity)
      )
    : 0.07

  const meshGradients = buildMeshGradients(colors)
  const borderBg = meshGradients.map((g) => `${g} border-box`)
  const fillBg = meshGradients.map((g) => `${g} padding-box`)
  const angleDeg = `${cursorAngle.toFixed(3)}deg`

  return (
    <div
      ref={cardRef}
      onPointerMove={handlePointerMove}
      onPointerEnter={() => setIsHovered(true)}
      onPointerLeave={() => setIsHovered(false)}
      className={`rounded-[inherit]] relative isolate grid ${className}`}
      style={{
        background: backgroundColor,
        transform: "translate3d(0, 0, 0.01px)",
      }}
    >
      <div
        className="absolute inset-0 z-[-1] rounded-[inherit]"
        style={{
          background: [
            `linear-gradient(${backgroundColor} 0 100%) padding-box`,
            "linear-gradient(rgb(255 255 255 / 0%) 0% 100%) border-box",
            ...borderBg,
          ].join(", "),
          opacity: borderOpacity,
          maskImage: `conic-gradient(from ${angleDeg} at center, black ${coneSpread}%, transparent ${coneSpread + 15}%, transparent ${100 - coneSpread - 15}%, black ${100 - coneSpread}%)`,
          WebkitMaskImage: `conic-gradient(from ${angleDeg} at center, black ${coneSpread}%, transparent ${coneSpread + 15}%, transparent ${100 - coneSpread - 15}%, black ${100 - coneSpread}%)`,
          transition: isVisible
            ? "opacity 0.25s ease-out"
            : "opacity 0.75s ease-in-out",
        }}
      />

      <div
        className="absolute inset-0 z-[-1] rounded-[inherit]"
        style={
          {
            border: "1px solid transparent",
            background: fillBg.join(", "),
            maskImage: [
              "linear-gradient(to bottom, black, black)",
              "radial-gradient(ellipse at 50% 50%, black 40%, transparent 65%)",
              "radial-gradient(ellipse at 66% 66%, black 5%, transparent 40%)",
              "radial-gradient(ellipse at 33% 33%, black 5%, transparent 40%)",
              "radial-gradient(ellipse at 66% 33%, black 5%, transparent 40%)",
              "radial-gradient(ellipse at 33% 66%, black 5%, transparent 40%)",
              `conic-gradient(from ${angleDeg} at center, transparent 5%, black 15%, black 85%, transparent 95%)`,
            ].join(", "),
            WebkitMaskImage: [
              "linear-gradient(to bottom, black, black)",
              "radial-gradient(ellipse at 50% 50%, black 40%, transparent 65%)",
              "radial-gradient(ellipse at 66% 66%, black 5%, transparent 40%)",
              "radial-gradient(ellipse at 33% 33%, black 5%, transparent 40%)",
              "radial-gradient(ellipse at 66% 33%, black 5%, transparent 40%)",
              "radial-gradient(ellipse at 33% 66%, black 5%, transparent 40%)",
              `conic-gradient(from ${angleDeg} at center, transparent 5%, black 15%, black 85%, transparent 95%)`,
            ].join(", "),
            maskComposite: "subtract, add, add, add, add, add",
            WebkitMaskComposite:
              "source-out, source-over, source-over, source-over, source-over, source-over",
            opacity: borderOpacity * fillOpacity,
            mixBlendMode: "soft-light",
            transition: isVisible
              ? "opacity 0.25s ease-out"
              : "opacity 0.75s ease-in-out",
          } as CSSProperties
        }
      />

      <span
        className="pointer-events-none absolute z-1 rounded-[inherit]"
        style={
          {
            inset: `${-glowRadius}px`,
            maskImage: `conic-gradient(from ${angleDeg} at center, black 2.5%, transparent 10%, transparent 90%, black 97.5%)`,
            WebkitMaskImage: `conic-gradient(from ${angleDeg} at center, black 2.5%, transparent 10%, transparent 90%, black 97.5%)`,
            opacity: glowOpacity,
            mixBlendMode: "plus-lighter",
            transition: isVisible
              ? "opacity 0.25s ease-out"
              : "opacity 0.75s ease-in-out",
          } as CSSProperties
        }
      >
        <span
          className="absolute rounded-[inherit]"
          style={{
            inset: `${glowRadius}px`,
            boxShadow: buildBoxShadow(resolvedGlowColor, glowIntensity),
          }}
        />
      </span>

      <div className="relative z-1 flex flex-col overflow-auto p-0">
        {children}
      </div>
    </div>
  )
}
```
