Skip to content

Photo Sleeves

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

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

Browser support · All evergreen browsers; backdrop-filter falls back to a flat tint. 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-sleeves.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/micro-interactions/photo-sleeves.tsx
1/**
2 * PhotoSleeves, record-sleeve albums with photos peeking out of the top.
3 * Hover lifts the photos, a click fans them out above the sleeve.
4 *
5 * Props: albums[] ({ name, images[] }), photoWidth (px, default 104),
6 * onOpen(index | null), aria-label.
7 * CSS vars: --fd-ps-radius, --fd-ps-sleeve (sleeve tint).
8 *
9 * Browser support: all evergreen browsers; the sleeve uses backdrop-filter
10 * and falls back to a flat tint without it.
11 * Accessibility: every sleeve is a button with aria-expanded; Escape or a
12 * click outside closes the open one. Reduced motion: no staggering.
13 */
14import { useEffect, useRef, useState } from "react";
15
16const TONES = [
17 "linear-gradient(160deg,#f3d1b8,#d7959a)",
18 "linear-gradient(160deg,#c9d6f2,#8e9fd6)",
19 "linear-gradient(160deg,#d3e6cf,#8fb996)",
20 "linear-gradient(160deg,#f2e2b6,#d9b36b)",
21];
22
23export interface Album {
24 name: string;
25 images: string[];
26}
27
28export interface PhotoSleevesProps {
29 albums: Album[];
30 photoWidth?: number;
31 onOpen?: (index: number | null) => void;
32 className?: string;
33 "aria-label"?: string;
34}
35
36export function PhotoSleeves({
37 albums,
38 photoWidth = 104,
39 onOpen,
40 className,
41 "aria-label": label = "Photo albums",
42}: PhotoSleevesProps) {
43 const [open, setOpen] = useState<number | null>(null);
44 const root = useRef<HTMLDivElement>(null);
45 const photoH = Math.round(photoWidth * 1.32);
46 const sleeveH = Math.round(photoH * 0.62);
47
48 const set = (v: number | null) => {
49 setOpen(v);
50 onOpen?.(v);
51 };
52
53 useEffect(() => {
54 if (open === null) return;
55 const onKey = (e: globalThis.KeyboardEvent) => e.key === "Escape" && set(null);
56 const onDown = (e: globalThis.PointerEvent) => {
57 if (root.current && !root.current.contains(e.target as Node)) set(null);
58 };
59 window.addEventListener("keydown", onKey);
60 window.addEventListener("pointerdown", onDown);
61 return () => {
62 window.removeEventListener("keydown", onKey);
63 window.removeEventListener("pointerdown", onDown);
64 };
65 // eslint-disable-next-line react-hooks/exhaustive-deps
66 }, [open]);
67
68 return (
69 <div
70 ref={root}
71 className={`fd-ps${className ? ` ${className}` : ""}`}
72 role="group"
73 aria-label={label}
74 style={{ paddingTop: photoH + 24 }}
75 >
76 <style>{css}</style>
77 {albums.map((album, a) => {
78 const isOpen = open === a;
79 const n = album.images.length;
80 return (
81 <div
82 key={a}
83 className="fd-ps-album"
84 data-open={isOpen || undefined}
85 data-dim={(open !== null && !isOpen) || undefined}
86 style={{ width: photoWidth + 56, height: sleeveH + 14, zIndex: isOpen ? 5 : 1 }}
87 >
88 {album.images.map((src, i) => {
89 const mid = (n - 1) / 2;
90 const spread = (i - mid) * (photoWidth * 0.58);
91 const tilt = (i - mid) * 7;
92 return (
93 <span
94 key={i}
95 className="fd-ps-photo"
96 style={
97 {
98 width: photoWidth,
99 height: photoH,
100 "--rest": `${(i % 2 ? 1 : -1) * 2}deg`,
101 "--x": `${spread}px`,
102 "--r": `${tilt}deg`,
103 "--i": i,
104 background: TONES[(a + i) % TONES.length],
105 } as React.CSSProperties
106 }
107 >
108 <Img src={src} />
109 </span>
110 );
111 })}
112 <button
113 type="button"
114 className="fd-ps-sleeve"
115 aria-expanded={isOpen}
116 aria-label={`${album.name}, ${n} photos`}
117 onClick={() => set(isOpen ? null : a)}
118 style={{ height: sleeveH }}
119 >
120 <span className="fd-ps-name">{album.name}</span>
121 <span className="fd-ps-count">{n}</span>
122 </button>
123 </div>
124 );
125 })}
126 </div>
127 );
128}
129
130function Img({ src }: { src: string }) {
131 const [ok, setOk] = useState(true);
132 if (!ok) return null;
133 return <img className="fd-ps-img" src={src} alt="" draggable={false} loading="lazy" decoding="async" onError={() => setOk(false)} />;
134}
135
136const FONT = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif`;
137const css = `
138.fd-ps,.fd-ps *,.fd-ps *::before,.fd-ps *::after{box-sizing:border-box}
139.fd-ps{--fd-ps-radius:18px;--fd-ps-sleeve:rgba(255,255,255,.55);--fd-ease-out:cubic-bezier(0.22,1,0.36,1);display:flex;flex-wrap:wrap;align-items:flex-end;justify-content:center;gap:28px 20px;margin:0;color:#111;font:400 14px/1.4 ${FONT}}
140.fd-ps-album{position:relative;display:block;margin:0;padding:0;transition:opacity 300ms,filter 300ms}
141.fd-ps-album[data-dim]{opacity:.45;filter:saturate(.7)}
142.fd-ps-photo{position:absolute;left:50%;bottom:10px;display:block;margin:0;padding:0;border-radius:12px;overflow:hidden;box-shadow:0 1px 2px rgba(0,0,0,.1),0 10px 22px -8px rgba(0,0,0,.35);transform-origin:50% 100%;transform:translate3d(-50%,0,0) rotate(var(--rest));transition:transform 560ms var(--fd-ease-out);transition-delay:calc(var(--i) * 28ms)}
143.fd-ps-album:hover:not([data-open]) .fd-ps-photo{transform:translate3d(-50%,-26px,0) rotate(calc(var(--rest) * 2))}
144.fd-ps-album[data-open] .fd-ps-photo{transform:translate3d(calc(-50% + var(--x)),-70%,0) rotate(var(--r));transition-delay:calc(var(--i) * 40ms)}
145.fd-ps-img{position:absolute;inset:0;display:block;width:100%;height:100%;margin:0;padding:0;border:0;object-fit:cover;pointer-events:none}
146.fd-ps-sleeve{position:absolute;left:0;right:0;bottom:0;display:flex;align-items:flex-end;justify-content:space-between;gap:8px;margin:0;padding:14px 16px;border:1px solid rgba(255,255,255,.7);border-radius:var(--fd-ps-radius);background:var(--fd-ps-sleeve);-webkit-backdrop-filter:blur(14px) saturate(1.3);backdrop-filter:blur(14px) saturate(1.3);box-shadow:0 1px 1px rgba(255,255,255,.7) inset,0 12px 28px -12px rgba(0,0,0,.28);color:#1d1d1f;font:500 14px/1.2 ${FONT};letter-spacing:normal;text-align:left;cursor:pointer;appearance:none;-webkit-appearance:none;transition:transform 400ms var(--fd-ease-out)}
147.fd-ps-sleeve:focus-visible{outline:2px solid #1a73e8;outline-offset:3px}
148.fd-ps-album:hover:not([data-open]) .fd-ps-sleeve{transform:translateY(2px)}
149.fd-ps-name{display:block;margin:0;padding:0}
150.fd-ps-count{display:block;margin:0;padding:0;color:rgba(0,0,0,.45);font:400 13px/1.2 ${FONT};font-variant-numeric:tabular-nums}
151@media (prefers-reduced-motion:reduce){.fd-ps-photo,.fd-ps-sleeve{transition:none}}
152`;
153
154export function Demo() {
155 const a = ["eagle", "butterfly", "giraffe", "iguana"];
156 const b = ["meadow", "ridge", "sunset", "balloons"];
157 const c = ["eye", "aurora", "butterfly", "giraffe"];
158 const url = (n: string) => `/demo/${n}-p.webp`;
159 return (
160 <div className="fd-psd">
161 <style>{demoCss}</style>
162 <PhotoSleeves
163 albums={[
164 { name: "Wildlife", images: a.map(url) },
165 { name: "Landscapes", images: b.map(url) },
166 { name: "Portraits", images: c.map(url) },
167 ]}
168 />
169 </div>
170 );
171}
172
173const demoCss = `
174.fd-psd,.fd-psd *,.fd-psd *::before,.fd-psd *::after{box-sizing:border-box}
175.fd-psd{display:flex;align-items:center;justify-content:center;width:100%;min-height:100vh;margin:0;padding:48px 20px;background:#ececec;font:400 14px/1.4 ${FONT};letter-spacing:normal;overflow:hidden}
176`;

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.

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.

Pull Button, micro interactions preview: Magnetic pull button for React: leans toward your cursor, the label moves further, then springs back

Pull Button

Magnetic pull button for React: leans toward your cursor, the label moves further, then springs back. A tiny rAF spring, no library.

Questions about Photo Sleeves

Run npx shadcn@latest add https://freedesignmd.com/r/photo-sleeves.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 album sleeves for React: pictures peek out, lift on hover and fan open on click. Frosted pocket, zero dependencies. 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; backdrop-filter falls back to a flat tint. 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.