Hover Index List
Editorial index list for an architecture studio: a photo trails the cursor on a spring and rows open in place.
- Feature Sections
- Architecture
- React only · 0 deps
- 1 file
Browser support · All evergreen browsers. The trailing photo shows only with a fine pointer; touch screens get it inline when a row opens.
Install. One command, or copy the file.
$
npx shadcn@latest add https://freedesignmd.com/r/hover-index-list.json- Byte-identical to the preview
- No Tailwind or config needed
- SSR-safe · keyboard accessible
- Respects reduced motion & transparency
components/features/hover-index-list.tsx
1/**2 * HoverIndexList, an editorial, numbered capabilities index for an3 * architecture and interiors studio. Hovering a row brings up a photograph4 * that trails the cursor on a spring (tilting with its velocity) while the row5 * slides and the others step back. Every row also opens in place with a short6 * brief and selected projects; on touch screens the photo appears inline.7 *8 * Props: eyebrow, title, intro, items ({ id, title, tag, count, body,9 * projects[], src, alt }). Images default to /img/halcyon/* and /img/lux/still-*10 * (copy them into your public folder or pass your own `src`).11 * CSS vars: --fd-hil-paper, --fd-hil-ink, --fd-hil-red.12 *13 * Browser support: all evergreen browsers; expand uses grid-template-rows14 * interpolation (Chrome 107+, Safari 16+, Firefox 66+).15 * Motion: a single rAF spring (stiffness 380, damping 30) moves the floating16 * photo with transform only, and stops when it settles.17 * Accessibility: each row is a button with aria-expanded controlling its18 * panel; the floating photo is decorative (aria-hidden) because the same image19 * with alt text sits inside the panel. Keyboard focus shows the photo pinned to20 * the focused row.21 * Reduced motion: the photo appears in place without trailing or tilt.22 */23import { useEffect, useId, useRef, useState } from "react";24import type { ReactNode } from "react";25 26export interface HilItem {27 id: string;28 title: string;29 tag: string;30 count: number;31 body: string;32 projects: string[];33 src: string;34 alt: string;35}36 37export interface HoverIndexListProps {38 eyebrow?: ReactNode;39 title?: ReactNode;40 intro?: ReactNode;41 items?: HilItem[];42 className?: string;43}44 45const DEFAULT_ITEMS: HilItem[] = [46 { id: "houses", title: "Private houses", tag: "New build, extensions", count: 41, body: "Houses designed from the way a family actually uses the kitchen at 7am. We stay on through the build and come back after a year to see what we got wrong.", projects: ["Villa Lisca, Amalfi, 2025", "Huis aan de Kade, Delft, 2023", "Two Barns, Zeeland, 2021"], src: "/img/halcyon/suite.webp", alt: "A pale bedroom suite opening onto a terrace above the sea" },47 { id: "hotels", title: "Hotels", tag: "12 to 60 keys", count: 14, body: "Small hotels where every room is a little different and nobody has to read a sign to find the pool. Brief to opening, usually 26 months.", projects: ["Casa Halcyon, Positano, 2024", "Hotel Oostkade, Rotterdam, 2022"], src: "/img/halcyon/courtyard-pool.webp", alt: "A stone courtyard with a pool, lemon trees and the sea beyond" },48 { id: "restore", title: "Restoration", tag: "Listed buildings", count: 22, body: "Careful, slow work on buildings older than the street plan around them. We document everything, replace little and argue with heritage officers politely.", projects: ["Ryokan Kaze, Kyoto, 2024", "Pakhuis 1868, Amsterdam, 2020"], src: "/img/halcyon/ryokan.webp", alt: "A tatami room with sliding paper screens open to a garden" },49 { id: "objects", title: "Objects", tag: "Furniture, ceramics", count: 63, body: "When the right chair doesn't exist we draw it, then make it with workshops in Eindhoven and Bassano. Some of them are now for sale.", projects: ["Vos Stool, oak, 2025", "Sill vase, stoneware, 2024"], src: "/img/lux/still-vase.webp", alt: "A pale ceramic vase with olive branches beside a bowl of lemons" },50 { id: "tea", title: "Tea rooms & bars", tag: "Food and drink", count: 17, body: "Rooms where the light does half the work. Counters at the right height for leaning, acoustics you can talk over.", projects: ["Cha Ma, Antwerp, 2025", "Bar Teun, Utrecht, 2023"], src: "/img/lux/still-tea.webp", alt: "A dark teapot and small cups on a wooden plinth in warm light" },51 { id: "garden", title: "Gardens & pools", tag: "Landscape", count: 19, body: "Terraces, pools and planting designed with the house, not after it. We plant for the tenth summer, not the first photo shoot.", projects: ["Terrazza Limone, Praiano, 2024", "Duinhof, Wassenaar, 2022"], src: "/img/halcyon/pool-lemons.webp", alt: "An infinity pool framed by lemon trees above a blue bay" },52];53 54export function HoverIndexList({55 eyebrow = "Practice",56 title = <>What we<br />design</>,57 intro = "Haarmann Vos is a studio of 23 architects and makers in Rotterdam and Naples. Six things we do, in roughly the order we started doing them.",58 items = DEFAULT_ITEMS,59 className,60}: HoverIndexListProps) {61 const [hot, setHot] = useState<number | null>(null);62 const [open, setOpen] = useState<string | null>(null);63 const root = useRef<HTMLElement>(null);64 const float = useRef<HTMLDivElement>(null);65 const target = useRef({ x: 0, y: 0 });66 const state = useRef({ x: 0, y: 0, vx: 0, vy: 0 });67 const raf = useRef(0);68 const fine = useRef(false);69 const reduced = useRef(false);70 const uid = useId();71 72 useEffect(() => {73 fine.current = window.matchMedia("(hover: hover) and (pointer: fine)").matches;74 reduced.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;75 return () => cancelAnimationFrame(raf.current);76 }, []);77 78 const apply = () => {79 const s = state.current, el = float.current;80 if (!el) return;81 const tilt = Math.max(-12, Math.min(12, s.vx * 0.012));82 el.style.transform = `translate3d(${s.x}px, ${s.y}px, 0) rotate(${tilt}deg)`;83 };84 const run = () => {85 if (raf.current) return;86 let last = performance.now();87 const tick = (t: number) => {88 const dt = Math.min(0.032, (t - last) / 1000); last = t;89 const s = state.current, g = target.current;90 const ax = -380 * (s.x - g.x) - 30 * s.vx, ay = -380 * (s.y - g.y) - 30 * s.vy;91 s.vx += ax * dt; s.vy += ay * dt; s.x += s.vx * dt; s.y += s.vy * dt;92 apply();93 if (Math.abs(s.x - g.x) + Math.abs(s.y - g.y) < 0.3 && Math.abs(s.vx) + Math.abs(s.vy) < 2) { s.vx = s.vy = 0; s.x = g.x; s.y = g.y; apply(); raf.current = 0; return; }94 raf.current = requestAnimationFrame(tick);95 };96 raf.current = requestAnimationFrame(tick);97 };98 const moveTo = (x: number, y: number, snap: boolean) => {99 target.current = { x, y };100 if (snap || reduced.current) { state.current = { x, y, vx: 0, vy: 0 }; apply(); return; }101 run();102 };103 const rel = (cx: number, cy: number) => {104 const r = root.current!.getBoundingClientRect();105 return [cx - r.left, cy - r.top] as const;106 };107 108 return (109 <section110 ref={root}111 className={`fd-hil${className ? " " + className : ""}`}112 aria-labelledby={`${uid}-h`}113 data-hot={hot !== null ? "" : undefined}114 onPointerMove={(e) => {115 if (e.pointerType !== "mouse" || !fine.current) return;116 const [x, y] = rel(e.clientX, e.clientY);117 moveTo(x, y, hot === null);118 }}119 >120 <style>{css}</style>121 <div className="fd-hil-head">122 <p className="fd-hil-eyebrow"><span>{eyebrow}</span><span>({String(items.length).padStart(2, "0")})</span></p>123 <h2 id={`${uid}-h`} className="fd-hil-title">{title}</h2>124 <p className="fd-hil-intro">{intro}</p>125 </div>126 <ol className="fd-hil-list" onPointerLeave={() => setHot(null)}>127 {items.map((it, i) => {128 const isOpen = open === it.id;129 return (130 <li key={it.id} className="fd-hil-item" data-hot={hot === i ? "" : undefined} data-open={isOpen ? "" : undefined}>131 <button132 type="button"133 className="fd-hil-row"134 aria-expanded={isOpen}135 aria-controls={`${uid}-p-${i}`}136 onPointerEnter={(e) => { if (e.pointerType === "mouse" && fine.current) setHot(i); }}137 onFocus={(e) => {138 if (!fine.current || !e.currentTarget.matches(":focus-visible")) return;139 const r = e.currentTarget.getBoundingClientRect();140 const [x, y] = rel(r.right - 220, r.top + r.height / 2);141 moveTo(x, y, hot === null);142 setHot(i);143 }}144 onBlur={() => setHot(null)}145 onClick={() => setOpen(isOpen ? null : it.id)}146 >147 <span className="fd-hil-n">{String(i + 1).padStart(2, "0")}</span>148 <span className="fd-hil-t">{it.title}</span>149 <span className="fd-hil-tag">{it.tag}</span>150 <span className="fd-hil-c">{it.count}<small> projects</small></span>151 <span className="fd-hil-x" aria-hidden="true"><i /><i /></span>152 </button>153 <div className="fd-hil-panel" id={`${uid}-p-${i}`} role="region" aria-label={it.title}>154 <div className="fd-hil-panel-in">155 <img className="fd-hil-pimg" src={it.src} alt={it.alt} width={900} height={1125} loading="lazy" />156 <p className="fd-hil-body">{it.body}</p>157 <ul className="fd-hil-proj">158 {it.projects.map((p) => <li key={p}>{p}</li>)}159 </ul>160 </div>161 </div>162 </li>163 );164 })}165 </ol>166 <div className="fd-hil-float" ref={float} aria-hidden="true">167 <div className="fd-hil-frame">168 {items.map((it, i) => (169 <img key={it.id} src={it.src} alt="" width={900} height={1125} loading="lazy" data-on={hot === i ? "" : undefined} />170 ))}171 <span className="fd-hil-cap">{hot !== null ? `${String(hot + 1).padStart(2, "0")} / ${items[hot].projects[0]}` : ""}</span>172 </div>173 </div>174 </section>175 );176}177 178const css = `179:where(.fd-hil,.fd-hil *){box-sizing:border-box;margin:0;padding:0;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none;border:0;background:none}180:where(.fd-hil,.fd-hil *)::before,:where(.fd-hil,.fd-hil *)::after{box-sizing:border-box}181:where(.fd-hil img){display:block}182:where(.fd-hil button){appearance:none;-webkit-appearance:none;outline:0;border-radius:0;cursor:pointer;text-align:left}183.fd-hil{184 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);185 --fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;186 --fd-hil-paper:#E9E6DF;--fd-hil-ink:#121110;--fd-hil-mut:#6E6A62;--fd-hil-line:rgba(18,17,16,.22);--fd-hil-red:#C8321C;187 --fd-hil-serif:"Iowan Old Style","Palatino Linotype",Palatino,"Book Antiqua",Georgia,serif;188 --fd-hil-sans:ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;189 --fd-hil-mono:ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;190 position:relative;display:block;width:100%;overflow:hidden;background:var(--fd-hil-paper);color:var(--fd-hil-ink);191 padding:clamp(56px,8vw,120px) clamp(16px,4vw,56px);font:400 15px/1.5 var(--fd-hil-sans);-webkit-font-smoothing:antialiased}192.fd-hil-head{max-width:1328px;margin:0 auto clamp(40px,6vw,80px);display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:24px;align-items:end}193.fd-hil-eyebrow{grid-column:1 / span 2;align-self:start;display:flex;justify-content:space-between;font:500 12px/16px var(--fd-hil-mono);letter-spacing:.04em;text-transform:uppercase;padding-top:14px;border-top:1px solid var(--fd-hil-ink)}194.fd-hil-title{grid-column:3 / span 6;font:400 clamp(3rem,8vw,7.5rem)/.88 var(--fd-hil-serif);letter-spacing:-.045em}195.fd-hil-intro{grid-column:10 / span 3;font:400 15px/1.55 var(--fd-hil-sans);color:var(--fd-hil-mut)}196.fd-hil-list{max-width:1328px;margin:0 auto;border-top:1px solid var(--fd-hil-ink)}197.fd-hil-item{border-bottom:1px solid var(--fd-hil-line);transition:opacity var(--fd-dur-3) var(--fd-ease-out)}198.fd-hil[data-hot] .fd-hil-item:not([data-hot]):not([data-open]){opacity:.32}199.fd-hil-row{display:grid;grid-template-columns:minmax(0,2fr) minmax(0,6fr) minmax(0,2.4fr) minmax(0,1.2fr) 28px;gap:24px;align-items:center;width:100%;padding:22px 0 20px;color:var(--fd-hil-ink);cursor:pointer}200.fd-hil-row:focus-visible{outline:2px solid rgba(200,50,28,.7);outline-offset:2px}201.fd-hil-n{font:500 13px/1 var(--fd-hil-mono);color:var(--fd-hil-mut);transition:color var(--fd-dur-2)}202.fd-hil-t{font:400 clamp(2rem,4.6vw,4.25rem)/1 var(--fd-hil-serif);letter-spacing:-.035em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;transition:transform var(--fd-dur-3) var(--fd-ease-out),color var(--fd-dur-2)}203.fd-hil-tag{font:400 14px/1.35 var(--fd-hil-sans);color:var(--fd-hil-mut)}204.fd-hil-c{font:500 13px/1 var(--fd-hil-mono);text-align:right;font-variant-numeric:tabular-nums}205.fd-hil-c small{font-size:11px;color:var(--fd-hil-mut)}206.fd-hil-x{position:relative;width:16px;height:16px;justify-self:end}207.fd-hil-x i{position:absolute;left:0;top:7.5px;width:16px;height:1px;background:currentColor;transition:transform var(--fd-dur-3) var(--fd-ease-in-out)}208.fd-hil-x i:last-child{transform:rotate(90deg)}209.fd-hil-item[data-open] .fd-hil-x i:last-child{transform:rotate(0)}210.fd-hil-item[data-hot] .fd-hil-t,.fd-hil-item[data-open] .fd-hil-t{transform:translateX(28px)}211.fd-hil-item[data-hot] .fd-hil-t{font-style:italic}212.fd-hil-item[data-hot] .fd-hil-n,.fd-hil-item[data-open] .fd-hil-n{color:var(--fd-hil-red)}213.fd-hil-panel{display:grid;grid-template-rows:0fr;transition:grid-template-rows var(--fd-dur-3) var(--fd-ease-in-out)}214.fd-hil-item[data-open] .fd-hil-panel{grid-template-rows:1fr}215.fd-hil-panel-in{min-height:0;overflow:hidden;display:grid;grid-template-columns:minmax(0,2fr) minmax(0,6fr) minmax(0,3.6fr) 28px;gap:24px}216.fd-hil-pimg{display:none}217.fd-hil-body{grid-column:2;max-width:52ch;padding:0 0 28px 28px;font:400 17px/1.5 var(--fd-hil-sans);color:var(--fd-hil-ink)}218.fd-hil-proj{grid-column:3;padding-bottom:28px}219.fd-hil-proj li{font:400 13px/1.4 var(--fd-hil-mono);padding:6px 0;border-top:1px solid var(--fd-hil-line);color:var(--fd-hil-mut)}220.fd-hil-float{position:absolute;left:0;top:0;width:0;height:0;z-index:3;pointer-events:none;will-change:transform}221.fd-hil-frame{position:absolute;left:24px;top:-150px;width:240px;height:300px;transform:scale(.6);opacity:0;transition:transform var(--fd-dur-3) var(--fd-ease-out),opacity var(--fd-dur-2) var(--fd-ease-out);background:#cfcac0;box-shadow:0 30px 60px -30px rgba(18,17,16,.5)}222.fd-hil[data-hot] .fd-hil-frame{transform:none;opacity:1}223.fd-hil-frame img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;clip-path:inset(100% 0 0 0);transition:clip-path var(--fd-dur-3) var(--fd-ease-in-out)}224.fd-hil-frame img[data-on]{clip-path:inset(0 0 0 0);z-index:1}225.fd-hil-cap{position:absolute;left:0;top:100%;margin-top:8px;z-index:2;font:500 11px/14px var(--fd-hil-mono);letter-spacing:.02em;white-space:nowrap;color:var(--fd-hil-ink)}226@media (hover:none),(pointer:coarse),(max-width:760px){227 .fd-hil-float{display:none}228 .fd-hil-pimg{display:block;width:100%;height:auto;aspect-ratio:4/3;object-fit:cover;margin-bottom:16px}229}230@media (max-width:1000px){231 .fd-hil-head{grid-template-columns:1fr;gap:20px}232 .fd-hil-eyebrow,.fd-hil-title,.fd-hil-intro{grid-column:1}233 .fd-hil-eyebrow{max-width:200px}234 .fd-hil-intro{max-width:46ch}235 .fd-hil-row{grid-template-columns:44px minmax(0,1fr) auto 20px;gap:12px}236 .fd-hil-tag{display:none}237 .fd-hil-panel-in{grid-template-columns:44px minmax(0,1fr) 20px;gap:12px}238 .fd-hil-body,.fd-hil-proj{grid-column:2;padding-left:0}239 .fd-hil-body{padding-bottom:16px;font-size:16px}240 .fd-hil-item[data-hot] .fd-hil-t,.fd-hil-item[data-open] .fd-hil-t{transform:translateX(12px)}241}242@media (max-width:760px){243 .fd-hil-panel-in{display:block;padding:0 0 4px}244 .fd-hil-pimg{margin-top:4px}245 .fd-hil-proj{padding-bottom:24px}246 .fd-hil-t{font-size:2rem;white-space:normal}247 .fd-hil-c small{display:none}248 .fd-hil-row{padding:18px 0}249}250@media (prefers-reduced-motion:reduce){251 .fd-hil *{transition-duration:1ms!important}252}253`;254 255export function Demo() {256 return (257 <div className="fd-hild">258 <style>{`.fd-hild{background:#E9E6DF;min-height:100vh}.fd-hild-nav{box-sizing:border-box;max-width:1440px;margin:0 auto;padding:22px clamp(16px,4vw,56px);display:flex;justify-content:space-between;align-items:baseline;font:500 12px/16px ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;text-transform:uppercase;letter-spacing:.04em;color:#121110}.fd-hild-nav b{font:400 22px/1 "Iowan Old Style","Palatino Linotype",Palatino,Georgia,serif;letter-spacing:-.03em;text-transform:none}.fd-hild-nav span{color:#6E6A62}`}</style>259 <div className="fd-hild-nav"><b>Haarmann Vos</b><span>Rotterdam 51.91° N · Napoli 40.85° N</span></div>260 <HoverIndexList />261 </div>262 );263}More Feature Sections. Same file contract.
Questions about Hover Index List
Run npx shadcn@latest add https://freedesignmd.com/r/hover-index-list.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.
Editorial index list for an architecture studio: a photo trails the cursor on a spring and rows open in place. It is a free, MIT-licensed React feature sections 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. The trailing photo shows only with a fine pointer; touch screens get it inline when a row opens.
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.


