





Elastic Panels
A row of panels that expand on hover (or tap) to reveal an image, with two reveal variants and four spring presets.
A free, copy-paste hover effects component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.
Configuration
Tweak these live from theConfigurecontrol in the preview.
Interaction Type
- Hover a panel to expand it (tap on touch)
- The image reveals as the panel grows
Dependency
This component uses motion for its animation. Install it, then drop the file in.
npm install motionPoint the CLI here once
A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:
npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/elastic-panels.json — or copy the file from Source code below.
[ shadcn CLI 3.0 registry ]…or add the component in one command
Install via CLI
pnpm dlx shadcn add @harshdev-ui/elastic-panelsHow to use
Pass an array of panels — each with a name and an image — and the row lays them out as thin columns that grow when interacted with. On a hover-capable pointer you hover to expand; on touch you tap (and tap again to close in closed mode).
Pick a reveal variant and a motion preset to change the feel: cover keeps the image visible behind a wash that clears on open, reveal starts as clean text and fades the image in. The width animates with a duration-bounded spring so it settles cleanly with no creep.
Basic usage
import { ElasticPanels } from "@/components/harsh-ui/elastic-panels";
const panels = [
{ name: "Velvet Dreams Studio", image: "/panels/panel-1.jpg" },
{ name: "Neon Pulse Agency", image: "/panels/panel-2.jpg" },
{ name: "Midnight Canvas", image: "/panels/panel-3.jpg" },
];
const Demo = () => (
<div className="h-[70vh]">
<ElasticPanels panels={panels} />
</div>
);Reveal variant, closed by default
With defaultOpen={false} every panel starts collapsed and opens only on hover/click, closing again on leave. reveal makes the image fade and zoom in rather than sitting behind a wash.
<ElasticPanels
panels={panels}
variant="reveal"
effect="snappy"
speed="fast"
defaultOpen={false}
/>Motion presets
Four presets — spring, gentle, snappy, smooth — plus a speed multiplier. Pass a transition to override the effect entirely.
<ElasticPanels panels={panels} effect="gentle" speed="slow" />Demo
import { ElasticPanelsDemo } from "@/components/demos/elastic-panels-demo";
const Demo = () => (
<div className="h-screen w-full">
<ElasticPanelsDemo />
</div>
);Props
Notes
- Each panel is a flex child whose flexGrow animates between 1 and 9, so the open one takes most of the row while the rest stay as slim tabs.
- A useHoverCapable hook picks the interaction: hover-to-open on a fine pointer, tap-to-toggle on touch — and in closed mode leaving the row collapses everything.
- The motion presets are duration-bounded springs, so they settle within the given time and ease all the way to target with no early snap.
- Labels rotate to vertical on desktop and sit horizontally on mobile; a scrim keeps them legible over the image.
- In a grid card the demo auto-cycles which panel is open, since the card has no live pointer.
Source code
The complete elastic-panels.tsx — toggle TSX / JSX to copy it in either language.
"use client";
import { useEffect, useState } from "react";
import { motion, type Transition } from "motion/react";
/**
* Harsh Dev UI · Developed by Harsh Pandav
* https://harshpandav.dev · hello@harshpandav.dev
*
* ElasticPanels
* A row of vertical panels that expand on interaction to reveal an image.
* - Desktop (hover-capable pointer): hover a panel to expand it.
* - Mobile / touch: tap a panel to expand it (tap again to close in closed mode).
* - Panel width animates with a configurable spring/tween for a fluid feel.
*
* Variants (same expansion mechanics, different reveal):
* "cover" — every panel shows its image behind a dark wash that clears on open. (default)
* "reveal" — collapsed panels are clean text; the image fades + zooms in on open.
*
* Animation effects (the `effect` prop):
* "spring" (default) · "gentle" · "snappy" · "smooth"
*/
export type Panel = {
name: string;
image: string;
year?: string;
};
export type ElasticPanelsEffect = "spring" | "gentle" | "snappy" | "smooth";
export type ElasticPanelsVariant = "cover" | "reveal";
export type ElasticPanelsSpeed = "fast" | "normal" | "slow";
export type ElasticPanelsProps = {
panels: Panel[];
variant?: ElasticPanelsVariant;
effect?: ElasticPanelsEffect;
speed?: ElasticPanelsSpeed;
defaultOpen?: boolean;
defaultActive?: number;
rounded?: boolean;
year?: string;
transition?: Transition;
className?: string;
};
const EFFECTS: Record<ElasticPanelsEffect, Transition> = {
spring: { type: "spring", duration: 0.5, bounce: 0.12 },
gentle: { type: "spring", duration: 0.7, bounce: 0 },
snappy: { type: "spring", duration: 0.38, bounce: 0.22 },
smooth: { type: "tween", duration: 0.5, ease: [0.22, 1, 0.36, 1] },
};
const SPEEDS: Record<ElasticPanelsSpeed, number> = {
fast: 0.55,
normal: 1,
slow: 1.8,
};
type VariantConfig = {
panelBg: string;
imageOpacity: (isActive: boolean) => number;
imageScale: (isActive: boolean) => number;
washOpacity: (isActive: boolean) => number;
idleName: string;
};
const VARIANTS: Record<ElasticPanelsVariant, VariantConfig> = {
cover: {
panelBg: "bg-neutral-300 dark:bg-neutral-900",
imageOpacity: () => 1,
imageScale: () => 1,
washOpacity: (isActive) => (isActive ? 0.15 : 0.72),
idleName: "text-white/55",
},
reveal: {
panelBg: "bg-neutral-100 dark:bg-neutral-950",
imageOpacity: (isActive) => (isActive ? 1 : 0),
imageScale: (isActive) => (isActive ? 1 : 1.12),
washOpacity: (isActive) => (isActive ? 0.3 : 0),
idleName: "text-neutral-600 dark:text-white/55",
},
};
export function ElasticPanels({
panels,
variant = "cover",
effect = "spring",
speed = "normal",
defaultOpen = true,
defaultActive = 0,
rounded = false,
year = "2024",
transition,
className = "",
}: ElasticPanelsProps) {
const resting = defaultOpen ? defaultActive : null;
const [active, setActive] = useState<number | null>(resting);
const hoverCapable = useHoverCapable();
const v = VARIANTS[variant] || VARIANTS.cover;
const isCover = variant === "cover";
const base = transition || EFFECTS[effect] || EFFECTS.spring;
const scale = SPEEDS[speed] ?? 1;
const t: Transition =
"duration" in base && typeof base.duration === "number"
? { ...base, duration: base.duration * scale }
: base;
useEffect(() => {
setActive(defaultOpen ? defaultActive : null);
}, [defaultOpen, defaultActive]);
const handlers = (i: number) =>
hoverCapable
? { onMouseEnter: () => setActive(i) }
: {
onClick: () =>
setActive((prev) => (prev === i && !defaultOpen ? null : i)),
};
const handleRowLeave = () => {
if (hoverCapable && !defaultOpen) setActive(null);
};
return (
<div
onMouseLeave={handleRowLeave}
className={`flex h-full w-full flex-col overflow-hidden rounded-[inherit] sm:flex-row ${className}`}
>
{panels.map((panel, i) => {
const isActive = i === active;
return (
<motion.div
key={panel.name}
{...handlers(i)}
animate={{ flexGrow: isActive ? 20 : 1 }}
transition={t}
style={{ flexBasis: "2.75rem" }}
className={`relative min-w-0 cursor-pointer overflow-hidden border-t border-black/10 first:border-t-0 dark:border-white/10 sm:border-l sm:border-t-0 sm:first:border-l-0 ${v.panelBg}`}
role="button"
aria-expanded={isActive}
aria-label={panel.name}
>
<div
className={`absolute overflow-hidden transition-[inset,border-radius] duration-300 ease-out ${
rounded
? "inset-2 rounded-xl sm:inset-2.5 sm:rounded-2xl"
: "inset-0"
}`}
>
<motion.img
src={panel.image}
alt={panel.name}
draggable={false}
loading="eager"
decoding="async"
className="absolute inset-0 h-full w-full select-none object-cover"
style={{ willChange: isCover ? "opacity" : "transform, opacity" }}
animate={
isCover
? { opacity: v.imageOpacity(isActive) }
: {
opacity: v.imageOpacity(isActive),
scale: v.imageScale(isActive),
}
}
transition={t}
/>
<motion.div
className="absolute inset-0 bg-black"
animate={{ opacity: v.washOpacity(isActive) }}
transition={t}
/>
<motion.div
aria-hidden
className="pointer-events-none absolute inset-0"
initial={false}
animate={{ opacity: isCover ? 1 : 0 }}
transition={{ duration: 0.3, ease: [0.22, 1, 0.36, 1] }}
>
<div className="absolute inset-0 bg-linear-to-t from-black/55 via-transparent to-black/20 sm:hidden" />
<div className="absolute inset-0 hidden bg-linear-to-r from-black/50 via-black/10 to-transparent sm:block" />
</motion.div>
</div>
<motion.span
className="pointer-events-none absolute left-5 top-4 text-base font-medium tracking-tight text-white/85 sm:left-4 sm:top-5 sm:rotate-180 sm:[writing-mode:vertical-rl]"
animate={{ opacity: isActive ? 1 : 0 }}
transition={{ duration: 0.3 }}
>
{panel.year || year}
</motion.span>
<span
className={`pointer-events-none absolute bottom-4 left-5 whitespace-nowrap text-xl tracking-tight transition-colors duration-300 sm:bottom-6 sm:left-4 sm:rotate-180 sm:text-2xl sm:[writing-mode:vertical-rl] ${
isActive
? "font-semibold text-white"
: `font-medium ${v.idleName}`
}`}
>
{panel.name}
</span>
</motion.div>
);
})}
</div>
);
}
function useHoverCapable() {
const [hoverCapable, setHoverCapable] = useState(true);
useEffect(() => {
const mq = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setHoverCapable(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
return hoverCapable;
}
Keep in mind
Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.
Contact
Found a bug or an issue? Drop a mail — hello@harshpandav.dev
License & Usage
- Free to use and modify in personal and commercial projects.
- Attribution to Harsh Dev UI is appreciated but not required.
- MIT licensed.