Collapse

data-collapse="<expr>" is a custom plugin defined in this app (src/datastar/plugins/collapse.ts, loaded via the integration’s entrypoint option) on top of the package’s engine export. It animates height between 0 and auto, sets aria-hidden while collapsed, and respects prefers-reduced-motion. Pair it with data-cloak so a collapsed panel never flashes open on load.

Opening hours

  • Mon–Fri: 9:00 – 17:30
  • Sat: 10:00 – 16:00
  • Sun: closed
<button data-on:click="$hours = !$hours"
        data-attr:aria-expanded="$hours ? 'true' : 'false'">Opening hours</button>
<div data-collapse="$hours" data-cloak>…</div>

Accordion (one open at a time)

A hypermedia framework: signals in attributes, fragments from the server.

No — the integration injects one small script site-wide.

In signals on the page, and in whatever your endpoints render.

The accordion demo

src/components/demos/AccordionDemo.astro
---
/** data-collapse: accordion, one panel open at a time. */
import { Icon } from 'astro-icon/components';

const faqs = [
  [
    'What is Datastar?',
    'A hypermedia framework: signals in attributes, fragments from the server.',
  ],
  [
    'Do I need an island?',
    'No — the integration injects one small script site-wide.',
  ],
  [
    'Where does state live?',
    'In signals on the page, and in whatever your endpoints render.',
  ],
];
---

<section class="demo" data-signals="{faq: 0}">
  <h2>Accordion (one open at a time)</h2>
  <div class="accordion">
    {
      faqs.map(([q, a], i) => (
        <div class="accordion-item">
          <button
            class="accordion-header"
            data-on:click={`$faq = $faq === ${i + 1} ? 0 : ${i + 1}`}
            data-attr:aria-expanded={`$faq === ${i + 1} ? 'true' : 'false'`}
          >
            <span>{q}</span>
            <Icon name="lucide:chevron-down" class="chevron" aria-hidden="true" />
          </button>
          <div data-collapse={`$faq === ${i + 1}`} data-cloak>
            <p class="accordion-panel">{a}</p>
          </div>
        </div>
      ))
    }
  </div>
</section>

<style>
  .accordion {
    border: 1px solid var(--line);
    border-radius: 10px;
    overflow: hidden;
  }
  .accordion-item + .accordion-item {
    border-top: 1px solid var(--line);
  }
  .accordion-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    width: 100%;
    padding: 0.7rem 1rem;
    border: 0;
    border-radius: 0;
    background: none;
    text-align: left;
    font-weight: 600;
  }
  .accordion-header:hover {
    background: var(--bg);
    color: var(--accent);
  }
  .accordion-header .chevron {
    flex: none;
    color: var(--muted);
    transition: transform 150ms ease;
  }
  .accordion-header[aria-expanded='true'] .chevron {
    transform: rotate(180deg);
  }
  .accordion-panel {
    margin: 0;
    padding: 0.25rem 1rem 0.9rem;
    color: var(--muted);
  }
</style>

The plugin

src/datastar/plugins/collapse.ts
import { attribute, effect } from '@wrux/astro-datastar/engine';

const TRANSITION = 'height 200ms ease';

/**
 * `data-collapse="<expression>"` animates an element open (auto height) when
 * the expression is truthy and collapsed (height 0, hidden) when falsy.
 *
 * ```html
 * <button data-on:click="$open = !$open" aria-expanded="false">Hours</button>
 * <div data-collapse="$open">…</div>
 * ```
 *
 * The element animates height between 0 and its scroll height, sets
 * `aria-hidden` while collapsed, and lands on `height: auto` when open so
 * content can reflow. First render applies the state without animating.
 */
attribute({
  name: 'collapse',
  requirement: { key: 'denied', value: 'must' },
  returnsValue: true,
  apply({ el, rx }) {
    const element = el as HTMLElement;
    let first = true;

    element.style.overflow = 'hidden';

    const settle = (event: TransitionEvent) => {
      if (event.propertyName !== 'height') return;
      if (element.getAttribute('aria-hidden') !== 'true') {
        element.style.height = 'auto';
      }
    };
    element.addEventListener('transitionend', settle);

    const setState = (open: boolean, animate: boolean) => {
      // Respect the user's reduced-motion preference (checked per toggle so
      // OS-level changes apply without a reload).
      if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
        animate = false;
      }
      element.style.transition = animate ? TRANSITION : 'none';
      if (open) {
        element.removeAttribute('aria-hidden');
        if (animate) {
          element.style.height = `${element.scrollHeight}px`;
        } else {
          element.style.height = 'auto';
        }
      } else {
        element.setAttribute('aria-hidden', 'true');
        if (animate) {
          // Fix the current height so the transition has a starting value.
          element.style.height = `${element.scrollHeight}px`;
          requestAnimationFrame(() => {
            element.style.transition = TRANSITION;
            element.style.height = '0px';
          });
        } else {
          element.style.height = '0px';
        }
      }
    };

    const cleanup = effect(() => {
      const open = !!rx();
      setState(open, !first);
      first = false;
    });

    return () => {
      element.removeEventListener('transitionend', settle);
      cleanup();
    };
  },
});