Skip to content

Expanding Panel Row

A row of art panels where the hovered, focused or tapped one springs open while its neighbours compress. Each title runs up the panel edge and swings to horizontal as it opens, with a pointer-lit spot, a depth shift in the art and arrow-key navigation. Photos optional, no dependencies.

DOM + CSS0 deps1 file · 11 KB TSX

Freeeditorialminimalglass
Preview

Props

4
Show labels
Vertical
2
No runtime dependencies
"use client";

import { useState } from "react";
import type { CSSProperties, KeyboardEvent, PointerEvent } from "react";

export type HoverExpandPanel = {
  id: string;
  label: string;
  /** Optional photo. Without one the panel paints its own CSS art. */
  image?: string;
  /** One line that resolves under the title once the panel is open. */
  caption?: string;
  /** Optional CSS background value to use instead of the built-in art. */
  art?: string;
};

const ART = [
  "radial-gradient(120% 75% at 12% 0%, #5b8cff 0%, transparent 58%), radial-gradient(90% 80% at 95% 100%, #b04bff 0%, transparent 60%), conic-gradient(from 200deg at 62% 38%, #070a1c, #1a2a73, #070a1c)",
  "radial-gradient(85% 60% at 50% 8%, #ffd9a8 0%, #ff7a45 28%, transparent 66%), linear-gradient(180deg, #2a0f1f 0%, #0c0710 100%)",
  "radial-gradient(100% 70% at 80% 12%, #4be3c4 0%, transparent 55%), radial-gradient(90% 70% at 10% 95%, #1673ff 0%, transparent 60%), linear-gradient(160deg, #06151a, #05070c)",
  "conic-gradient(from 20deg at 30% 70%, #1b1b22, #7a7f92 22%, #1b1b22 48%, #c8ccd8 70%, #1b1b22), linear-gradient(0deg, #0a0b0e, #0a0b0e)",
  "radial-gradient(70% 55% at 30% 20%, #ff8fc3 0%, transparent 62%), radial-gradient(80% 70% at 85% 85%, #ffb347 0%, transparent 58%), linear-gradient(200deg, #3a1054, #12081f)",
  "radial-gradient(90% 60% at 70% 0%, #c4f26b 0%, transparent 56%), radial-gradient(80% 70% at 5% 100%, #0f9d6c 0%, transparent 62%), linear-gradient(170deg, #0a1a10, #050a07)",
];

const DEFAULT_PANELS: HoverExpandPanel[] = [
  { id: "01", label: "Aurora", caption: "Cold light over slow water" },
  { id: "02", label: "Ember", caption: "A low sun, held a moment" },
  { id: "03", label: "Tide", caption: "Teal edges, deep current" },
  { id: "04", label: "Chrome", caption: "Brushed metal, one sweep" },
  { id: "05", label: "Bloom", caption: "Soft petals of heat" },
];

const EASE = "cubic-bezier(0.22,1.25,0.36,1)";
const DUR = "650ms";

