Velvet Dreams Studio
2024Velvet Dreams Studio

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.

variantcover · reveal — default "cover".
effectspring · gentle · snappy · smooth — default "spring".
speedfast · normal · slow — default "normal".
defaultOpenon / off — default on.
roundedon / off — default off.

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 motion

Point 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-panels

How 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

panelsArray<{ name, image, year? }> — the panels. Each needs a name and an image URL. Required.
variant"cover" | "reveal" — cover = image always visible behind a wash; reveal = text-first, image fades/zooms in on open. Default "cover".
effect"spring" | "gentle" | "snappy" | "smooth" — the expansion animation preset. Default "spring".
speed"fast" | "normal" | "slow" — scales the animation duration (fast ≈ 0.55×, slow ≈ 1.8×). Default "normal".
defaultOpenOpen a panel by default. When false, all panels start closed and open only on hover/click, closing again on leave. Default true.
defaultActiveIndex open on first render when defaultOpen is true. Default 0.
roundedRound the image into an inset card. Default false — the image bleeds to the panel edges.
year / transition / classNameyear labels the open panel; transition is a Motion override that wins over effect; className adds classes to the outer row.

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.

Source code
"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.

Command Palette

Search for a command to run...