# Expanding Panels (`expanding-panels`)

> An editorial menu of numbered columns — hover one and it blooms into a full-height accent panel with its content, while the others compress into slim rails with vertical titles.

- **Docs:** https://www.mellowui.com/components/expanding-panels
- **Markdown:** https://www.mellowui.com/components/expanding-panels.md
- **Registry:** https://www.mellowui.com/r/expanding-panels.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/expanding-panels
- **Categories:** interactive, display, animation
- **Dependencies:** motion

## AI prompt

Add an ExpandingPanels component from the mellow library — an editorial menu of numbered columns. Hover (or focus) one and it blooms into a full-height accent panel with its content, while the others compress into slim rails with vertical titles. Pass `items` with title, optional subtitle, accent, content and cta. Tune `height`, `grow`, `stiffness` and `damping`. `defaultActive` opens a panel on mount; null keeps them equal. Reduced-motion swaps the layout instantly.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/expanding-panels.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `items` | `ExpandingPanelItem[]` | — | Columns — title, optional subtitle, accent, content, cta. |
| `defaultActive` | `number \| null` | `null` | Panel open on mount; null = all columns rest equal. |
| `height` | `number` | `420` | Row height in px. |
| `grow` | `number` | `3.4` | Flex-grow of the open panel relative to the rails. |
| `stiffness` | `number` | `220` | Bloom spring stiffness. |
| `damping` | `number` | `30` | Bloom spring damping. |
| `onActiveChange` | `(index: number \| null) => void` | — | Called when the open panel changes. |
| `onCta` | `(index: number) => void` | — | Called when a panel CTA is pressed. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

export interface ExpandingPanelItem {
  title: string;
  subtitle?: string;
  /** Numeral label; defaults to 01, 02… */
  numeral?: string;
  /** Numeral color, and the panel wash when open. Any CSS color. */
  accent?: string;
  /** Text color on top of the accent wash. */
  foreground?: string;
  /** Rich content revealed when the panel opens. */
  content?: React.ReactNode;
  /** CTA pill label. */
  cta?: string;
}

export interface ExpandingPanelsProps {
  items: ExpandingPanelItem[];
  /** Panel open on mount; null = all columns rest equal. */
  defaultActive?: number | null;
  height?: number;
  /** Flex-grow of the open panel relative to the rails. */
  grow?: number;
  /** Bloom spring stiffness. */
  stiffness?: number;
  /** Bloom spring damping. */
  damping?: number;
  onActiveChange?: (index: number | null) => void;
  onCta?: (index: number) => void;
  className?: string;
  style?: React.CSSProperties;
}

/**
 * An editorial menu of numbered columns — hover (or focus) one and it blooms
 * into a full-height accent panel with its content, while the others compress
 * into slim rails with vertical titles.
 */
