# Expanding Panels (Moonarc)

A row of panels where the one pointed at or focused widens and shows its words, and the others narrow to a strip with their title turned up the side. Each panel is a link or a plain panel, its picture a slot of its own or the colour block it names. With nothing pointed at, one panel you pick is the wide one. Below 40 rem of width the panels stack, each one whole. The widening is a transition of flex-grow, a number, so it runs in every engine. Zero JavaScript.

- Import: `import ExpandingPanels from '@moonarc/core/ExpandingPanels'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/expanding-panels.json`
- Tier A · category ui · trigger pointer, hover
- Readout: `<ExpandingPanels panels={[{ title, text, href }]} open={1} />`
- Browser support: widely (Chrome 105 · Firefox 110 · Safari 16); elsewhere: without container queries the panels stay stacked, each one whole
- Measured cost: 0 B JS (CSS 7.1 kB raw)
- Page: https://moonarc.dev/components/expanding-panels/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `panels` | `{ title; text?; meta?; href?; block? }[]` | none | Two to eight panels, in order. href makes a panel a link; block is the colour block it paints when its slot (panel-1 … panel-8) is empty; meta is a short line over the title. |
| `open` | `number` | `1` | The panel that is wide while nothing is pointed at, from 1. |
| `height` | `string` | `'26rem'` | The row's height on a wide box, any CSS length. |
| `label` | `string` | none | What the list is, for a screen reader. |

## Usage

```astro
<ExpandingPanels
  label="Neighbourhoods"
  panels={[
    { title: 'Old Harbour', meta: 'From $640,000', text: 'Warehouses turned to flats, ten minutes to the station.', href: '/areas/old-harbour/' },
    { title: 'Linden Hill', meta: 'From $820,000', text: 'Family houses, two schools and the park.', href: '/areas/linden-hill/' },
    { title: 'Southbank', meta: 'From $510,000', text: 'New builds by the river.', href: '/areas/southbank/' },
  ]}
>
  <HomeArt slot="panel-1" kind="apartment" seed="Old Harbour" tile block={1} />
</ExpandingPanels>
```

## Reduced motion

A panel widens at once, with no transition.

## With ClientRouter

CSS only.

## Craft

