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
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 the3 * 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 of7 * 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 active12 * 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 <div70 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 <button78 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.
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.


