Skip to main content

Peek Image Index

A text index whose rows reveal a square photo preview the moment the pointer lands: the preview springs to the hovered row's vertical center while non-hovered rows dim and the active row slides in. A ResizeObserver scales the preview, gap, and label size proportionally so it stays legible from a wide gallery down to a tight column.

NavigationReactMotionTailwind CSS
CSSTailwind

Manual

Create a file and paste the following code into it.

peek-image-index.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
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
"use client";

import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useMotionValue, useSpring, useTransform } from "motion/react";
import { cn } from "@/lib/cn";

/* ------------------------------------------------------------------ */
/*  PeekImageIndex                                                     */
/* ------------------------------------------------------------------ */

export interface HoverImageItem {
  label: string;
  /** Still preview [Optional if video given] */
  image?: string;
  /** Looping muted clip previewed instead of a still [Optional] */
  video?: string;
  imageAlt?: string;
}

interface PeekImageIndexProps {
  items: HoverImageItem[];
  /** Preview width in px at full container size [Optional, default: 220] */
  imageWidth?: number;
  /** Gap between preview and labels in px [Optional, default: 40] */
  imageGap?: number;
  className?: string;
}

const REVEAL_EASE = [0.23, 1, 0.32, 1] as const;

/**
 * A text list whose rows reveal a square media preview beside them on hover — stills or looping clips per item:
 * the preview springs to the hovered row's vertical center, non-hovered rows
 * dim and the active row slides in. Sizes scale down responsively so the whole
 * thing stays legible in narrow containers.
 * @param {HoverImageItem[]} items - Rows with label + preview image [Required]
 * @param {number} imageWidth - Preview width in px [Optional, default: 220]
 * @param {number} imageGap - Gap between preview and labels [Optional, default: 40]
 */
export function PeekImageIndex({
  items,
  imageWidth = 220,
  imageGap = 40,
  className,
}: PeekImageIndexProps) {
  const [active, setActive] = useState<number | null>(null);
  const [width, setWidth] = useState(0);
  const containerRef = useRef<HTMLDivElement>(null);
  const rowRefs = useRef<(HTMLDivElement | null)[]>([]);

  // Scale down proportionally in tight containers so nothing overflows.
  const previewWidth = width > 0 ? Math.min(imageWidth, width * 0.28) : imageWidth;
  const previewGap = width > 0 ? Math.min(imageGap, width * 0.05) : imageGap;
  const labelSize = width > 0 ? Math.min(Math.max(width * 0.2, 18), 62) : 30;

  useEffect(() => {
    const el = containerRef.current;
    if (!el) return;
    const ro = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width));
    ro.observe(el);
    setWidth(el.clientWidth);
    return () => ro.disconnect();
  }, []);

  const centerY = useMotionValue(0);
  const springY = useSpring(centerY, { stiffness: 200, damping: 18, mass: 0.6 });
  const topY = useTransform(springY, (v) => v - previewWidth / 2);

  const focusRow = (index: number) => {
    setActive(index);
    const row = rowRefs.current[index];
    const container = containerRef.current;
    if (row && container) {
      const r = row.getBoundingClientRect();
      const c = container.getBoundingClientRect();
      centerY.set(r.top - c.top + r.height / 2);
    }
  };

  return (
    <div
      ref={containerRef}
      className={cn("flex flex-row items-start", className)}
    >
      <div
        className="relative flex-shrink-0 self-stretch"
        style={{ width: previewWidth, marginRight: previewGap }}
      >
        <motion.div
          className="absolute left-0 w-full"
          style={{ top: topY }}
          animate={{
            opacity: active !== null ? 1 : 0,
            scale: active !== null ? 1 : 0.94,
            filter: active !== null ? "blur(0px)" : "blur(6px)",
          }}
          transition={{ duration: 0.25, ease: REVEAL_EASE }}
        >
          <AnimatePresence mode="popLayout">
            {active !== null &&
              (items[active].video ? (
                <motion.video
                  key={active}
                  src={items[active].video}
                  aria-label={items[active].imageAlt ?? ""}
                  className="aspect-square w-full rounded-2xl object-cover"
                  autoPlay
                  muted
                  loop
                  playsInline
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                  transition={{ duration: 0.18 }}
                />
              ) : (
                <motion.img
                  key={active}
                  src={items[active].image}
                  alt={items[active].imageAlt ?? ""}
                  className="aspect-square w-full rounded-2xl object-cover"
                  loading="lazy"
                  decoding="async"
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0 }}
                  transition={{ duration: 0.18 }}
                />
              ))}
          </AnimatePresence>
        </motion.div>
      </div>

      <div className="flex flex-col">
        {items.map((item, index) => (
          <motion.div
            key={item.label}
            ref={(el) => {
              rowRefs.current[index] = el;
            }}
            className="cursor-default select-none"
            animate={{
              x: active === index ? 16 : 0,
              opacity: active !== null && active !== index ? 0.18 : 1,
            }}
            transition={{ type: "spring", stiffness: 380, damping: 30 }}
            onHoverStart={() => focusRow(index)}
            onHoverEnd={() => setActive(null)}
          >
            <span
              className="block py-1 font-bold leading-tight tracking-tight text-zinc-900 dark:text-zinc-100"
              style={{ fontSize: labelSize }}
            >
              {item.label}
            </span>
          </motion.div>
        ))}
      </div>
    </div>
  );
}

Update the import paths to match your project setup.

Similar components

New

Docs Tree Sidebar

Max

Lens Dock

Unfold Panel Navbar

Drift Pill Navbar

Install via CLI

Resource details

PublishedJuly 17, 2026
CategoryNavigation
ReactMotionTailwind CSS