Accordion
A vertically stacked set of interactive headings that reveal content sections.
Single Mode (JS)
Only one item can be open at a time.
Yes. It adheres to the WAI-ARIA design pattern.
Yes. It comes with default styles.
CSS-Only: Multiple Mode
Zero JavaScript. Multiple items can be open.
Works even if JS is disabled.
Uses checkboxes - open as many as you want.
CSS-Only: Single Mode
Zero JavaScript. Only one item can be open.
Click another item to close this one.
Pure CSS magic!
FreeAccordion
Disclosure UI for FAQs and settings groups.
Install with the Proa CLI
$ proa ui add accordionInstalls the reviewed component source, dependencies, shared support files, and required legal notices.
CLI and registry setup →recipe.rs
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Accordion recipe — named slots for interactive and CSS-only accordions.
//!
//! Accordion behavior owns semantic state attributes. This recipe owns only
//! the static appearance of each rendered part, including the additional
//! slots used by the zero-JavaScript implementation.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Accordion type - controls how many items can be open at once.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum AccordionType {
/// Only one item can be open at a time.
#[default]
Single,
/// Multiple items can be open simultaneously.
Multiple,
}
Accordion source
Browse and copy the reviewed source included with this Free component.
accordion.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND (ISC AND MIT)
//! Accordion component implementations
//!
//! SSR markup for accordions. The JavaScript hydration handles the
//! expand/collapse behavior and animations.
use super::recipe::{AccordionRecipeProps, AccordionType, ACCORDION_RECIPE};
use super::ACCORDION_ANATOMY;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Accordion root component - manages the accordion state
///
/// Supports two modes:
/// - `Single`: Only one item can be open at a time
/// - `Multiple`: Multiple items can be open simultaneously
pub struct Accordion<C = ()> {
/// Accordion type (single or multiple)
pub accordion_type: AccordionType,
/// Whether items can be collapsed in single mode
pub collapsible: bool,
/// Default open item(s) - comma-separated values for multiple
pub default_value: Option<&'static str>,
/// Additional classes
pub class: Option<&'static str>,
/// Accordion items
pub children: Option<C>,
}
impl Accordion<()> {
pub const fn new() -> Self {
Self {
accordion_type: AccordionType::Single,
collapsible: false,
default_value: None,
class: None,
children: None,
}
}
}
impl<C> Default for Accordion<C> {
fn default() -> Self {
Self {
accordion_type: AccordionType::Single,
collapsible: false,
default_value: None,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for Accordion<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = ACCORDION_RECIPE.resolve(AccordionRecipeProps);
let collapsible = if self.collapsible { "true" } else { "false" };
html_sync! {
<div
data-accordion-root
data-scope={ACCORDION_ANATOMY.scope()}
data-part="root"
data-slot="accordion"
data-type={self.accordion_type.as_str()}
data-collapsible={collapsible}
data-value={self.default_value}
class={proa_macros::text!("{} {}", style, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// AccordionItem - individual expandable section
pub struct AccordionItem<C = ()> {
/// Unique value for this item
pub value: &'static str,
/// Whether this item is disabled
pub disabled: bool,
/// Additional classes
pub class: Option<&'static str>,
/// Item children (trigger + content)
pub children: Option<C>,
}
impl<C> Default for AccordionItem<C> {
fn default() -> Self {
Self {
value: "",
disabled: false,
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AccordionItem<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let disabled = if self.disabled { "true" } else { "false" };
html_sync! {
<div
data-accordion-item
data-scope={ACCORDION_ANATOMY.scope()}
data-part="item"
data-slot="accordion-item"
data-value={self.value}
data-state="closed"
data-disabled={disabled}
class={proa_macros::text!("{} {}", ACCORDION_RECIPE.item, self.class)}
>
{self.children}
</div>
}
.render(cx)
}
}
/// AccordionTrigger - clickable header that toggles the content
pub struct AccordionTrigger<C = ()> {
/// Additional classes
pub class: Option<&'static str>,
/// Trigger content (usually text)
pub children: Option<C>,
}
impl<C> Default for AccordionTrigger<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AccordionTrigger<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<h3
data-scope={ACCORDION_ANATOMY.scope()}
data-part="heading"
data-slot="accordion-heading"
class={ACCORDION_RECIPE.heading}
>
<button
type="button"
data-accordion-trigger
data-scope={ACCORDION_ANATOMY.scope()}
data-part="trigger"
data-slot="accordion-trigger"
aria-expanded="false"
class={proa_macros::text!("{} {}", ACCORDION_RECIPE.trigger, self.class)}
>
{self.children}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={ACCORDION_RECIPE.chevron}
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
</h3>
}
.render(cx)
}
}
/// AccordionContent - the expandable content area
pub struct AccordionContent<C = ()> {
/// Additional classes
pub class: Option<&'static str>,
/// Content to show when expanded
pub children: Option<C>,
}
impl<C> Default for AccordionContent<C> {
fn default() -> Self {
Self {
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AccordionContent<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
html_sync! {
<div
data-accordion-content
data-scope={ACCORDION_ANATOMY.scope()}
data-part="content"
data-slot="accordion-content"
data-state="closed"
role="region"
hidden
class={proa_macros::text!("{} {}", ACCORDION_RECIPE.content, self.class)}
>
<div
data-scope={ACCORDION_ANATOMY.scope()}
data-part="content-inner"
data-slot="accordion-content-inner"
class={ACCORDION_RECIPE.content_inner}
>
{self.children}
</div>
</div>
}
.render(cx)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
use proa_core::text::text;
#[test]
fn accordion_renders_with_data_attributes() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Accordion {
accordion_type: AccordionType::Single,
collapsible: true,
default_value: Some("item-1"),
class: None,
children: Some(text("Items")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-accordion-root"));
assert!(html.contains("data-slot=\"accordion\""));
assert!(html.contains("data-type=\"single\""));
assert!(html.contains("data-collapsible=\"true\""));
assert!(html.contains("data-value=\"item-1\""));
}
#[test]
fn accordion_item_renders_with_value() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AccordionItem {
value: "item-1",
disabled: false,
class: None,
children: Some(text("Content")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-accordion-item"));
assert!(html.contains("data-slot=\"accordion-item\""));
assert!(html.contains("data-value=\"item-1\""));
assert!(html.contains("data-state=\"closed\""));
}
#[test]
fn accordion_trigger_renders_as_button() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AccordionTrigger {
class: None,
children: Some(text("Click me")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-accordion-trigger"));
assert!(html.contains("data-slot=\"accordion-trigger\""));
assert!(html.contains("<button"));
assert!(html.contains("aria-expanded=\"false\""));
assert!(html.contains("<svg"));
}
#[test]
fn accordion_content_renders_hidden() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AccordionContent {
class: None,
children: Some(text("Hidden content")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-accordion-content"));
assert!(html.contains("data-slot=\"accordion-content\""));
assert!(html.contains("data-state=\"closed\""));
assert!(html.contains("hidden"));
assert!(html.contains("role=\"region\""));
}
#[test]
fn disabled_item_renders_correctly() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AccordionItem {
value: "item-1",
disabled: true,
class: None,
children: Some(text("Disabled")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("data-disabled=\"true\""));
}
#[test]
fn delegated_runtime_markup_matches_public_anatomy() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
Accordion {
children: Some(AccordionItem {
value: "shipping",
children: Some(html_sync! {
{AccordionTrigger { children: Some("Shipping"), ..Default::default() }}
{AccordionContent { children: Some("Ships tomorrow"), ..Default::default() }}
}),
..Default::default()
}),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert_eq!(
html.matches(r#"data-scope="accordion""#).count(),
6,
"{html}"
);
for part in [
"root",
"item",
"heading",
"trigger",
"content",
"content-inner",
] {
assert!(ACCORDION_ANATOMY.contains(part));
assert!(
html.contains(&format!(r#"data-part="{part}""#)),
"missing {part}: {html}"
);
}
assert!(html.contains(r#"data-slot="accordion-trigger""#));
assert!(html.contains(r#"data-slot="accordion-content""#));
}
}
css_only.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: (MIT OR Apache-2.0) AND (ISC AND MIT)
//! CSS-only Accordion component
//!
//! A zero-JavaScript accordion using the checkbox/radio input hack.
//! Works without JavaScript, supports both single and multiple modes.
//!
//! - Multiple mode: Uses checkboxes, multiple items can be open
//! - Single mode: Uses radio inputs, only one item can be open at a time
use super::recipe::{AccordionRecipeProps, ACCORDION_RECIPE};
use super::ACCORDION_ANATOMY;
use proa_core::{WebContext, WebRenderSync, WriteBuf};
use proa_macros::html_sync;
/// Accordion mode for CSS-only accordion
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum AccordionCSSOnlyMode {
/// Multiple items can be open (uses checkboxes)
#[default]
Multiple,
/// Only one item can be open at a time (uses radio inputs)
Single,
}
/// CSS-only Accordion root container
///
/// Uses the checkbox/radio input hack for pure CSS expand/collapse.
/// - `Multiple` mode: checkboxes allow multiple items open
/// - `Single` mode: radio inputs ensure only one item open at a time
pub struct AccordionCSSOnly<C = ()> {
/// Accordion mode - single or multiple
pub mode: AccordionCSSOnlyMode,
/// Unique name for radio group (required for single mode)
/// Items will use this to link their radio inputs
pub name: &'static str,
/// Additional classes
pub class: Option<&'static str>,
/// Accordion items
pub children: Option<C>,
}
impl<C> Default for AccordionCSSOnly<C> {
fn default() -> Self {
Self {
mode: AccordionCSSOnlyMode::Multiple,
name: "accordion",
class: None,
children: None,
}
}
}
impl<B: WriteBuf, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader> WebRenderSync<L, B>
for AccordionCSSOnly<C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let style = ACCORDION_RECIPE.resolve(AccordionRecipeProps);
let mode_str = match self.mode {
AccordionCSSOnlyMode::Multiple => "multiple",
AccordionCSSOnlyMode::Single => "single",
};
html_sync! {
<div
class={proa_macros::text!("{} {}", style, self.class)}
data-accordion-css-only
data-scope={ACCORDION_ANATOMY.scope()}
data-part="root"
data-slot="accordion-css-only"
data-mode={mode_str}
data-name={self.name}
>
{self.children}
</div>
}
.render(cx)
}
}
/// CSS-only Accordion Item using checkbox/radio input hack
pub struct AccordionCSSOnlyItem<T = (), C = ()> {
/// Unique ID for this item (used for input id and label for)
pub id: &'static str,
/// Group name - must match parent AccordionCSSOnly name for single mode
pub name: &'static str,
/// Whether to use radio (single mode) or checkbox (multiple mode)
pub single_mode: bool,
/// Whether this item is open by default
pub open: bool,
/// Whether this item is disabled
pub disabled: bool,
/// Additional classes
pub class: Option<&'static str>,
/// Trigger content (the clickable label)
pub trigger: Option<T>,
/// Content to show when expanded
pub children: Option<C>,
}
impl<T, C> Default for AccordionCSSOnlyItem<T, C> {
fn default() -> Self {
Self {
id: "",
name: "accordion",
single_mode: false,
open: false,
disabled: false,
class: None,
trigger: None,
children: None,
}
}
}
impl<B: WriteBuf, T: WebRenderSync<L, B>, C: WebRenderSync<L, B>, L: ::proa_core::DataLoader>
WebRenderSync<L, B> for AccordionCSSOnlyItem<T, C>
{
fn render(
self,
cx: &mut WebContext<L, B>,
) -> Result<(), proa_core::shared::write_buf::WriteError> {
let disabled_classes = self
.disabled
.then_some(ACCORDION_RECIPE.css_only_item_disabled);
let input_type = if self.single_mode {
"radio"
} else {
"checkbox"
};
if self.open {
html_sync! {
<div
data-scope={ACCORDION_ANATOMY.scope()}
data-part="item"
data-slot="accordion-css-only-item"
class={proa_macros::text!("{} {} {}", ACCORDION_RECIPE.css_only_item, disabled_classes, self.class)}
>
<input
type={input_type}
name={self.name}
id={self.id}
data-scope={ACCORDION_ANATOMY.scope()}
data-part="input"
data-slot="accordion-css-only-input"
aria-controls={proa_macros::text!("{}-content", self.id)}
class={ACCORDION_RECIPE.css_only_input}
checked
disabled={self.disabled}
/>
<label
id={proa_macros::text!("{}-trigger", self.id)}
for={self.id}
data-scope={ACCORDION_ANATOMY.scope()}
data-part="trigger"
data-slot="accordion-css-only-trigger"
class={ACCORDION_RECIPE.css_only_trigger}
>
{self.trigger}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={ACCORDION_RECIPE.css_only_chevron}
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</label>
<div
id={proa_macros::text!("{}-content", self.id)}
data-scope={ACCORDION_ANATOMY.scope()}
data-part="content"
data-slot="accordion-css-only-content"
role="region"
aria-labelledby={proa_macros::text!("{}-trigger", self.id)}
class={ACCORDION_RECIPE.css_only_content}
>
<div
data-scope={ACCORDION_ANATOMY.scope()}
data-part="content-inner"
data-slot="accordion-css-only-content-inner"
class={ACCORDION_RECIPE.css_only_content_inner}
>
<div
data-scope={ACCORDION_ANATOMY.scope()}
data-part="content-body"
data-slot="accordion-css-only-content-body"
class={ACCORDION_RECIPE.css_only_content_body}
>
{self.children}
</div>
</div>
</div>
</div>
}
.render(cx)
} else {
html_sync! {
<div
data-scope={ACCORDION_ANATOMY.scope()}
data-part="item"
data-slot="accordion-css-only-item"
class={proa_macros::text!("{} {} {}", ACCORDION_RECIPE.css_only_item, disabled_classes, self.class)}
>
<input
type={input_type}
name={self.name}
id={self.id}
data-scope={ACCORDION_ANATOMY.scope()}
data-part="input"
data-slot="accordion-css-only-input"
aria-controls={proa_macros::text!("{}-content", self.id)}
class={ACCORDION_RECIPE.css_only_input}
disabled={self.disabled}
/>
<label
id={proa_macros::text!("{}-trigger", self.id)}
for={self.id}
data-scope={ACCORDION_ANATOMY.scope()}
data-part="trigger"
data-slot="accordion-css-only-trigger"
class={ACCORDION_RECIPE.css_only_trigger}
>
{self.trigger}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={ACCORDION_RECIPE.css_only_chevron}
aria-hidden="true"
>
<path d="m6 9 6 6 6-6" />
</svg>
</label>
<div
id={proa_macros::text!("{}-content", self.id)}
data-scope={ACCORDION_ANATOMY.scope()}
data-part="content"
data-slot="accordion-css-only-content"
role="region"
aria-labelledby={proa_macros::text!("{}-trigger", self.id)}
class={ACCORDION_RECIPE.css_only_content}
>
<div
data-scope={ACCORDION_ANATOMY.scope()}
data-part="content-inner"
data-slot="accordion-css-only-content-inner"
class={ACCORDION_RECIPE.css_only_content_inner}
>
<div
data-scope={ACCORDION_ANATOMY.scope()}
data-part="content-body"
data-slot="accordion-css-only-content-body"
class={ACCORDION_RECIPE.css_only_content_body}
>
{self.children}
</div>
</div>
</div>
</div>
}
.render(cx)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use proa_core::ctx::Ctx;
use proa_core::text::text;
#[test]
fn css_only_accordion_renders_checkbox_by_default() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AccordionCSSOnlyItem {
id: "item-1",
name: "acc",
single_mode: false,
open: false,
disabled: false,
class: None,
trigger: Some(text("Click me")),
children: Some(text("Content here")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("type=\"checkbox\""));
assert!(html.contains("id=\"item-1\""));
assert!(html.contains("for=\"item-1\""));
assert!(html.contains("aria-controls=\"item-1-content\""));
assert!(html.contains("id=\"item-1-trigger\""));
assert!(html.contains("id=\"item-1-content\""));
assert!(html.contains("aria-labelledby=\"item-1-trigger\""));
assert!(html.contains("role=\"region\""));
}
#[test]
fn css_only_accordion_renders_radio_for_single_mode() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AccordionCSSOnlyItem {
id: "item-1",
name: "acc",
single_mode: true,
open: false,
disabled: false,
class: None,
trigger: Some(text("Click me")),
children: Some(text("Content here")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("type=\"radio\""));
assert!(html.contains("name=\"acc\""));
}
#[test]
fn css_only_accordion_renders_checked_when_open() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AccordionCSSOnlyItem {
id: "item-1",
name: "acc",
single_mode: false,
open: true,
disabled: false,
class: None,
trigger: Some(text("Open item")),
children: Some(text("Visible content")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("checked"));
}
#[test]
fn css_only_accordion_disabled_styling() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AccordionCSSOnlyItem {
id: "item-1",
name: "acc",
single_mode: false,
open: false,
disabled: true,
class: None,
trigger: Some(text("Disabled")),
children: Some(text("Content")),
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert!(html.contains("pointer-events-none"));
assert!(html.contains("opacity-50"));
assert!(html.contains(" disabled"));
}
#[test]
fn css_only_markup_matches_public_anatomy() {
let mut out = Vec::new();
let mut cx = Ctx::with_buffer(&mut out);
AccordionCSSOnly {
children: Some(AccordionCSSOnlyItem {
id: "shipping",
trigger: Some("Shipping"),
children: Some("Ships tomorrow"),
..Default::default()
}),
..Default::default()
}
.render(&mut cx)
.unwrap();
let html = String::from_utf8(out).unwrap();
assert_eq!(
html.matches(r#"data-scope="accordion""#).count(),
7,
"{html}"
);
for part in [
"root",
"item",
"input",
"trigger",
"content",
"content-inner",
"content-body",
] {
assert!(ACCORDION_ANATOMY.contains(part));
assert!(
html.contains(&format!(r#"data-part="{part}""#)),
"missing {part}: {html}"
);
}
assert!(html.contains(r#"data-slot="accordion-css-only-trigger""#));
assert!(html.contains(r#"data-slot="accordion-css-only-content""#));
}
}
md.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
use super::{Accordion, AccordionContent, AccordionItem, AccordionTrigger};
use crate::components::md_support::block_boundary;
use proa_core::WebContext;
use proa_core::{MdRenderSync, WriteBuf, WriteError};
/// Accordion is a passthrough — children handle their own rendering.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for Accordion<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
Ok(())
}
}
/// AccordionItem is a passthrough.
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for AccordionItem<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
Ok(())
}
}
/// AccordionTrigger renders as a heading (level 3 + offset).
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for AccordionTrigger<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
block_boundary(cx)?;
cx.md_heading_with(3, |cx| self.children.render_md(cx))
}
}
/// AccordionContent always renders in markdown (no collapse state).
impl<Loader: ::proa_core::DataLoader, B: WriteBuf, C: MdRenderSync<Loader, B>>
MdRenderSync<Loader, B> for AccordionContent<C>
{
fn render_md(self, cx: &mut WebContext<Loader, B>) -> Result<(), WriteError> {
self.children.render_md(cx)?;
cx.md_blank_line()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accordion_item_renders_heading_and_content() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
AccordionItem {
value: "item-1",
disabled: false,
class: None,
children: Some((
AccordionTrigger {
class: None,
children: Some("Is it accessible?"),
},
AccordionContent {
class: None,
children: Some("Yes. It adheres to WAI-ARIA."),
},
)),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.contains("### Is it accessible?"), "output: {output}");
assert!(
output.contains("Yes\\. It adheres to WAI\\-ARIA\\."),
"output: {output}"
);
}
#[test]
fn accordion_composes_after_inline_text() {
let mut buf = Vec::new();
let mut cx = WebContext::with_buffer(&mut buf);
cx.write_md_prepared_static(b"intro prose").unwrap();
AccordionItem {
value: "item-1",
disabled: false,
class: None,
children: Some((
AccordionTrigger {
class: None,
children: Some("Is it accessible?"),
},
AccordionContent {
class: None,
children: Some("Yes."),
},
)),
}
.render_md(&mut cx)
.unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(
output.contains("intro prose\n\n### Is it accessible?"),
"trigger heading must start at a block boundary, got:\n{output}"
);
}
}
mod.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Accordion component module
//!
//! A vertically stacked set of interactive headings that reveal content sections.
//!
//! Two variants are available:
//! - `Accordion` - Full-featured with JS hydration (single/multiple mode, keyboard nav)
//! - `AccordionCSSOnly` - Zero-JS using native `<details>`/`<summary>` elements
use crate::Anatomy;
#[allow(clippy::module_inception)]
mod accordion;
mod css_only;
mod md;
mod recipe;
/// Stable DOM parts shared by the delegated-runtime and CSS-only accordions.
pub const ACCORDION_ANATOMY: Anatomy = Anatomy::new(
"accordion",
&[
"root",
"item",
"heading",
"trigger",
"input",
"content",
"content-inner",
"content-body",
],
);
pub use accordion::{Accordion, AccordionContent, AccordionItem, AccordionTrigger};
pub use css_only::{AccordionCSSOnly, AccordionCSSOnlyItem, AccordionCSSOnlyMode};
pub use recipe::{
accordion_content_classes, accordion_content_inner_classes, accordion_item_classes,
accordion_recipe, accordion_trigger_classes, AccordionRecipe, AccordionRecipeProps,
AccordionStyle, AccordionType, ACCORDION_RECIPE,
};
#[cfg(test)]
mod anatomy_tests {
use super::*;
#[test]
fn anatomy_declares_every_public_part() {
assert_eq!(ACCORDION_ANATOMY.scope(), "accordion");
assert_eq!(
ACCORDION_ANATOMY.parts(),
&[
"root",
"item",
"heading",
"trigger",
"input",
"content",
"content-inner",
"content-body",
]
);
}
}
recipe.rsOpen raw ↗
// SPDX-FileCopyrightText: 2026 Proa Labs, Inc.
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Accordion recipe — named slots for interactive and CSS-only accordions.
//!
//! Accordion behavior owns semantic state attributes. This recipe owns only
//! the static appearance of each rendered part, including the additional
//! slots used by the zero-JavaScript implementation.
use crate::tw_join;
use proa_core::shared::attr_value::AttrValue;
use proa_core::{DataLoader, WebContext, WriteBuf, WriteError};
/// Accordion type - controls how many items can be open at once.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum AccordionType {
/// Only one item can be open at a time.
#[default]
Single,
/// Multiple items can be open simultaneously.
Multiple,
}
impl AccordionType {
pub const fn as_str(self) -> &'static str {
match self {
Self::Single => "single",
Self::Multiple => "multiple",
}
}
}
mod base {
use super::*;
pub const ROOT: &str = "w-full";
pub const ITEM: &str = tw_join!("border-b", "border-border");
pub const HEADING: &str = "flex";
pub const TRIGGER: &str = tw_join!(
"flex",
"flex-1",
"items-center",
"justify-between",
"py-4",
"text-sm",
"font-medium",
"text-foreground",
"transition-all",
"hover:underline",
"text-left",
"[&[data-state=open]>svg]:rotate-180"
);
pub const CHEVRON: &str = "shrink-0 transition-transform duration-200";
pub const CONTENT: &str = tw_join!(
"overflow-hidden",
"text-sm",
"text-muted-foreground",
"data-[state=closed]:animate-accordion-up",
"data-[state=open]:animate-accordion-down"
);
pub const CONTENT_INNER: &str = tw_join!("pb-4", "pt-0");
pub const CSS_ONLY_ITEM: &str = tw_join!("border-b", "border-border", "group/item", "relative");
pub const CSS_ONLY_ITEM_DISABLED: &str = "pointer-events-none opacity-50";
pub const CSS_ONLY_INPUT: &str = "peer absolute opacity-0 -z-10";
pub const CSS_ONLY_TRIGGER: &str = tw_join!(
"flex",
"flex-1",
"items-center",
"justify-between",
"py-4",
"text-sm",
"font-medium",
"text-foreground",
"transition-all",
"hover:underline",
"text-left",
"cursor-pointer"
);
pub const CSS_ONLY_CHEVRON: &str =
"shrink-0 transition-transform duration-200 peer-checked:group-[]/item:rotate-180";
pub const CSS_ONLY_CONTENT: &str =
"grid grid-rows-[0fr] transition-all duration-300 peer-checked:grid-rows-[1fr]";
pub const CSS_ONLY_CONTENT_INNER: &str = "overflow-hidden";
pub const CSS_ONLY_CONTENT_BODY: &str = "pb-4 pt-0 text-sm text-muted-foreground";
}
/// Optional inputs accepted by [`AccordionRecipe::resolve`].
///
/// Accordion type and open state are behavioral rather than visual axes, so
/// the current recipe has no optional appearance inputs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct AccordionRecipeProps;
/// Static named classes for all Accordion parts.
#[derive(Debug, Clone, Copy)]
pub struct AccordionRecipe {
pub root: &'static str,
pub item: &'static str,
pub heading: &'static str,
pub trigger: &'static str,
pub chevron: &'static str,
pub content: &'static str,
pub content_inner: &'static str,
pub css_only_item: &'static str,
pub css_only_item_disabled: &'static str,
pub css_only_input: &'static str,
pub css_only_trigger: &'static str,
pub css_only_chevron: &'static str,
pub css_only_content: &'static str,
pub css_only_content_inner: &'static str,
pub css_only_content_body: &'static str,
}
impl AccordionRecipe {
pub const fn resolve(&'static self, _props: AccordionRecipeProps) -> AccordionStyle {
AccordionStyle { recipe: self }
}
}
/// Fully resolved, streamable Accordion root style.
#[must_use]
#[derive(Debug, Clone, Copy)]
pub struct AccordionStyle {
recipe: &'static AccordionRecipe,
}
impl AttrValue for AccordionStyle {
#[inline(always)]
fn should_render(&self) -> bool {
true
}
#[inline(always)]
fn render_attr_value<B: WriteBuf, L: DataLoader>(
&self,
cx: &mut WebContext<L, B>,
) -> Result<(), WriteError> {
cx.out.extend_static(self.recipe.root.as_bytes())
}
}
pub const ACCORDION_RECIPE: AccordionRecipe = AccordionRecipe {
root: base::ROOT,
item: base::ITEM,
heading: base::HEADING,
trigger: base::TRIGGER,
chevron: base::CHEVRON,
content: base::CONTENT,
content_inner: base::CONTENT_INNER,
css_only_item: base::CSS_ONLY_ITEM,
css_only_item_disabled: base::CSS_ONLY_ITEM_DISABLED,
css_only_input: base::CSS_ONLY_INPUT,
css_only_trigger: base::CSS_ONLY_TRIGGER,
css_only_chevron: base::CSS_ONLY_CHEVRON,
css_only_content: base::CSS_ONLY_CONTENT,
css_only_content_inner: base::CSS_ONLY_CONTENT_INNER,
css_only_content_body: base::CSS_ONLY_CONTENT_BODY,
};
pub const fn accordion_recipe() -> &'static AccordionRecipe {
&ACCORDION_RECIPE
}
impl crate::variant_spec::ComponentSpec for AccordionRecipe {
const NAME: &'static str = "Accordion";
fn base_classes(&self) -> &'static str {
self.root
}
fn variant_metadata(&self) -> Vec<crate::variant_spec::VariantMetadata> {
Vec::new()
}
fn anatomy(&self) -> Option<crate::Anatomy> {
Some(super::ACCORDION_ANATOMY)
}
}
/// Compatibility accessors for the former standalone helpers.
pub const fn accordion_item_classes() -> &'static str {
ACCORDION_RECIPE.item
}
pub const fn accordion_trigger_classes() -> &'static str {
ACCORDION_RECIPE.trigger
}
pub const fn accordion_content_classes() -> &'static str {
ACCORDION_RECIPE.content
}
pub const fn accordion_content_inner_classes() -> &'static str {
ACCORDION_RECIPE.content_inner
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recipe_exposes_interactive_named_slots() {
let recipe = accordion_recipe();
assert_eq!(recipe.root, "w-full");
assert!(recipe.item.contains("border-b"));
assert!(recipe.trigger.contains("justify-between"));
assert!(recipe.chevron.contains("transition-transform"));
assert!(recipe.content.contains("animate-accordion-down"));
assert!(recipe.content_inner.contains("pb-4"));
}
#[test]
fn recipe_exposes_css_only_named_slots() {
let recipe = accordion_recipe();
assert!(recipe.css_only_item.contains("group/item"));
assert!(recipe.css_only_input.contains("peer"));
assert!(recipe.css_only_chevron.contains("peer-checked"));
assert!(recipe.css_only_content.contains("peer-checked"));
assert!(recipe
.css_only_content_body
.contains("text-muted-foreground"));
}
#[test]
fn recipe_is_const_and_copy() {
const STYLE: AccordionStyle = ACCORDION_RECIPE.resolve(AccordionRecipeProps);
let copied = STYLE;
assert_eq!(copied.recipe.root, "w-full");
}
#[test]
fn accordion_type_as_str() {
assert_eq!(AccordionType::Single.as_str(), "single");
assert_eq!(AccordionType::Multiple.as_str(), "multiple");
}
#[test]
fn component_metadata_uses_canonical_anatomy() {
use crate::variant_spec::ComponentSpec;
assert_eq!(
ACCORDION_RECIPE.anatomy(),
Some(super::super::ACCORDION_ANATOMY)
);
assert!(ACCORDION_RECIPE.variant_metadata().is_empty());
}
}