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
---
/** 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
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();
};
},
});