export function ExpandingPanels({
  items,
  defaultActive = null,
  height = 420,
  grow = 3.4,
  stiffness = 220,
  damping = 30,
  onActiveChange,
  onCta,
  className,
  style,
}: ExpandingPanelsProps) {
  const [active, setActive] = useState<number | null>(defaultActive);
  const reduced = useReducedMotion();

  const set = (i: number | null) => {
    setActive(i);
    onActiveChange?.(i);
  };

  const spring = reduced
    ? ({ duration: 0 } as const)
    : ({ type: "spring", stiffness, damping } as const);

  // Hover (info → rail): kill horizontal instantly so it never doubles with
  // vertical. Leave (rail → info): fade vertical out, then ease horizontal in
  // once the columns have started springing wide again.
  const infoEnter = reduced
    ? { duration: 0 }
    : { duration: 0.4, delay: 0.12, ease: [0.22, 1, 0.36, 1] as const };
  const railEnter = reduced
    ? { duration: 0 }
    : { duration: 0.3, delay: 0.14, ease: [0.22, 1, 0.36, 1] as const };
  const infoExit = { duration: 0 };
  const railExit = reduced
    ? { duration: 0 }
    : { duration: 0.2, ease: [0.4, 0, 1, 1] as const };
  const wash = reduced
    ? { duration: 0 }
    : { duration: 0.3, ease: [0.22, 1, 0.36, 1] as const };
  const contentFade = reduced
    ? { duration: 0 }
    : { duration: 0.28, delay: 0.08, ease: [0.22, 1, 0.36, 1] as const };

  return (
    <div
      className={[
        "flex w-full overflow-hidden rounded-2xl border border-[var(--rule)] bg-[var(--background)]",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      style={{ height, ...style }}
      onMouseLeave={() => set(defaultActive)}
    >
      {items.map((item, i) => {
        const isOpen = active === i;
        const mode = active === null ? "rest" : isOpen ? "open" : "rail";
        const accent = item.accent ?? "rgba(var(--ink-rgb),0.5)";
        const fg = item.foreground ?? "#fff";
        const numeral = item.numeral ?? String(i + 1).padStart(2, "0");
        const showRail = mode === "rail";

        return (
          <motion.div
            key={i}
            role="button"
            tabIndex={0}
            aria-expanded={isOpen}
            aria-label={`${numeral} — ${item.title}`}
            animate={{ flexGrow: isOpen ? grow : 1 }}
            transition={spring}
            onMouseEnter={() => set(i)}
            onFocus={() => set(i)}
            onClick={() => set(i)}
            onKeyDown={(e) => {
              if (e.key === "Enter" || e.key === " ") {
                e.preventDefault();
                set(isOpen ? null : i);
              }
            }}
            className="relative min-w-0 basis-0 cursor-pointer overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[rgba(var(--ink-rgb),0.3)]"
          >
            <motion.div
              aria-hidden="true"
              initial={false}
              animate={{ opacity: mode === "open" ? 1 : 0 }}
              transition={wash}
              className="absolute inset-0"
              style={{ background: accent }}
            />

            <AnimatePresence mode="wait" initial={false}>
              {showRail ? (
                <motion.div
                  key="rail"
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0, transition: railExit }}
                  transition={railEnter}
                  className="absolute inset-0 flex flex-col items-center justify-between px-2 py-5"
                >
                  <span
                    className="[font-family:var(--font-mono)] text-sm font-medium tabular-nums"
                    style={{ color: accent }}
                  >
                    {numeral}
                  </span>
                  <span className="rotate-180 [font-family:var(--font-sans)] text-sm font-medium tracking-[-0.01em] whitespace-nowrap text-[rgba(var(--ink-rgb),0.55)] [writing-mode:vertical-rl]">
                    {item.title}
                  </span>
                </motion.div>
              ) : (
                <motion.div
                  key="info"
                  initial={{ opacity: 0 }}
                  animate={{ opacity: 1 }}
                  exit={{ opacity: 0, transition: infoExit }}
                  transition={infoEnter}
                  className="relative flex h-full min-w-0 flex-col p-6"
                >
                  <span
                    className="[font-family:var(--font-mono)] text-2xl font-medium tabular-nums transition-colors duration-300"
                    style={{ color: mode === "open" ? fg : accent }}
                  >
                    {numeral}
                  </span>

                  <motion.div
                    initial={false}
                    animate={{ opacity: mode === "open" ? 1 : 0 }}
                    transition={contentFade}
                    className={
                      "min-h-0 flex-1" +
                      (mode === "open" ? "" : " pointer-events-none")
                    }
                  >
                    {item.content}
                  </motion.div>

                  <div className="mt-auto min-w-0 pt-4">
                    <div
                      className="[font-family:var(--font-sans)] text-lg font-medium tracking-[-0.02em] whitespace-nowrap transition-colors duration-300"
                      style={{ color: mode === "open" ? fg : "var(--ink)" }}
                    >
                      {item.title}
                    </div>
                    {item.subtitle && (
                      <div
                        className="mt-0.5 [font-family:var(--font-sans)] text-sm whitespace-nowrap transition-colors duration-300"
                        style={{
                          color:
                            mode === "open"
                              ? `color-mix(in oklab, ${fg} 65%, transparent)`
                              : "rgba(var(--ink-rgb),0.5)",
                        }}
                      >
                        {item.subtitle}
                      </div>
                    )}
                    {item.cta && (
                      <button
                        type="button"
                        tabIndex={0}
                        onClick={(e) => {
                          e.stopPropagation();
                          onCta?.(i);
                        }}
                        className="mt-4 inline-flex cursor-pointer items-center gap-1.5 rounded-full border px-3.5 py-1.5 [font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.12em] uppercase transition-opacity duration-300 hover:opacity-70"
                        style={{
                          color: mode === "open" ? fg : "var(--ink)",
                          borderColor:
                            mode === "open"
                              ? `color-mix(in oklab, ${fg} 45%, transparent)`
                              : "var(--rule)",
                        }}
                      >
                        {item.cta}
                        <span aria-hidden="true">→</span>
                      </button>
                    )}
                  </div>
                </motion.div>
              )}
            </AnimatePresence>
          </motion.div>
        );
      })}
    </div>
  );
}

export default ExpandingPanels;

```

## Demo

```tsx
"use client";

import React, { useEffect, useState } from "react";
import { ExpandingPanels } from "../mellow/expanding-panels";

function Plate({ word, note }: { word: string; note: string }) {
  return (
    <div className="flex h-full flex-col items-start justify-center gap-1 pt-4">
      <span className="[font-family:var(--font-serif)] text-5xl leading-none text-white/90 italic">
        {word}
      </span>
      <span className="[font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] text-white/50 uppercase">
        {note}
      </span>
    </div>
  );
}

export const EXPANDING_PANELS_ITEMS = [
  {
    title: "Green Smoothie",
    subtitle: "Guava flavour",
    accent: "oklch(0.62 0.11 120)",
    cta: "View more",
    content: <Plate word="verdant" note="cold-pressed · 250ml" />,
  },
  {
    title: "Berry Smoothie",
    subtitle: "Delicious flavour",
    accent: "oklch(0.55 0.2 340)",
    cta: "View more",
    content: <Plate word="bramble" note="wild-picked · 250ml" />,
  },
  {
    title: "Coffee Milk",
    subtitle: "Slow-brewed flavour",
    accent: "oklch(0.72 0.11 85)",
    cta: "View more",
    content: <Plate word="arabica" note="single origin · 250ml" />,
  },
  {
    title: "Strawberry",
    subtitle: "Exclusive drinks",
    accent: "oklch(0.55 0.19 15)",
    cta: "View more",
    content: <Plate word="fraise" note="limited run · 250ml" />,
  },
];


/** The docs preview box is ~340px wide on phones — shrink to fit it. */
function useNarrow() {
  const [narrow, setNarrow] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(max-width: 640px)");
    const sync = () => setNarrow(mq.matches);
    sync();
    mq.addEventListener("change", sync);
    return () => mq.removeEventListener("change", sync);
  }, []);
  return narrow;
}

export default function ExpandingPanelsDemo() {
  const narrow = useNarrow();

  return (
    <div className="flex w-full flex-col items-center gap-3 p-3 sm:gap-4 sm:p-6">
      <ExpandingPanels
        items={EXPANDING_PANELS_ITEMS}
        height={narrow ? 260 : 380}
        className="max-w-3xl"
      />
      <p className="text-[0.8125rem] text-[rgba(var(--ink-rgb),0.35)]">
        Hover a column — it blooms, the rest become rails
      </p>
    </div>
  );
}

```