- Each panel is a flex item whose flex-grow goes from 1 to 4: a number transitions in every engine, where an animated width or grid track does not everywhere yet.
- The words of a narrow panel fade out first and the wide panel's fade in after it has opened, so text never wraps in a box that is still widening.
- A narrow panel keeps its title as a strip turned up its side (a copy hidden from screen readers, which read every panel's words in order whatever their width).
- The layout switches on the list's own width (a container query), so the row works in a sidebar as on a page.

## Replaces

- Expanding Cards (React Bits)
- Expandable gallery (Aceternity)
- Image accordion (Cult UI)

## Source

```astro
---
/**
 * ExpandingPanels — a row of panels where the one pointed at or focused widens and shows its words, and the others
 * narrow to a strip with their title turned up the side. Each panel is a link (or a plain panel), its picture filling it:
 * a slot of its own (`panel-1` … `panel-8`) or the colour block it names. With nothing pointed at, the `open` panel is
 * the wide one. Below 40 rem of width the panels stack, each one whole. The widening is a transition of flex-grow, a
 * number, so it runs on any engine. Zero JavaScript.
 */
import type { HTMLAttributes } from 'astro/types';

interface Panel {
  title: string;
  /** A line or two, shown when the panel is wide. */
  text?: string;
  /** A short line over the title: a figure, a tag. */
  meta?: string;
  /** The panel is a link to this page. */
  href?: string;
  /** The colour block the panel paints (1–5) when it has no picture of its own. */
  block?: 1 | 2 | 3 | 4 | 5;
}

interface Props extends HTMLAttributes<'div'> {
  panels: Panel[];
  /** The panel that is wide when nothing is pointed at, from 1. */
  open?: number;
  /** The row's height on a wide screen, any CSS length. */
  height?: string;
  /** What the list is, for a screen reader. */
  label?: string;
}

const { panels, open = 1, height = '26rem', label, class: className, style, ...rest } = Astro.props;
if (panels.length < 2 || panels.length > 8) throw new Error(`ExpandingPanels: panels holds ${panels.length}; it lays out two to eight.`);
const list = panels;
const slots = Astro.slots;
// slot names are static (a name built in a map is not a slot Astro can fill), so each is rendered here, in order
const art = await Promise.all(list.map((_, i) => (slots.has(`panel-${i + 1}`) ? slots.render(`panel-${i + 1}`) : Promise.resolve(''))));
const vars = [`--ma-xp-height:${height}`, typeof style === 'string' ? style : ''].filter(Boolean).join(';');
---

<div class:list={['ma-xpanels', className]} style={vars} {...rest}>
<ul class="ma-xpanels__list" aria-label={label}>
  {list.map((p, i) => {
    const Tag = p.href ? 'a' : 'div';
    return (
      <li class="ma-xpanels__item" data-open={i + 1 === open ? '' : undefined} data-ma-block={art[i] ? undefined : (p.block ?? ((i % 5) + 1))}>
        <Tag class="ma-xpanels__panel" href={p.href} tabindex={p.href ? undefined : 0}>
          {art[i] && <span class="ma-xpanels__art" aria-hidden="true" set:html={art[i]} />}
          <span class="ma-xpanels__strip" aria-hidden="true">{p.title}</span>
          <span class="ma-xpanels__words">
            {p.meta && <><span class="ma-xpanels__meta">{p.meta}</span>{' '}</>}
            <span class="ma-xpanels__title">{p.title}</span>
            {p.text && <>{' '}<span class="ma-xpanels__text">{p.text}</span></>}
          </span>
        </Tag>
      </li>
    );
  })}
</ul>
</div>

<style is:global>
  @layer components {
    /* the root is the container and the list its child: an element never answers its own container query */
    :where(.ma-xpanels) { container-type: inline-size; }
    :where(.ma-xpanels__list) {
      display: grid;
      gap: 0.75rem;
      margin: 0;
      padding: 0;
      list-style: none;
    }
    :where(.ma-xpanels__item) { min-inline-size: 0; }
    :where(.ma-xpanels__strip) { display: none; }
    :where(.ma-xpanels__panel) {
      position: relative;
      display: grid;
      align-content: end;
      block-size: 100%;
      min-block-size: 15rem;
      overflow: hidden;
      border-radius: var(--ma-radius-xl, 1.25rem);
      background: var(--ma-block-ground, var(--card, var(--ma-panel)));
      color: var(--ma-block-text, var(--card-foreground, currentColor));
      text-decoration: none;
      isolation: isolate;
    }
    :where(.ma-xpanels__panel:focus-visible) { outline: 2px solid var(--ring, currentColor); outline-offset: 3px; }
    :where(.ma-xpanels__art) { position: absolute; inset: 0; z-index: -1; display: grid; }
    :where(.ma-xpanels__art > *) { inline-size: 100%; block-size: 100%; }
    /* a picture under the words gets a scrim so they read on it: dark enough under every line for white text on the
       palest sky (4.5 : 1 at 0.84), fading out over the 3 rem above the first line */
    :where(.ma-xpanels__art ~ .ma-xpanels__words) { padding-block-start: 3rem; background: linear-gradient(to top, oklch(0.16 0 0 / 0.88), oklch(0.16 0 0 / 0.84) calc(100% - 3rem), oklch(0.16 0 0 / 0)); color: #fff; }
    :where(.ma-xpanels__words) { display: grid; gap: 0.35rem; padding: 1.25rem 1.35rem 1.35rem; }
    :where(.ma-xpanels__meta) { font-size: 0.8125rem; font-weight: 600; }
    :where(.ma-xpanels__title) { font-family: var(--ma-font-display, inherit); font-size: 1.35rem; font-weight: var(--ma-display-weight, 700); letter-spacing: -0.015em; line-height: 1.15; }
    :where(.ma-xpanels__text) { max-inline-size: 30ch; font-size: 0.9375rem; line-height: 1.5; }

    /* a wide box: one row, the open panel wide and the rest strips, the one pointed at or focused taking the width */
    @container (min-width: 40rem) {
      :where(.ma-xpanels__list) { display: flex; block-size: var(--ma-xp-height, 26rem); }
      :where(.ma-xpanels__item) { flex: 1 1 0; transition: flex-grow var(--ma-duration-slow, 500ms) var(--ma-ease-out, ease-out); }
      :where(.ma-xpanels__item[data-open]) { flex-grow: 4; }
      :where(.ma-xpanels__list:is(:hover, :focus-within) .ma-xpanels__item) { flex-grow: 1; }
      :where(.ma-xpanels__list:is(:hover, :focus-within) .ma-xpanels__item:is(:hover, :focus-within)) { flex-grow: 4; }
      :where(.ma-xpanels__words) { min-inline-size: 16rem; }
      /* a narrow panel shows its title only, turned to read up the strip */
      :where(.ma-xpanels__item:not([data-open]) .ma-xpanels__words),
      :where(.ma-xpanels__list:is(:hover, :focus-within) .ma-xpanels__item .ma-xpanels__words) { opacity: 0; transition: opacity var(--ma-duration-fast, 150ms) var(--ma-ease-out, ease-out); }
      :where(.ma-xpanels__list:not(:hover, :focus-within) .ma-xpanels__item[data-open] .ma-xpanels__words),
      :where(.ma-xpanels__list .ma-xpanels__item:is(:hover, :focus-within) .ma-xpanels__words) { opacity: 1; transition-delay: 180ms; }
      /* physical insets: a logical one would resolve in the strip's own vertical writing mode, block-end being the left */
      :where(.ma-xpanels__strip) {
        position: absolute;
        bottom: 1rem;
        left: 50%;
        display: block;
        max-height: calc(100% - 2rem);
        overflow: hidden;
        translate: -50% 0;
        writing-mode: vertical-rl;
        rotate: 180deg;
        white-space: nowrap;
        text-overflow: ellipsis;
        font-family: var(--ma-font-display, inherit);
        font-size: 1.05rem;
        font-weight: var(--ma-display-weight, 700);
        transition: opacity var(--ma-duration-fast, 150ms) var(--ma-ease-out, ease-out);
      }
      /* over a picture the title sits on a dark pill of its own, so it reads on the lightest sky */
      :where(.ma-xpanels__art ~ .ma-xpanels__strip) { padding: 0.7rem 0.35rem; border-radius: 999px; background: oklch(0.15 0 0 / 0.62); color: #fff; }
      :where(.ma-xpanels__list:not(:hover, :focus-within) .ma-xpanels__item[data-open] .ma-xpanels__strip),
      :where(.ma-xpanels__list .ma-xpanels__item:is(:hover, :focus-within) .ma-xpanels__strip) { opacity: 0; }
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-xpanels__item), :where(.ma-xpanels__words), :where(.ma-xpanels__strip) { transition: none; }
    }
  }
</style>

```
