Component 01 of 60
Accordion
A vertically stacked set of interactive headings that reveal content.
Starting the preview. GPUI compiles to WebAssembly, so the first load takes a few seconds. This browser has neither WebGPU nor WebGL 2, so the live preview cannot run. The code on this page still works. The preview did not start.
fn accordion_preview(&self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let expanded = window.use_keyed_state("faq-open", cx, |_, _| 0_usize);
let selected = *expanded.read(cx);
accordion("preview.accordion", cx).w(px(480.)).children(
[
(
"Is it accessible?",
"Use Tab and Enter to open each section.",
),
(
"Is it styled?",
"Yes. It follows the shadcn Nova visual system.",
),
(
"Is it native?",
"Yes. GPUI Kit supplies the native controls.",
),
]
.into_iter()
.enumerate()
.map(|(i, (title, text))| {
let expanded = expanded.clone();
accordion_item(cx)
.open(selected == i)
.header(accordion_header(
accordion_trigger(("faq", i), selected == i, false, cx)
.child(title)
.on_click(move |_, _, cx| {
expanded.update(cx, |selected, cx| {
*selected = if *selected == i { usize::MAX } else { i };
cx.notify();
})
}),
))
.panel(accordion_content(
("faq-content", i),
selected == i,
text,
window,
cx,
))
}),
)
}
Installation
After the one-time setup, run the command from your app root. The CLI
writes the modules and keeps src/ui/mod.rs in step.
gpuicn add accordion
If you prefer to copy the files by hand, declare them yourself:
// src/ui/mod.rs
pub mod theme;
pub mod accordion;
// src/main.rs
mod ui;
Usage
Put this in a view module and return example(open, window, cx) from its render method.
use crate::ui::accordion::*;
use gpui_kit::{App, IntoElement, ParentElement, Window};
fn example(open: bool, window: &mut Window, cx: &mut App) -> impl IntoElement {
accordion("faq", cx).child(
accordion_item(cx)
.open(open)
.header(accordion_header(
accordion_trigger("shipping", open, false, cx).child("When will it arrive?"),
))
.panel(accordion_content(
"shipping-content",
open,
"Within three working days.",
window,
cx,
)),
)
}
API reference
pub fn accordion(id: impl Into<ElementId>, cx: &App) -> Accordion
Creates the accordion collection. Each item receives its expanded state from the caller.
pub fn accordion_item(cx: &App) -> AccordionItem
Creates an item with the Nova divider.
pub fn accordion_header(trigger: AccordionTrigger) -> AccordionHeader
Creates a heading around a trigger.
pub fn accordion_trigger(
id: impl Into<ElementId>,
open: bool,
disabled: bool,
cx: &App,
) -> AccordionTrigger
Creates a keyboard-focusable disclosure trigger with a chevron.
pub fn accordion_content(
id: impl Into<ElementId>,
open: bool,
content: impl IntoElement,
window: &mut Window,
cx: &mut App,
) -> AccordionPanel
Creates a controlled panel, retaining its content only while the exit motion runs.
Installed source, 147 lines
//! Nova accordion presentation with caller-owned disclosure state.
use super::theme::{UiTheme, primitives::disabled};
use gpui_icons::{LucideIcon, lucide};
pub use gpui_kit::base::{
Accordion, AccordionHeader, AccordionItem, AccordionPanel, AccordionTrigger,
};
use gpui_kit::{
App, ElementId, FontWeight, InteractiveElement as _, IntoElement, ParentElement as _, Styled,
Window, div, prelude::FluentBuilder as _,
};
/// Creates the accordion collection. Each item receives its expanded state from the caller.
pub fn accordion(id: impl Into<ElementId>, cx: &App) -> Accordion {
Accordion::new(id)
.flex()
.flex_col()
.w_full()
.font_family(UiTheme::read(cx).fonts.body.clone())
}
/// Creates an item with the Nova divider.
pub fn accordion_item(cx: &App) -> AccordionItem {
AccordionItem::new()
.border_b_1()
.border_color(UiTheme::read(cx).colors.border)
}
/// Creates a heading around a trigger.
pub fn accordion_header(trigger: AccordionTrigger) -> AccordionHeader {
AccordionHeader::new(trigger).flex()
}
/// Creates a keyboard-focusable disclosure trigger with a chevron.
pub fn accordion_trigger(
id: impl Into<ElementId>,
open: bool,
disabled: bool,
cx: &App,
) -> AccordionTrigger {
let theme = UiTheme::read(cx);
let colors = theme.colors;
AccordionTrigger::new(id)
.open(open)
.disabled(disabled)
.when(!disabled, |b| {
b.tab_index(0).cursor_pointer().hover(|s| s.underline())
})
.when(disabled, |b| {
disabled::style(b)
.capture_any_mouse_down(|_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.capture_any_mouse_up(|_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.capture_key_down(|event, window, cx| {
if matches!(event.keystroke.key.as_str(), "enter" | "space") {
window.prevent_default();
cx.stop_propagation();
}
})
})
.w_full()
.flex()
.flex_row_reverse()
.items_start()
.justify_between()
.rounded(theme.radius.lg)
.border_1()
.border_color(colors.background.opacity(0.))
.py(theme.space(2.5))
.text_left()
.font_family(theme.fonts.body.clone())
.font_weight(FontWeight::MEDIUM)
.text_size(theme.text(14.))
.text_color(colors.foreground)
.focus_visible(move |s| s.border_color(colors.ring))
.child(super::theme::disclosure_icon(
lucide(LucideIcon::ChevronDown)
.size(theme.space(4.))
.text_color(colors.muted_foreground),
open,
))
}
/// Creates a controlled panel, retaining its content only while the exit motion runs.
pub fn accordion_content(
id: impl Into<ElementId>,
open: bool,
content: impl IntoElement,
window: &mut Window,
cx: &mut App,
) -> AccordionPanel {
let id = id.into();
let presence = super::theme::presence(id.clone(), open, window, cx);
let theme = UiTheme::read(cx);
AccordionPanel::new()
.open(open)
.keep_mounted(presence.should_render() && (open || presence.progress > 0.))
.child(gpui_kit::base::motion::MotionReveal::new(
id,
presence.progress,
div()
.w_full()
.pb(theme.space(2.5))
.font_family(theme.fonts.body.clone())
.text_size(theme.text(14.))
.text_color(theme.colors.foreground)
.child(content)
.into_any_element(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use gpui_kit::{
Context, Modifiers, Render, StatefulInteractiveElement as _, TestAppContext, point, px,
};
use std::{cell::Cell, rc::Rc};
#[gpui_kit::test]
fn disabled_trigger_blocks_caller_click_handlers(cx: &mut TestAppContext) {
struct Probe {
disabled: bool,
clicks: Rc<Cell<usize>>,
}
impl Render for Probe {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let clicks = self.clicks.clone();
accordion_trigger("trigger", false, self.disabled, cx)
.size(px(100.))
.on_click(move |_, _, _| clicks.set(clicks.get() + 1))
}
}
cx.update(|cx| UiTheme::set(cx, UiTheme::neutral_light()));
for disabled in [false, true] {
let clicks = Rc::new(Cell::new(0));
let (_, visual) = cx.add_window_view({
let clicks = clicks.clone();
move |_, _| Probe { disabled, clicks }
});
visual.update(|window, cx| window.draw(cx).clear(cx));
visual.simulate_click(point(px(10.), px(10.)), Modifiers::default());
assert_eq!(clicks.get(), usize::from(!disabled));
}
}
}
Platform notes
Visual reference: shadcn/ui 4.19.0, Neutral Nova. Implementation:
crates/ui/registry/accordion/accordion.rs, using GPUI Kit 0.6.4.
Kit supplies the accordion parts; the application passes each expanded state and handles trigger clicks. gpuicn adds Nova borders, spacing, typography and chevrons. Single or multiple expansion is an application choice.
The Rust source and Usage example define the supported API. Browser DOM/CSS behavior, exact exit animations and cross-platform screen-reader parity are not implied by the native component. See migration qualification for the checks completed on this revision.