Skip to main content

Spotlight Quotes

A wall of quotes rendered as one flowing paragraph, each seeded with the author's avatar inline. Hovering a quote sharpens it while the rest blur back, and a small name/role card lifts out of the avatar. Only the hovered id drives state, so just the active quote and its neighbours ever re-style.

TextReactMotionTailwind CSS
CSSTailwind

Manual

Create a file and paste the following code into it.

spotlight-quotes.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
"use client";

import { useState } from "react";
import { AnimatePresence, motion } from "motion/react";
import { cn } from "@/lib/cn";

/* ------------------------------------------------------------------ */
/*  SpotlightQuotes                                                 */
/* ------------------------------------------------------------------ */

export interface Testimonial {
  id: string;
  text: string;
  author: { name: string; role: string; avatar: string };
}

interface SpotlightQuotesProps {
  testimonials: Testimonial[];
  /** Color of the lifted author name — the one accent in the flow [Optional, default: "#f97316"] */
  accentColor?: string;
  /** Blur applied to non-hovered quotes in px [Optional, default: 5] */
  blurAmount?: number;
  /** Opacity of blurred quotes [Optional, default: 0.25] */
  blurOpacity?: number;
  /** Avatar diameter in px [Optional, default: 32] */
  avatarSize?: number;
  /** Base font size in px [Optional, default: 30] */
  fontSize?: number;
  className?: string;
}

/**
 * A wall of quotes rendered as one flowing paragraph, each seeded with the
 * author's avatar inline. Hovering a quote sharpens it while the rest blur
 * back, and a small name/role card lifts out of the avatar. State is the one
 * hovered id, so only the active quote and its neighbours ever re-style.
 * @param {Testimonial[]} testimonials - Quotes with inline author avatars [Required]
 * @param {number} fontSize - Base font size in px [Optional, default: 30]
 */
export function SpotlightQuotes({
  testimonials,
  accentColor = "#f97316",
  blurAmount = 5,
  blurOpacity = 0.25,
  avatarSize = 32,
  fontSize = 30,
  className,
}: SpotlightQuotesProps) {
  const [hovered, setHovered] = useState<string | null>(null);

  return (
    <div
      className={cn("font-medium tracking-tight text-zinc-900 dark:text-zinc-100", className)}
      style={{ fontSize: `${fontSize}px`, lineHeight: 1.35 }}
    >
      {testimonials.map((item, index) => {
        const isActive = hovered === item.id;
        const isDimmed = hovered !== null && !isActive;
        const restOpacity = index % 2 !== 0 ? 0.7 : 1;
        return (
          <span
            key={item.id}
            onMouseEnter={() => setHovered(item.id)}
            onMouseLeave={() => setHovered(null)}
            style={{
              filter: isDimmed ? `blur(${blurAmount}px)` : "none",
              opacity: isDimmed ? blurOpacity : isActive ? 1 : restOpacity,
              transition: "filter 0.3s ease, opacity 0.3s ease",
              cursor: "default",
            }}
          >
            <span
              className="relative inline-block"
              style={{ width: avatarSize, height: avatarSize, verticalAlign: "middle", marginRight: 3 }}
            >
              <img
                src={item.author.avatar}
                alt={item.author.name}
                width={avatarSize}
                height={avatarSize}
                style={{ width: avatarSize, height: avatarSize, borderRadius: "50%", objectFit: "cover", display: "block" }}
              />
              <AnimatePresence>
                {isActive && (
                  <motion.span
                    initial={{ opacity: 0, x: -6 }}
                    animate={{ opacity: 1, x: 0 }}
                    exit={{ opacity: 0, x: -6 }}
                    transition={{ duration: 0.16, ease: "easeOut" }}
                    style={{
                      position: "absolute",
                      left: avatarSize / 2,
                      bottom: "150%",
                      transform: "translateY(-50%)",
                      display: "inline-flex",
                      flexDirection: "column",
                      gap: 3,
                      whiteSpace: "nowrap",
                      pointerEvents: "none",
                      zIndex: 20,
                    }}
                  >
                    <span className="text-[12px] font-semibold leading-tight" style={{ color: accentColor }}>
                      {item.author.name}
                    </span>
                    <span className="text-[9px] font-medium uppercase leading-tight tracking-[0.1em] text-zinc-500 dark:text-zinc-400">
                      {item.author.role}
                    </span>
                  </motion.span>
                )}
              </AnimatePresence>
            </span>
            <span>{item.text} </span>
          </span>
        );
      })}
    </div>
  );
}

Update the import paths to match your project setup.

Similar components

Prism Reveal Text

Char Spring Morph

Snap Scroll Headlines

Cipher Text Reveal

Install via CLI

Resource details

PublishedJuly 17, 2026
CategoryText
ReactMotionTailwind CSS