export function HoverExpandPanels({
  panels = DEFAULT_PANELS,
  expandRatio = 4,
  showLabels = true,
  vertical = false,
  restIndex = null,
  className = "",
}: {
  panels?: HoverExpandPanel[];
  expandRatio?: number;
  /** The title that rotates from the panel edge to horizontal as it opens. */
  showLabels?: boolean;
  /** Stack the panels instead of laying them out in a row. */
  vertical?: boolean;
  /** Zero-based panel that stays open while nothing is hovered or focused. */
  restIndex?: number | null;
  /** Extra classes for the row. The default height is h-64; override with e.g. !h-96. */
  className?: string;
}) {
  const [active, setActive] = useState<number | null>(null);
  const open = active ?? restIndex;
  const ratio = Math.min(Math.max(expandRatio, 1), 12);

  const move = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    const fwd = e.key === "ArrowRight" || e.key === "ArrowDown";
    const back = e.key === "ArrowLeft" || e.key === "ArrowUp";
    let to = -1;
    if (fwd) to = Math.min(i + 1, panels.length - 1);
    else if (back) to = Math.max(i - 1, 0);
    else if (e.key === "Home") to = 0;
    else if (e.key === "End") to = panels.length - 1;
    if (to < 0) return;
    e.preventDefault();
    const sib = e.currentTarget.parentElement?.children[to];
    if (sib instanceof HTMLElement) sib.focus();
  };

  const track = (e: PointerEvent<HTMLButtonElement>) => {
    if (e.pointerType === "touch") return;
    const el = e.currentTarget;
    const r = el.getBoundingClientRect();
    if (!r.width || !r.height) return;
    // Layout pixels: ancestors may be transform-scaled.
    const kx = el.offsetWidth / r.width;
    const ky = el.offsetHeight / r.height;
    const x = (e.clientX - r.left) * kx;
    const y = (e.clientY - r.top) * ky;
    el.style.setProperty("--hx", x.toFixed(1));
    el.style.setProperty("--hy", y.toFixed(1));
    el.style.setProperty("--nx", ((x / el.offsetWidth) * 2 - 1).toFixed(3));
    el.style.setProperty("--ny", ((y / el.offsetHeight) * 2 - 1).toFixed(3));
  };

  return (
    <div
      role="group"
      aria-label="Expanding panels"
      onPointerLeave={(e) => {
        if (e.pointerType !== "touch") setActive(null);
      }}
      className={
        "flex h-64 w-full gap-1.5 " + (vertical ? "flex-col " : "flex-row ") + className
      }
    >
      {panels.map((panel, i) => {
        const isOpen = open === i;
        const dim = open !== null && !isOpen;
        const art = panel.art ?? ART[i % ART.length] ?? "";
        const titleTransform = vertical
          ? isOpen
            ? "translate(4px, 0px) scale(1.45)"
            : "translate(0px, 0px) scale(1)"
          : isOpen
            ? "translate(4px, -38px) rotate(0deg) scale(1.45)"
            : "translate(1em, 0px) rotate(-90deg) scale(1)";
        const titleStyle: CSSProperties = {
          left: vertical ? (isOpen ? 20 : 44) : 17,
          bottom: vertical ? (isOpen ? 48 : "calc(50% - 0.5em)") : 18,
          transform: titleTransform,
          transformOrigin: "0 100%",
          transition: `transform ${DUR} ${EASE}, bottom ${DUR} ${EASE}, left ${DUR} ${EASE}, opacity 300ms ease`,
          opacity: showLabels ? 1 : 0,
          textShadow: "0 1px 12px rgba(0,0,0,0.45)",
        };
        return (
          <button
            key={panel.id}
            type="button"
            aria-label={panel.label}
            aria-expanded={isOpen}
            onPointerEnter={(e) => {
              if (e.pointerType === "touch") return;
              setActive(i);
              e.currentTarget.style.setProperty("--lit", "1");
              track(e);
            }}
            onPointerMove={track}
            onPointerLeave={(e) => {
              e.currentTarget.style.setProperty("--lit", "0");
              e.currentTarget.style.setProperty("--nx", "0");
              e.currentTarget.style.setProperty("--ny", "0");
            }}
            onClick={() => setActive(i)}
            onFocus={() => setActive(i)}
            onBlur={() => setActive(null)}
            onKeyDown={(e) => move(e, i)}
            className="group relative min-h-0 min-w-0 cursor-pointer overflow-hidden rounded-xl border-0 bg-[#0b0c10] p-0 text-left text-white outline-none transition-[flex-grow] duration-[650ms] ease-[cubic-bezier(0.22,1.25,0.36,1)] focus-visible:!outline-2 focus-visible:!-outline-offset-4 focus-visible:!outline-white motion-reduce:transition-none"
            style={{
              flexGrow: isOpen ? ratio : 1,
              flexBasis: 0,
              boxShadow: isOpen
                ? "inset 0 0 0 1px rgba(255,255,255,0.2), 0 18px 40px -18px rgba(0,0,0,0.6)"
                : "inset 0 0 0 1px rgba(255,255,255,0.08)",
              ["--hx" as string]: "0",
              ["--hy" as string]: "0",
              ["--nx" as string]: "0",
              ["--ny" as string]: "0",
              ["--lit" as string]: "0",
            }}
          >
            {/* Art layer: oversized so the pointer parallax never shows an edge. */}
            <span
              aria-hidden
              className="absolute -inset-[7%] block motion-reduce:!transform-none motion-reduce:!transition-none"
              style={{
                background: art,
                transform:
                  "translate3d(calc(var(--nx) * -9px), calc(var(--ny) * -9px), 0) scale(" +
                  (isOpen ? 1.04 : 1.12) +
                  ")",
                transition: `transform 520ms cubic-bezier(0.22,1,0.36,1)`,
              }}
            >
              {panel.image ? (
                // eslint-disable-next-line @next/next/no-img-element
                <img src={panel.image} alt="" loading="lazy" className="absolute inset-0 h-full w-full object-cover" />
              ) : null}
            </span>

            {/* Pointer-lit spot, only on the open panel. */}
            <span
              aria-hidden
              className="pointer-events-none absolute left-0 top-0 block h-[260px] w-[260px] motion-reduce:hidden"
              style={{
                transform: "translate3d(calc(var(--hx) * 1px - 130px), calc(var(--hy) * 1px - 130px), 0)",
                background: "radial-gradient(circle, rgba(255,255,255,0.26), rgba(255,255,255,0) 64%)",
                mixBlendMode: "soft-light",
                opacity: isOpen ? "var(--lit)" : 0,
                transition: "opacity 300ms ease",
              }}
            />

            {/* Legibility scrim, deeper when open. */}
            <span
              aria-hidden
              className="absolute inset-0 block"
              style={{
                background:
                  "linear-gradient(0deg, rgba(0,0,0,0.62) 0%, rgba(0,0,0,0.12) 46%, rgba(0,0,0,0.28) 100%)",
                opacity: isOpen ? 1 : 0.8,
                transition: "opacity 400ms ease",
              }}
            />

            {/* Neighbours settle back a touch so the open panel carries the light. */}
            <span
              aria-hidden
              className="absolute inset-0 block bg-black"
              style={{ opacity: dim ? 0.34 : 0, transition: "opacity 400ms ease" }}
            />

            <span className="absolute left-4 top-4 font-mono text-[10px] tracking-[0.14em] text-white/75" style={{ fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace" }}>
              {panel.id}
            </span>

            <span
              aria-hidden
              className="absolute right-3 top-3 flex h-7 w-7 items-center justify-center rounded-full motion-reduce:!transition-none"
              style={{
                background: "rgba(255,255,255,0.16)",
                boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.22)",
                opacity: isOpen ? 1 : 0,
                transform: isOpen ? "scale(1)" : "scale(0.6)",
                transition: `opacity 260ms ease ${isOpen ? "200ms" : "0ms"}, transform 420ms ${EASE} ${isOpen ? "200ms" : "0ms"}`,
              }}
            >
              <svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="#fff" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
                <path d="M3 9 9 3M4.5 3H9v4.5" />
              </svg>
            </span>

            <span
              aria-hidden
              className="pointer-events-none absolute whitespace-nowrap text-base font-semibold leading-none tracking-tight motion-reduce:!transition-none"
              style={titleStyle}
            >
              {panel.label}
            </span>

            {showLabels && panel.caption ? (
              <span
                aria-hidden
                className="pointer-events-none absolute left-5 right-4 bottom-4 block text-xs leading-snug text-white/70 motion-reduce:!transition-none"
                style={{
                  opacity: isOpen ? 1 : 0,
                  transform: isOpen ? "translateY(0)" : "translateY(6px)",
                  transition: isOpen
                    ? "opacity 300ms ease 220ms, transform 500ms cubic-bezier(0.22,1,0.36,1) 220ms"
                    : "opacity 120ms ease, transform 120ms ease",
                  display: vertical && !isOpen ? "none" : undefined,
                }}
              >
                {panel.caption}
              </span>
            ) : null}
          </button>
        );
      })}
    </div>
  );
}

