Skip to content

Components / UI

Expanding Panels

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.

Live demo

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 7.1 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · widely available

Chrome 105 · Firefox 110 · Safari 16; needs container-units. Elsewhere: without container queries the panels stay stacked, each one whole.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import ExpandingPanels from '@moonarc/core/ExpandingPanels';
---
<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>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/expanding-panels.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

ExpandingPanels.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>

Props

PropTypeDefaultDescription
panels{ title; text?; meta?; href?; block? }[]noneTwo 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.
opennumber1The panel that is wide while nothing is pointed at, from 1.
heightstring'26rem'The row's height on a wide box, any CSS length.
labelstringnoneWhat the list is, for a screen reader.

Reduced motion

A panel widens at once, with no transition.

With ClientRouter

CSS only.

Why it is built this way

Replaces: Expanding Cards (React Bits) · Expandable gallery (Aceternity) · Image accordion (Cult UI). See the migration table.

Zero JavaScript?

For this one, yes. The browser animates the height.

Keyboard?

Native: Tab, Enter, Space.

One at a time?

details name=, also native.

Native <details> items whose height animates open and closed, with an optional one-at-a-time rule the browser enforces.

newly · Chrome 131 · Firefox 143 · Safari 18.4click

Cards stick to the top as you scroll and each one shrinks and dims as the next slides over it, as on Apple product pages.

Chrome 116 · not Firefox · Safari 26scroll

Home Art

Backgrounds

A building drawn flat, for a listing that has no photograph yet: a house, a terrace, an apartment block, a villa, a cabin, a tower, an office or a shop, in the colour of the block or tone it sits on, with a shade, a light and the windows mixed from it.

every browserpointer
npx astro add moonarc
pnpm astro add moonarc
npx shadcn@latest add …/r/tabs.json

Tabs with an indicator that slides to the selected one and panels that fade in.

every browserclick

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown