Skip to content

Photo Dock

Photo dock for React: thumbnails swell around the pointer with a smooth falloff, labels float up, clicks hop. Keyboard friendly.

  • Micro-interactions
  • Photography
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers. Keyboard focus magnifies the tile; touch and reduced motion keep the sizes. Photos are plain URLs you pass in; if one fails to load, a soft gradient stays in its place. The demo uses /demo/*.webp, swap in your own files.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/photo-dock.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/micro-interactions/photo-dock.tsx
1/**
2 * PhotoDock, a strip of photo thumbnails that swell around the pointer with
3 * a smooth falloff, like a dock. Neighbours make room, a label floats above.
4 *
5 * Props: items[] ({ src, label }), size (px of a resting tile, default 52),
6 * magnify (peak scale, default 1.9), reach (px of influence, default 120),
7 * onSelect(index), aria-label.
8 * CSS vars: --fd-pd-radius, --fd-pd-gap.
9 *
10 * Browser support: all evergreen browsers (pointer events + CSS sizes).
11 * Accessibility: tiles are buttons in a toolbar; keyboard focus magnifies
12 * the focused tile just like hover. Reduced motion and touch: no magnify.
13 */
14import { useState } from "react";
15import type { PointerEvent } from "react";
16
17const TONES = [
18 "linear-gradient(160deg,#f3d1b8,#d7959a)",
19 "linear-gradient(160deg,#c9d6f2,#8e9fd6)",
20 "linear-gradient(160deg,#d3e6cf,#8fb996)",
21 "linear-gradient(160deg,#f2e2b6,#d9b36b)",
22 "linear-gradient(160deg,#e3cdf0,#a98fd0)",
23];
24
25export interface DockItem {
26 src: string;
27 label: string;
28}
29
30export interface PhotoDockProps {
31 items: DockItem[];
32 size?: number;
33 magnify?: number;
34 reach?: number;
35 onSelect?: (index: number) => void;
36 className?: string;
37 "aria-label"?: string;
38}
39
40export function PhotoDock({
41 items,
42 size = 52,
43 magnify = 1.9,
44 reach = 120,
45 onSelect,
46 className,
47 "aria-label": label = "Photos",
48}: PhotoDockProps) {
49 const gap = 8;
50 const pitch = size + gap;
51 const [x, setX] = useState<number | null>(null);
52 const [focus, setFocus] = useState<number | null>(null);
53 const [hits, setHits] = useState<Record<number, number>>({});
54
55 const center = x !== null ? x : focus !== null ? 8 + focus * pitch + size / 2 : null;
56
57 const move = (e: PointerEvent<HTMLDivElement>) => {
58 if (e.pointerType === "touch") return;
59 const r = e.currentTarget.getBoundingClientRect();
60 setX(e.clientX - r.left);
61 };
62
63 const scaleAt = (i: number) => {
64 if (center === null) return 1;
65 const d = center - (8 + i * pitch + size / 2);
66 return 1 + (magnify - 1) * Math.exp(-(d * d) / (2 * (reach / 2.2) ** 2));
67 };
68
69 const active = center === null ? -1 : items.reduce((best, _, i) => (Math.abs(center - (8 + i * pitch + size / 2)) < Math.abs(center - (8 + best * pitch + size / 2)) ? i : best), 0);
70
71 return (
72 <div
73 className={`fd-pd${className ? ` ${className}` : ""}`}
74 style={{ height: Math.round(size * magnify) + 16 + 36 }}
75 >
76 <style>{css}</style>
77 <div
78 className="fd-pd-bar"
79 role="toolbar"
80 aria-label={label}
81 onPointerMove={move}
82 onPointerLeave={() => setX(null)}
83 style={{ paddingInline: 8, gap }}
84 >
85 {items.map((it, i) => {
86 const s = scaleAt(i);
87 return (
88 <button
89 key={i}
90 type="button"
91 className="fd-pd-tile"
92 aria-label={it.label}
93 data-active={active === i || undefined}
94 data-hit={hits[i] || undefined}
95 onFocus={() => setFocus(i)}
96 onBlur={() => setFocus(null)}
97 onClick={() => {
98 setHits((h) => ({ ...h, [i]: (h[i] || 0) + 1 }));
99 onSelect?.(i);
100 }}
101 onAnimationEnd={() => setHits((h) => ({ ...h, [i]: 0 }))}
102 style={{ width: size * s, height: size * s, background: TONES[i % TONES.length] }}
103 >
104 <Img src={it.src} />
105 <span className="fd-pd-label">{it.label}</span>
106 </button>
107 );
108 })}
109 </div>
110 </div>
111 );
112}
113
114function Img({ src }: { src: string }) {
115 const [ok, setOk] = useState(true);
116 if (!ok) return null;
117 return <img className="fd-pd-img" src={src} alt="" draggable={false} decoding="async" onError={() => setOk(false)} />;
118}
119
120const FONT = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif`;
121const css = `
122.fd-pd,.fd-pd *,.fd-pd *::before,.fd-pd *::after{box-sizing:border-box}
123.fd-pd{--fd-pd-radius:14px;--fd-ease-out:cubic-bezier(0.22,1,0.36,1);display:flex;align-items:flex-end;justify-content:center;margin:0;padding:0;font:400 12px/1.2 ${FONT}}
124.fd-pd-bar{display:flex;align-items:flex-end;margin:0;padding-block:8px;border:1px solid rgba(255,255,255,.7);border-radius:22px;background:rgba(255,255,255,.55);-webkit-backdrop-filter:blur(16px) saturate(1.3);backdrop-filter:blur(16px) saturate(1.3);box-shadow:0 1px 1px rgba(255,255,255,.8) inset,0 14px 30px -14px rgba(0,0,0,.3)}
125.fd-pd-tile{position:relative;display:block;flex:none;margin:0;padding:0;border:0;border-radius:var(--fd-pd-radius);overflow:visible;cursor:pointer;appearance:none;-webkit-appearance:none;box-shadow:0 1px 2px rgba(0,0,0,.18),0 6px 12px -6px rgba(0,0,0,.3);transition:width 110ms linear,height 110ms linear;-webkit-tap-highlight-color:transparent}
126.fd-pd-img{position:absolute;inset:0;display:block;width:100%;height:100%;margin:0;padding:0;border:0;border-radius:inherit;object-fit:cover;pointer-events:none}
127.fd-pd-tile:focus-visible{outline:2px solid #1a73e8;outline-offset:3px}
128.fd-pd-tile[data-hit]{animation:fd-pd-hop 480ms var(--fd-ease-out)}
129@keyframes fd-pd-hop{0%{transform:translateY(0)}35%{transform:translateY(-14px)}70%{transform:translateY(0)}85%{transform:translateY(-3px)}100%{transform:translateY(0)}}
130.fd-pd-label{position:absolute;left:50%;bottom:calc(100% + 10px);display:block;margin:0;padding:5px 10px;border-radius:999px;background:rgba(20,20,22,.82);color:#fff;font:500 12px/1.2 ${FONT};white-space:nowrap;pointer-events:none;opacity:0;transform:translate(-50%,4px);transition:opacity 160ms,transform 220ms var(--fd-ease-out)}
131.fd-pd-tile[data-active]:hover .fd-pd-label,.fd-pd-tile[data-active]:focus-visible .fd-pd-label{opacity:1;transform:translate(-50%,0)}
132@media (prefers-reduced-motion:reduce),(hover:none){.fd-pd-tile{transition:none}.fd-pd-tile[data-hit]{animation:none}}
133`;
134
135export function Demo() {
136 const names = ["eagle", "butterfly", "giraffe", "iguana", "meadow", "balloons", "sunset"];
137 const label = (n: string) => n[0].toUpperCase() + n.slice(1);
138 return (
139 <div className="fd-pdd">
140 <style>{demoCss}</style>
141 <PhotoDock items={names.map((n) => ({ src: `/demo/${n}-s.webp`, label: label(n) }))} />
142 </div>
143 );
144}
145
146const demoCss = `
147.fd-pdd,.fd-pdd *,.fd-pdd *::before,.fd-pdd *::after{box-sizing:border-box}
148.fd-pdd{display:flex;align-items:center;justify-content:center;width:100%;min-height:100vh;margin:0;padding:48px 20px;background:radial-gradient(48% 60% at 30% 40%,#ffe0c4 0%,transparent 70%),radial-gradient(44% 56% at 72% 62%,#c6d0ff 0%,transparent 70%),#f1eeea;font:400 14px/1.4 ${FONT};letter-spacing:normal;overflow:hidden}
149`;

More Micro-interactions. Same file contract.

Card Stack, micro interactions preview: Card stack for React: photo cards you throw to the back with a drag or a flick, tap to pull one forward

Card Stack

Card stack for React: photo cards you throw to the back with a drag or a flick, tap to pull one forward. No dependencies.

Photo Sleeves, micro interactions preview: Photo album sleeves for React: pictures peek out, lift on hover and fan open on click

Photo Sleeves

Photo album sleeves for React: pictures peek out, lift on hover and fan open on click. Frosted pocket, zero dependencies.

Tilt Card, micro interactions preview: 3D tilt card for React: leans toward the pointer with a soft sheen and a drifting photo

Tilt Card

3D tilt card for React: leans toward the pointer with a soft sheen and a drifting photo. Lightweight, no animation library.

Questions about Photo Dock

Run npx shadcn@latest add https://freedesignmd.com/r/photo-dock.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Photo dock for React: thumbnails swell around the pointer with a smooth falloff, labels float up, clicks hop. Keyboard friendly. It is a free, MIT-licensed React micro-interactions component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers. Keyboard focus magnifies the tile; touch and reduced motion keep the sizes. Photos are plain URLs you pass in; if one fails to load, a soft gradient stays in its place. The demo uses /demo/*.webp, swap in your own files.

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.