Hover Expand
A vertical list of rows that expand on hover to reveal a full-width photograph, animated with Motion spring physics.
Installation
File Structure
Usage
import { HoverExpand } from "@/components/unlumen-ui/hover-expand";
const items = [
{
label: "Kyoto",
sublabel: "Japan",
description: "Ancient temples hidden among bamboo groves",
image: "/images/kyoto.jpg",
},
];
<HoverExpand items={items} />;API Reference
HoverExpand
itemsHoverExpandItem[]—Array of items to display.
collapsedHeight?number68Row height in pixels when not hovered.
expandedHeight?number320Row height in pixels when hovered.
className?string—Additional CSS classes on the container.
HoverExpandItem
labelstring—Primary row label.
imagestring—Image URL revealed on hover.
sublabel?string—Secondary metadata shown beside the label (country, year, category, etc.).
description?string—Short descriptor that slides in from the left when the row is expanded.
imageAlt?string""Alt text for the image.
Notes
- Row height animates with a Motion spring (
stiffness: 280,damping: 32,mass: 0.9) — the expand and collapse feel physical, not timed. - The background image animates from
scale: 1.06toscale: 1on expand, giving a subtle zoom-in reveal. On collapse the reverse plays. - Non-hovered items fade to
opacity: 0.38, keeping visual focus on the active row. descriptionslides in fromx: -8tox: 0with a0.12sdelay after expand starts — it appears only after the row has opened enough to be readable.- A gradient overlay (
from-black/70 via-black/20 to-black/10) is baked into the component to keep white labels legible over any image. - Use landscape images (16:9 or wider) for best results at the default
expandedHeight. Square or portrait images will be cropped byobject-cover. - The component works best with 4–8 rows. More rows will still function but the list may exceed viewport height when fully collapsed.
- The file is marked
"use client"and relies ononHoverStart/onHoverEnd— not suitable for server rendering without a client boundary.
Credits
Inspired by the horizontal accordion hover popularized on editorial design studio portfolios — hovering a thin column expands it sideways to reveal a photograph. This component rotates the axis to vertical, making it a better fit for standard document flow.
Original horizontal pattern notably seen on Skiper UI and various portfolio galleries.
Keep in mind
Most components on this site are inspired by or recreated from existing work across the web. I'm not here to take credit; just to learn, experiment, and sometimes push things a bit further. If something looks familiar and I forgot to mention you, reach out and I'll fix that right away.
"use client";
import * as React from "react";
import { motion } from "motion/react";
import { cn } from "@/lib/utils";
export interface HoverExpandItem {
label: string;
/** e.g. country, year, category */
sublabel?: string;
image: string;
imageAlt?: string;
/** short descriptor shown when expanded */
description?: string;
}
export interface HoverExpandProps {
items: HoverExpandItem[];
/**
* Row height when collapsed, in pixels.
* @default 68
*/
collapsedHeight?: number;
/**
* Row height when expanded, in pixels.
* @default 320
*/
expandedHeight?: number;
className?: string;
}
export function HoverExpand({
items,
collapsedHeight = 68,
expandedHeight = 320,
className,
}: HoverExpandProps) {
const [hoveredIndex, setHoveredIndex] = React.useState<number | null>(null);
return (
<div className={cn("flex flex-col w-full", className)}>
<div className="w-full border-t border-current opacity-15" />
{items.map((item, i) => {
const isHovered = hoveredIndex === i;
const isOtherHovered = hoveredIndex !== null && !isHovered;
return (
<React.Fragment key={i}>
<motion.div
className="relative w-full overflow-hidden cursor-default"
animate={{
height: isHovered ? expandedHeight : collapsedHeight,
opacity: isOtherHovered ? 0.38 : 1,
}}
transition={{
height: {
type: "spring",
stiffness: 280,
damping: 32,
mass: 0.9,
},
opacity: { duration: 0.22, ease: "easeOut" },
}}
onHoverStart={() => setHoveredIndex(i)}
onHoverEnd={() => setHoveredIndex(null)}
>
<motion.div
className="absolute inset-0 w-full h-full"
initial={false}
animate={{
opacity: isHovered ? 1 : 0,
scale: isHovered ? 1 : 1.06,
}}
transition={{
opacity: { duration: 0.45, ease: [0.23, 1, 0.32, 1] },
scale: { duration: 0.55, ease: [0.23, 1, 0.32, 1] },
}}
>
<img
src={item.image}
alt={item.imageAlt ?? ""}
className="w-full h-full object-cover"
loading="lazy"
decoding="async"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-black/10" />
</motion.div>
<div className="absolute inset-0 flex items-end px-5 pb-4">
<div className="flex w-full items-end justify-between gap-4">
<div className="flex items-baseline gap-3 min-w-0">
<motion.span
className="text-xs tabular-nums shrink-0 opacity-40"
animate={{
color: isHovered ? "#ffffff" : "currentColor",
opacity: isHovered ? 0.5 : 0.4,
}}
transition={{ duration: 0.2 }}
>
{String(i + 1).padStart(2, "0")}
</motion.span>
<motion.span
className="font-semibold tracking-tight truncate"
style={{ fontSize: "clamp(1.1rem, 2.2vw, 1.5rem)" }}
animate={{
color: isHovered ? "#ffffff" : "currentColor",
}}
transition={{ duration: 0.2 }}
>
{item.label}
</motion.span>
{item.description && (
<motion.span
className="text-sm text-white/70 truncate hidden sm:block"
initial={{ opacity: 0, x: -8 }}
animate={{
opacity: isHovered ? 1 : 0,
x: isHovered ? 0 : -8,
}}
transition={{
duration: 0.3,
delay: isHovered ? 0.12 : 0,
ease: [0.23, 1, 0.32, 1],
}}
>
— {item.description}
</motion.span>
)}
</div>
{item.sublabel && (
<motion.span
className="text-xs tracking-widest uppercase shrink-0"
animate={{
color: isHovered
? "rgba(255,255,255,0.55)"
: "currentColor",
opacity: isHovered ? 1 : 0.45,
}}
transition={{ duration: 0.2 }}
>
{item.sublabel}
</motion.span>
)}
</div>
</div>
</motion.div>
<div className="w-full border-t border-current opacity-15" />
</React.Fragment>
);
})}
</div>
);
}