Skip to content

Photo Accordion

Image accordion for React: narrow photo strips where the hovered or tapped one grows and the rest make room. Keyboard ready.

  • Micro-interactions
  • Travel & hospitality
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (flex-grow transitions). Hover on mouse, tap on touch, arrow keys for focus. 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-accordion.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/micro-interactions/photo-accordion.tsx
1/**
2 * PhotoAccordion, a row of narrow photo strips where the one under the
3 * pointer (or the last one you tapped) grows and the others make room.
4 *
5 * Props: items[] ({ src, title }), width (px, default 600), height (px,
6 * default 340), defaultActive (index, default 0), expand (growth factor of
7 * the active strip, default 7), onChange(index), aria-label.
8 * CSS vars: --fd-pa-radius, --fd-pa-gap.
9 *
10 * Browser support: all evergreen browsers (flex-grow transitions).
11 * Accessibility: every strip is a button; ArrowLeft/Right move the active
12 * strip, hover follows fine pointers, tap works on touch. Reduced motion:
13 * strips resize without easing.
14 */
15import { useRef, useState } from "react";
16import type { KeyboardEvent } from "react";
17
18const TONES = [
19 "linear-gradient(160deg,#f3d1b8,#d7959a)",
20 "linear-gradient(160deg,#c9d6f2,#8e9fd6)",
21 "linear-gradient(160deg,#d3e6cf,#8fb996)",
22 "linear-gradient(160deg,#f2e2b6,#d9b36b)",
23 "linear-gradient(160deg,#e3cdf0,#a98fd0)",
24];
25
26export interface AccordionItem {
27 src: string;
28 title: string;
29}
30
31export interface PhotoAccordionProps {
32 items: AccordionItem[];
33 width?: number;
34 height?: number;
35 defaultActive?: number;
36 expand?: number;
37 onChange?: (index: number) => void;
38 className?: string;
39 "aria-label"?: string;
40}
41
42export function PhotoAccordion({
43 items,
44 width = 600,
45 height = 340,
46 defaultActive = 0,
47 expand = 7,
48 onChange,
49 className,
50 "aria-label": label = "Photos",
51}: PhotoAccordionProps) {
52 const [active, setActive] = useState(defaultActive);
53 const refs = useRef<(HTMLButtonElement | null)[]>([]);
54
55 const go = (i: number) => {
56 if (i === active) return;
57 setActive(i);
58 onChange?.(i);
59 };
60 const key = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
61 const to = e.key === "ArrowRight" ? Math.min(i + 1, items.length - 1) : e.key === "ArrowLeft" ? Math.max(i - 1, 0) : -1;
62 if (to < 0) return;
63 e.preventDefault();
64 go(to);
65 refs.current[to]?.focus();
66 };
67
68 return (
69 <div
70 className={`fd-pa${className ? ` ${className}` : ""}`}
71 role="group"
72 aria-label={label}
73 style={{ maxWidth: width, height }}
74 >
75 <style>{css}</style>
76 {items.map((it, i) => (
77 <button
78 key={i}
79 ref={(n) => {
80 refs.current[i] = n;
81 }}
82 type="button"
83 className="fd-pa-strip"
84 aria-pressed={active === i}
85 aria-label={it.title}
86 data-active={active === i || undefined}
87 style={{ flexGrow: active === i ? expand : 1, background: TONES[i % TONES.length] }}
88 onPointerEnter={(e) => e.pointerType === "mouse" && go(i)}
89 onClick={() => go(i)}
90 onFocus={() => go(i)}
91 onKeyDown={(e) => key(e, i)}
92 >
93 <Img src={it.src} />
94 <span className="fd-pa-shade" />
95 <span className="fd-pa-title">{it.title}</span>
96 </button>
97 ))}
98 </div>
99 );
100}
101
102function Img({ src }: { src: string }) {
103 const [ok, setOk] = useState(true);
104 if (!ok) return null;
105 return <img className="fd-pa-img" src={src} alt="" draggable={false} loading="lazy" decoding="async" onError={() => setOk(false)} />;
106}
107
108const FONT = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif`;
109const css = `
110.fd-pa,.fd-pa *,.fd-pa *::before,.fd-pa *::after{box-sizing:border-box}
111.fd-pa{--fd-pa-radius:20px;--fd-pa-gap:8px;--fd-ease-out:cubic-bezier(0.22,1,0.36,1);display:flex;gap:var(--fd-pa-gap);width:100%;margin:0;padding:0;font:400 14px/1.4 ${FONT};color:#fff}
112.fd-pa-strip{position:relative;display:block;flex:1 1 0;min-width:0;margin:0;padding:0;border:0;border-radius:var(--fd-pa-radius);overflow:hidden;cursor:pointer;appearance:none;-webkit-appearance:none;box-shadow:0 1px 2px rgba(0,0,0,.1),0 14px 28px -14px rgba(0,0,0,.35);transition:flex-grow 700ms var(--fd-ease-out);-webkit-tap-highlight-color:transparent}
113.fd-pa-strip:focus-visible{outline:2px solid #1a73e8;outline-offset:3px}
114.fd-pa-img{position:absolute;inset:0;display:block;width:100%;height:100%;margin:0;padding:0;border:0;object-fit:cover;pointer-events:none;transform:scale(1.12);transition:transform 900ms var(--fd-ease-out)}
115.fd-pa-strip[data-active] .fd-pa-img{transform:scale(1)}
116.fd-pa-shade{position:absolute;inset:0;display:block;background:linear-gradient(180deg,transparent 55%,rgba(0,0,0,.45));opacity:0;transition:opacity 500ms;pointer-events:none}
117.fd-pa-strip[data-active] .fd-pa-shade{opacity:1}
118.fd-pa-title{position:absolute;left:20px;bottom:16px;display:block;margin:0;padding:0;font:500 16px/1.2 ${FONT};letter-spacing:-.005em;white-space:nowrap;opacity:0;transform:translateY(8px);transition:opacity 300ms,transform 500ms var(--fd-ease-out)}
119.fd-pa-strip[data-active] .fd-pa-title{opacity:1;transform:none;transition-delay:200ms}
120@media (prefers-reduced-motion:reduce){.fd-pa-strip,.fd-pa-img,.fd-pa-title{transition:none!important}}
121`;
122
123export function Demo() {
124 const items = [
125 { n: "ridge", t: "Ridge" },
126 { n: "balloons", t: "Balloons" },
127 { n: "sunset", t: "Sunset" },
128 { n: "meadow", t: "Meadow" },
129 { n: "butterfly", t: "Butterfly" },
130 ];
131 return (
132 <div className="fd-pad">
133 <style>{demoCss}</style>
134 <PhotoAccordion items={items.map((i) => ({ src: `/demo/${i.n}.webp`, title: i.t }))} defaultActive={2} />
135 </div>
136 );
137}
138
139const demoCss = `
140.fd-pad,.fd-pad *,.fd-pad *::before,.fd-pad *::after{box-sizing:border-box}
141.fd-pad{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}
142`;

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 Accordion

Run npx shadcn@latest add https://freedesignmd.com/r/photo-accordion.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.

Image accordion for React: narrow photo strips where the hovered or tapped one grows and the rest make room. Keyboard ready. 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 (flex-grow transitions). Hover on mouse, tap on touch, arrow keys for focus. 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.