Free. Pro unlocks everything, from $27/month.

Details

About this pattern

The horizontal accordion is a familiar gallery shape, and the usual implementation measures the container and animates pixel widths, which needs a resize observer and drifts by a pixel or two. This one drives everything with flex-grow: the open panel grows to expandRatio, the rest stay at 1, flex-basis is 0 and every panel has min-w-0, so the browser guarantees the row sums to its container at every frame, whatever the panel count or width. The grow transition is 650ms with cubic-bezier(0.22, 1.25, 0.36, 1), which overshoots by a few percent and settles like a spring. Each title is one element that moves between two poses with the same easing: collapsed, it runs up the left edge rotated -90 degrees; open, it swings to horizontal, scales to 1.45 and lifts to make room for a caption that arrives 220ms later, into space that already exists. The art layer is oversized by 7% and shifts up to 9px against the pointer, a soft-light spot follows the pointer on the open panel, and neighbours dim by about a third. Panels without an image paint their own CSS gradient art (six recipes cycle); pass image for photos or art for your own CSS background. Hover, focus, click and tap all open a panel; leaving the row closes it, or returns to restIndex if you set one. Set vertical for a stacked version, where titles stay horizontal. Size the row with className (the default is h-64; use !h-96 to override).

Curator’s note

Rebuilt in October: the row still runs on flex-grow, but the panels now spring open, the titles turn from vertical to horizontal as they gain room, and the art answers the pointer.

Pairs well with

Pro